llama_cpp_4/mtmd.rs
1//! Safe wrappers for the `libmtmd` multimodal support library.
2//!
3//! `libmtmd` extends llama.cpp with the ability to encode image and audio
4//! inputs (bitmaps) into token embeddings that can then be fed into a
5//! standard [`crate::context::LlamaContext::decode`] call alongside normal text tokens.
6//!
7//! # Quick-start
8//!
9//! ```no_run
10//! # #[cfg(feature = "mtmd")]
11//! # {
12//! use std::path::Path;
13//! use llama_cpp_4::{
14//! llama_backend::LlamaBackend,
15//! model::{LlamaModel, params::LlamaModelParams, AddBos},
16//! context::params::LlamaContextParams,
17//! mtmd::{MtmdContext, MtmdContextParams, MtmdBitmap, MtmdInputChunks, MtmdInputText},
18//! };
19//!
20//! let backend = LlamaBackend::init().unwrap();
21//! let model = LlamaModel::load_from_file(&backend, Path::new("model.gguf"),
22//! &LlamaModelParams::default()).unwrap();
23//! let mut lctx = model.new_context(&backend, LlamaContextParams::default()).unwrap();
24//!
25//! // Load the multimodal projector (mmproj) model.
26//! let ctx_params = MtmdContextParams::default();
27//! let mut mtmd_ctx = MtmdContext::init_from_file(Path::new("mmproj.gguf"), &model, ctx_params)
28//! .unwrap();
29//!
30//! // Load an image from a file.
31//! let bitmap = MtmdBitmap::from_file(&mtmd_ctx, Path::new("image.jpg")).unwrap();
32//!
33//! // Tokenize a prompt that contains the media marker.
34//! let marker = MtmdContext::default_marker();
35//! let prompt = format!("Describe this image: {marker}");
36//! let text = MtmdInputText::new(&prompt, true, true);
37//! let bitmaps = [&bitmap];
38//!
39//! let mut chunks = MtmdInputChunks::new();
40//! mtmd_ctx.tokenize(&text, &bitmaps, &mut chunks).unwrap();
41//!
42//! // Evaluate / decode all chunks.
43//! let n_batch = lctx.n_batch() as i32;
44//! let mut n_past = 0i32;
45//! mtmd_ctx.eval_chunks(&mut lctx, &chunks, 0, 0, n_batch, true, &mut n_past).unwrap();
46//! # }
47//! ```
48//!
49//! # Feature flag
50//!
51//! This module is only compiled when the `mtmd` Cargo feature is enabled.
52
53use std::ffi::{CStr, CString};
54use std::os::raw::c_void;
55use std::path::Path;
56use std::ptr::NonNull;
57use std::slice;
58
59use llama_cpp_sys_4 as sys;
60
61use crate::model::LlamaModel;
62
63// ─────────────────────────────────────────────────────────────────────────────
64// Error types
65// ─────────────────────────────────────────────────────────────────────────────
66
67/// All errors that can be returned by the mtmd module.
68#[derive(Debug, thiserror::Error)]
69pub enum MtmdError {
70 /// The context could not be created (e.g. bad mmproj file).
71 #[error("failed to create mtmd context (null return from mtmd_init_from_file)")]
72 ContextCreateFailed,
73
74 /// The bitmap could not be created.
75 #[error("failed to create mtmd bitmap")]
76 BitmapCreateFailed,
77
78 /// A path could not be converted to a valid C string (embedded NUL byte or non-UTF-8).
79 #[error("invalid path: {0}")]
80 InvalidPath(#[from] std::ffi::NulError),
81
82 /// A path was not representable as UTF-8.
83 #[error("path is not valid UTF-8")]
84 PathNotUtf8,
85
86 /// `mtmd_tokenize` returned an error code.
87 #[error("tokenize error: code {0} (1 = bitmap count mismatch, 2 = preprocessing error)")]
88 TokenizeError(i32),
89
90 /// `mtmd_encode_chunk` returned a non-zero code.
91 #[error("encode error: code {0}")]
92 EncodeError(i32),
93
94 /// `mtmd_input_chunk_save` returned a non-zero code.
95 #[error("chunk save error: code {0}")]
96 ChunkSaveFailed(i32),
97
98 /// `mtmd_input_chunk_load` returned null — the buffer was not a chunk
99 /// this build can restore.
100 #[error("failed to load an input chunk from the buffer")]
101 ChunkLoadFailed,
102
103 /// A chunk could not be added to a batch.
104 #[error("batch add error: code {0} (2 = batch full, 3 = incompatible with existing chunks)")]
105 BatchAddFailed(i32),
106
107 /// `mtmd_batch_init` returned null.
108 #[error("failed to create an mtmd batch")]
109 BatchCreateFailed,
110
111 /// `mtmd_helper_eval_chunks` (or single-chunk variant) returned a non-zero code.
112 #[error("eval error: code {0}")]
113 EvalError(i32),
114
115 /// A video stream could not be opened. Common causes: the build lacks
116 /// video support (`MTMD_VIDEO` was OFF), `ffmpeg`/`ffprobe` is not on
117 /// `PATH`, or the file is unreadable.
118 #[error("failed to open video stream (null return from mtmd_helper_video_init)")]
119 VideoInitFailed,
120
121 /// `mtmd_helper_video_read_next` returned an error code (`-2`).
122 #[error("video read error: code {0}")]
123 VideoReadError(i32),
124}
125
126/// A convenience `Result` alias for this module.
127pub type Result<T> = std::result::Result<T, MtmdError>;
128
129/// Progress callback invoked while the CLIP/mmproj weights are loading.
130///
131/// Receives a value in `[0.0, 1.0]`. Return `true` to continue loading or
132/// `false` to abort immediately.
133pub type MtmdProgressCallback = unsafe extern "C" fn(progress: f32, user_data: *mut c_void) -> bool;
134
135// ─────────────────────────────────────────────────────────────────────────────
136// MtmdContextParams
137// ─────────────────────────────────────────────────────────────────────────────
138
139/// Parameters used when creating an [`MtmdContext`].
140///
141/// Obtain a default-initialised instance via [`MtmdContextParams::default()`].
142pub struct MtmdContextParams {
143 pub(crate) params: sys::mtmd_context_params,
144}
145
146impl std::fmt::Debug for MtmdContextParams {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("MtmdContextParams")
149 .field("use_gpu", &self.params.use_gpu)
150 .field("print_timings", &self.params.print_timings)
151 .field("n_threads", &self.params.n_threads)
152 .field("warmup", &self.params.warmup)
153 .field("image_min_tokens", &self.params.image_min_tokens)
154 .field("image_max_tokens", &self.params.image_max_tokens)
155 .finish()
156 }
157}
158
159impl Default for MtmdContextParams {
160 fn default() -> Self {
161 // SAFETY: no pointer is involved; upstream returns a fully initialised struct by value.
162 let params = unsafe { sys::mtmd_context_params_default() };
163 Self { params }
164 }
165}
166
167impl MtmdContextParams {
168 /// Whether to run the vision/audio encoder on the GPU (default: `true`).
169 #[must_use]
170 pub fn use_gpu(mut self, v: bool) -> Self {
171 self.params.use_gpu = v;
172 self
173 }
174
175 /// Whether to print timing info after each encode (default: `false`).
176 #[must_use]
177 pub fn print_timings(mut self, v: bool) -> Self {
178 self.params.print_timings = v;
179 self
180 }
181
182 /// Number of threads used for the vision encoder (default taken from
183 /// `mtmd_context_params_default`).
184 #[must_use]
185 pub fn n_threads(mut self, n: i32) -> Self {
186 self.params.n_threads = n;
187 self
188 }
189
190 /// Whether to run a warm-up encode pass after initialisation.
191 #[must_use]
192 pub fn warmup(mut self, v: bool) -> Self {
193 self.params.warmup = v;
194 self
195 }
196
197 /// Minimum number of image tokens (0 = use model default).
198 #[must_use]
199 pub fn image_min_tokens(mut self, n: i32) -> Self {
200 self.params.image_min_tokens = n;
201 self
202 }
203
204 /// Maximum number of image tokens (0 = use model default).
205 #[must_use]
206 pub fn image_max_tokens(mut self, n: i32) -> Self {
207 self.params.image_max_tokens = n;
208 self
209 }
210
211 /// Maximum number of multimodal output tokens per batch.
212 ///
213 /// Maps to `mtmd_context_params.batch_max_tokens`. The upstream default
214 /// is `1024`. Increase for large images or long audio segments.
215 ///
216 /// # Examples
217 ///
218 /// ```rust
219 /// # #[cfg(feature = "mtmd")]
220 /// # {
221 /// use llama_cpp_4::mtmd::MtmdContextParams;
222 /// let params = MtmdContextParams::default().with_batch_max_tokens(2048);
223 /// assert_eq!(params.batch_max_tokens(), 2048);
224 /// # }
225 /// ```
226 #[must_use]
227 pub fn with_batch_max_tokens(mut self, n: i32) -> Self {
228 self.params.batch_max_tokens = n;
229 self
230 }
231
232 /// Get the configured batch token cap (`batch_max_tokens`).
233 #[must_use]
234 pub fn batch_max_tokens(&self) -> i32 {
235 self.params.batch_max_tokens
236 }
237
238 /// Set flash-attention mode for the vision encoder.
239 ///
240 /// Maps to `mtmd_context_params.flash_attn_type`. Uses the same
241 /// [`crate::context::params::LlamaFlashAttnType`] enum as text contexts.
242 ///
243 /// # Examples
244 ///
245 /// ```rust
246 /// # #[cfg(feature = "mtmd")]
247 /// # {
248 /// use llama_cpp_4::context::params::LlamaFlashAttnType;
249 /// use llama_cpp_4::mtmd::MtmdContextParams;
250 /// let params = MtmdContextParams::default()
251 /// .with_flash_attn_type(LlamaFlashAttnType::Auto);
252 /// assert_eq!(params.flash_attn_type(), LlamaFlashAttnType::Auto);
253 /// # }
254 /// ```
255 #[must_use]
256 pub fn with_flash_attn_type(
257 mut self,
258 flash_attn_type: crate::context::params::LlamaFlashAttnType,
259 ) -> Self {
260 self.params.flash_attn_type = flash_attn_type.into();
261 self
262 }
263
264 /// Get flash-attention mode for the vision encoder.
265 #[must_use]
266 pub fn flash_attn_type(&self) -> crate::context::params::LlamaFlashAttnType {
267 crate::context::params::LlamaFlashAttnType::from(self.params.flash_attn_type)
268 }
269
270 /// Register a callback invoked while mmproj weights load.
271 ///
272 /// Maps to `mtmd_context_params.progress_callback`. Pass `None` to disable
273 /// progress reporting. The callback may return `false` to abort loading
274 /// early; see [`MtmdProgressCallback`].
275 ///
276 /// `user_data` is forwarded to each invocation and must remain valid until
277 /// [`MtmdContext::init_from_file`] returns.
278 #[must_use]
279 pub fn with_progress_callback(
280 mut self,
281 callback: Option<MtmdProgressCallback>,
282 user_data: *mut c_void,
283 ) -> Self {
284 self.params.progress_callback = callback;
285 self.params.progress_callback_user_data = user_data;
286 self
287 }
288
289 /// Override the media marker string (e.g. `"<image>"`).
290 ///
291 /// The provided string must not contain interior NUL bytes. Pass `None`
292 /// to use the library default (`mtmd_default_marker()`).
293 ///
294 /// **Note:** the `CString` is stored inside the params so the pointer
295 /// remains valid as long as this `MtmdContextParams` lives.
296 /// # Errors
297 ///
298 /// Returns [`MtmdError`] if the marker string contains a NUL byte.
299 pub fn media_marker(mut self, marker: Option<&str>) -> std::result::Result<Self, MtmdError> {
300 match marker {
301 None => {
302 self.params.media_marker = std::ptr::null();
303 Ok(self)
304 }
305 Some(s) => {
306 let cs = CString::new(s)?;
307 self.params.media_marker = cs.as_ptr();
308 // Leak the CString so the raw pointer stays valid; the caller
309 // must ensure the params don't outlive the string. Since
310 // MtmdContextParams is consumed by MtmdContext::init_from_file,
311 // this is safe.
312 std::mem::forget(cs);
313 Ok(self)
314 }
315 }
316 }
317}
318
319// ─────────────────────────────────────────────────────────────────────────────
320// MtmdContext
321// ─────────────────────────────────────────────────────────────────────────────
322
323/// The main multimodal context.
324///
325/// Wraps a `mtmd_context *`. This context is tied to a specific mmproj model
326/// file and a loaded [`LlamaModel`]. It is safe to share across threads for
327/// `tokenize` calls (read-only), but `encode_chunk` / eval helpers mutate
328/// internal state and must not be called concurrently.
329pub struct MtmdContext {
330 ptr: NonNull<sys::mtmd_context>,
331 /// Elements in the buffer `mtmd_get_output_embd` points at, recorded by
332 /// the last successful [`encode_chunk`](MtmdContext::encode_chunk).
333 ///
334 /// upstream documents the size only in prose — `n_embd_inp(model) *
335 /// chunk.n_tokens()` — and offers no accessor for it, so the only way for
336 /// [`output_embd`](MtmdContext::output_embd) to be a safe function is to
337 /// remember it at the point it is known. `0` before any encode, which is
338 /// also what an empty slice means.
339 last_output_elements: usize,
340}
341
342// SAFETY: `Send` is sound because nothing in `mtmd_context` is tied to the
343// thread that created it.
344//
345// `Sync` is sound because every operation that mutates the context now takes
346// `&mut self` — `encode_chunk` and the three eval/decode helpers — so a shared
347// `&MtmdContext` can only reach the read-only accessors and `tokenize`, which
348// upstream synchronises internally. That was *not* true before: those methods
349// took `&self`, which let two threads encode through a shared reference while
350// a third held a slice from `output_embd`. The signatures are what makes this
351// claim hold, so moving any of them back to `&self` invalidates it.
352unsafe impl Send for MtmdContext {}
353// SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
354// remaining arguments are scalars or pointers valid for the call.
355unsafe impl Sync for MtmdContext {}
356
357impl std::fmt::Debug for MtmdContext {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("MtmdContext")
360 .field("ptr", &self.ptr)
361 .field("last_output_elements", &self.last_output_elements)
362 .finish()
363 }
364}
365
366impl Drop for MtmdContext {
367 fn drop(&mut self) {
368 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
369 // reads it afterwards.
370 unsafe { sys::mtmd_free(self.ptr.as_ptr()) }
371 }
372}
373
374impl MtmdContext {
375 /// Returns the default media marker string used in prompts
376 /// (currently `"<__media__>"`).
377 #[must_use]
378 pub fn default_marker() -> &'static str {
379 // SAFETY: a read through a live handle (or no handle at all, for the
380 // library-wide accessors); the returned pointer is only converted
381 // below, never freed here.
382 let ptr = unsafe { sys::mtmd_default_marker() };
383 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
384 // handle-owned NUL-terminated string that outlives the borrow taken here.
385 unsafe { CStr::from_ptr(ptr) }
386 .to_str()
387 .unwrap_or("<__media__>")
388 }
389
390 /// Initialise a multimodal context from an mmproj GGUF file.
391 ///
392 /// # Parameters
393 ///
394 /// * `mmproj_path` – path to the mmproj `.gguf` file
395 /// * `text_model` – the already-loaded text model
396 /// * `params` – context parameters (use [`MtmdContextParams::default()`])
397 ///
398 /// # Errors
399 ///
400 /// Returns [`MtmdError::ContextCreateFailed`] if the underlying C call
401 /// returns a null pointer.
402 #[allow(clippy::needless_pass_by_value)]
403 pub fn init_from_file(
404 mmproj_path: impl AsRef<Path>,
405 text_model: &LlamaModel,
406 params: MtmdContextParams,
407 ) -> Result<Self> {
408 let path = mmproj_path
409 .as_ref()
410 .to_str()
411 .ok_or(MtmdError::PathNotUtf8)?;
412 let c_path = CString::new(path)?;
413
414 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
415 // and the model/context handles are borrowed — and the result is checked
416 // for null before being wrapped.
417 let ptr = unsafe {
418 sys::mtmd_init_from_file(c_path.as_ptr(), text_model.model.as_ptr(), params.params)
419 };
420
421 let ptr = NonNull::new(ptr).ok_or(MtmdError::ContextCreateFailed)?;
422 Ok(Self {
423 ptr,
424 last_output_elements: 0,
425 })
426 }
427
428 // ── Logging ──────────────────────────────────────────────────────────
429
430 /// Silence all clip/mtmd log output by installing a no-op callback.
431 ///
432 /// Call this right after [`init_from_file`](Self::init_from_file) to
433 /// suppress the verbose `clip_model_loader: tensor[N]…` lines that
434 /// clip.cpp emits to its own private logger (separate from `llama_log_set`).
435 pub fn void_logs() {
436 unsafe extern "C" fn noop(
437 _level: sys::ggml_log_level,
438 _text: *const ::std::os::raw::c_char,
439 _ud: *mut ::std::os::raw::c_void,
440 ) {
441 }
442 // SAFETY: the callback is a plain `extern "C"` function with no captured
443 // state, and the user-data pointer is null, which upstream allows.
444 unsafe { sys::mtmd_log_set(Some(noop), std::ptr::null_mut()) };
445 }
446
447 /// Like [`void_logs`](Self::void_logs), but additionally silences logs
448 /// emitted by the `mtmd_helper_*` layer (e.g. eval/decode helpers).
449 ///
450 /// Internally calls `mtmd_helper_log_set` which also routes through
451 /// `mtmd_log_set`, so this is a strict superset of `void_logs`.
452 pub fn void_helper_logs() {
453 unsafe extern "C" fn noop(
454 _level: sys::ggml_log_level,
455 _text: *const ::std::os::raw::c_char,
456 _ud: *mut ::std::os::raw::c_void,
457 ) {
458 }
459 // SAFETY: the callback is a plain `extern "C"` function with no captured
460 // state, and the user-data pointer is null, which upstream allows.
461 unsafe { sys::mtmd_helper_log_set(Some(noop), std::ptr::null_mut()) };
462 }
463
464 // ── Capability queries ────────────────────────────────────────────────
465
466 /// Returns `true` if the model supports vision (image) input.
467 #[must_use]
468 pub fn supports_vision(&self) -> bool {
469 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
470 // remaining arguments are scalars or pointers valid for the call.
471 unsafe { sys::mtmd_support_vision(self.ptr.as_ptr()) }
472 }
473
474 /// Returns `true` if the model supports audio input.
475 #[must_use]
476 pub fn supports_audio(&self) -> bool {
477 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
478 // remaining arguments are scalars or pointers valid for the call.
479 unsafe { sys::mtmd_support_audio(self.ptr.as_ptr()) }
480 }
481
482 /// Returns `true` if this build and model support video input.
483 ///
484 /// Video support additionally requires `ffmpeg`/`ffprobe` to be available
485 /// at runtime (see [`MtmdVideo`]). Wraps `mtmd_helper_support_video`.
486 #[must_use]
487 #[deprecated(
488 since = "0.10.0",
489 note = "upstream deprecated `mtmd_helper_support_video`; use `capabilities(model).video`"
490 )]
491 pub fn supports_video(&self) -> bool {
492 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
493 // remaining arguments are scalars or pointers valid for the call.
494 unsafe { sys::mtmd_helper_support_video(self.ptr.as_ptr()) }
495 }
496
497 /// Everything this model and projector can take in and produce.
498 ///
499 /// Replaces the two single-purpose predicates upstream deprecated in
500 /// `b12032` ([`supports_video`](Self::supports_video) and
501 /// [`model_can_chat`](Self::model_can_chat)), and reports six more
502 /// capabilities besides — notably whether the model can *generate* audio,
503 /// and whether it accepts a speaker reference for voice cloning.
504 ///
505 /// Wraps `mtmd_helper_get_capabilities`. Note it is keyed on the **model**,
506 /// not a context, because that is what the capabilities are a property of.
507 #[must_use]
508 pub fn capabilities(&self, model: &crate::model::LlamaModel) -> MtmdCapabilities {
509 // SAFETY: both pointers are borrowed from live values for the duration
510 // of the call, and upstream documents a null `mctx` as valid (meaning a
511 // text-only model) — ours is never null, which is the stronger case.
512 let raw =
513 unsafe { sys::mtmd_helper_get_capabilities(model.model.as_ptr(), self.ptr.as_ptr()) };
514 MtmdCapabilities {
515 chat: raw.chat,
516 vision: raw.vision,
517 audio: raw.audio,
518 video: raw.video,
519 audio_stream: raw.audio_stream,
520 text_gen: raw.text_gen,
521 audio_gen: raw.audio_gen,
522 audio_gen_spk_ref: raw.audio_gen_spk_ref,
523 }
524 }
525
526 /// Returns the media marker string configured for *this* context.
527 ///
528 /// Unlike [`default_marker`](Self::default_marker) (the library-wide
529 /// default), this reflects any override passed via
530 /// [`MtmdContextParams::media_marker`]. Wraps `mtmd_get_marker`.
531 #[must_use]
532 pub fn marker(&self) -> &str {
533 // SAFETY: a read through a live handle (or no handle at all, for the
534 // library-wide accessors); the returned pointer is only converted
535 // below, never freed here.
536 let ptr = unsafe { sys::mtmd_get_marker(self.ptr.as_ptr()) };
537 if ptr.is_null() {
538 return Self::default_marker();
539 }
540 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
541 // handle-owned NUL-terminated string that outlives the borrow taken here.
542 unsafe { CStr::from_ptr(ptr) }
543 .to_str()
544 .unwrap_or_else(|_| Self::default_marker())
545 }
546
547 /// Returns the audio sample rate in Hz (e.g. `16_000` for Whisper), or `-1` if
548 /// audio is not supported.
549 #[must_use]
550 pub fn audio_sample_rate(&self) -> i32 {
551 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
552 // remaining arguments are scalars or pointers valid for the call.
553 unsafe { sys::mtmd_get_audio_sample_rate(self.ptr.as_ptr()) }
554 }
555
556 /// Whether `llama_decode` must use a non-causal attention mask when
557 /// decoding image embeddings for this model.
558 #[must_use]
559 pub fn decode_use_non_causal(&self, chunk: &MtmdInputChunk<'_>) -> bool {
560 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
561 // remaining arguments are scalars or pointers valid for the call.
562 unsafe { sys::mtmd_decode_use_non_causal(self.ptr.as_ptr(), chunk.as_ptr()) }
563 }
564
565 /// Whether the model uses M-RoPE for `llama_decode`.
566 #[must_use]
567 pub fn decode_use_mrope(&self) -> bool {
568 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
569 // remaining arguments are scalars or pointers valid for the call.
570 unsafe { sys::mtmd_decode_use_mrope(self.ptr.as_ptr()) }
571 }
572
573 // ── Core API ──────────────────────────────────────────────────────────
574
575 /// Tokenize a text prompt that contains one or more media markers.
576 ///
577 /// The number of `bitmaps` must equal the number of media markers in the
578 /// prompt text, otherwise [`MtmdError::TokenizeError`] with code `1` is returned.
579 ///
580 /// This call is **thread-safe** (shared `&self`).
581 ///
582 /// # Parameters
583 ///
584 /// * `text` – text + tokenisation options
585 /// * `bitmaps` – slice of [`MtmdBitmap`] references, one per media marker
586 /// * `output` – an [`MtmdInputChunks`] that will be populated with the result
587 ///
588 /// # Errors
589 ///
590 /// Returns [`MtmdError::TokenizeError`] if tokenization fails.
591 pub fn tokenize(
592 &self,
593 text: &MtmdInputText<'_>,
594 bitmaps: &[&MtmdBitmap],
595 output: &mut MtmdInputChunks,
596 ) -> Result<()> {
597 // The C signature is: mtmd_tokenize(..., mtmd_bitmap ** bitmaps, ...)
598 // where each element is a `const mtmd_bitmap *`. We build a Vec of
599 // `*const mtmd_bitmap` and pass a mutable pointer to its first element
600 // (i.e. `*mut *const mtmd_bitmap`) to satisfy the C API.
601 let mut bitmap_ptrs: Vec<*const sys::mtmd_bitmap> = bitmaps
602 .iter()
603 .map(|b| b.ptr.as_ptr().cast_const())
604 .collect();
605
606 // Length-delimited (llama.cpp #25548), so interior NULs are preserved.
607 let c_text = text.as_raw();
608
609 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
610 // remaining arguments are scalars or pointers valid for the call.
611 let ret = unsafe {
612 sys::mtmd_tokenize(
613 self.ptr.as_ptr(),
614 output.ptr.as_ptr(),
615 &raw const c_text,
616 bitmap_ptrs.as_mut_ptr(),
617 bitmap_ptrs.len(),
618 )
619 };
620
621 if ret != 0 {
622 return Err(MtmdError::TokenizeError(ret));
623 }
624 Ok(())
625 }
626
627 /// Tokenize an explicit sequence of parts, without media markers.
628 ///
629 /// [`Self::tokenize`] splices bitmaps in wherever the prompt contains the
630 /// media marker, which means the marker string has to be embedded in the
631 /// text and cannot itself be user content. This takes the interleaving
632 /// directly, so:
633 ///
634 /// - a marker appearing in user text is just text, not a splice point;
635 /// - `parse_special` is per text part, so a system prompt can enable
636 /// special tokens while user content does not.
637 ///
638 /// `add_special` applies once to the whole sequence — upstream ignores the
639 /// per-part flag.
640 ///
641 /// Wraps `mtmd_tokenize_from_parts`.
642 ///
643 /// # Errors
644 ///
645 /// Returns [`MtmdError::TokenizeError`] — code `1` means a part carried
646 /// both text and a bitmap, or neither.
647 pub fn tokenize_from_parts(
648 &self,
649 parts: &[MtmdInputPart<'_>],
650 add_special: bool,
651 output: &mut MtmdInputChunks,
652 ) -> Result<()> {
653 // Three levels have to stay alive across the call: the C text structs,
654 // the parts that point at them, and the array of pointers to those
655 // parts. Building them in that order keeps every borrow valid.
656 let raw_texts: Vec<sys::mtmd_input_text> = parts
657 .iter()
658 .filter_map(|part| match part {
659 MtmdInputPart::Text(text) => Some(text.as_raw()),
660 MtmdInputPart::Bitmap(_) => None,
661 })
662 .collect();
663
664 let mut next_text = 0usize;
665 let raw_parts: Vec<sys::mtmd_input_part> = parts
666 .iter()
667 .map(|part| match part {
668 MtmdInputPart::Text(_) => {
669 let raw = &raw_texts[next_text];
670 next_text += 1;
671 sys::mtmd_input_part {
672 text: std::ptr::from_ref(raw),
673 bitmap: std::ptr::null(),
674 }
675 }
676 MtmdInputPart::Bitmap(bitmap) => sys::mtmd_input_part {
677 text: std::ptr::null(),
678 bitmap: bitmap.ptr.as_ptr().cast_const(),
679 },
680 })
681 .collect();
682 let part_ptrs: Vec<*const sys::mtmd_input_part> =
683 raw_parts.iter().map(std::ptr::from_ref).collect();
684
685 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
686 // remaining arguments are scalars or pointers valid for the call.
687 let ret = unsafe {
688 sys::mtmd_tokenize_from_parts(
689 self.ptr.as_ptr(),
690 output.ptr.as_ptr(),
691 part_ptrs.as_ptr(),
692 part_ptrs.len(),
693 add_special,
694 )
695 };
696 if ret != 0 {
697 return Err(MtmdError::TokenizeError(ret));
698 }
699 Ok(())
700 }
701
702 /// Audio-generation capabilities of the loaded mmproj.
703 ///
704 /// Returns `None` when this projector cannot generate audio, which is the
705 /// case for every vision-only mmproj.
706 ///
707 /// Wraps `mtmd_gen_audio_get_info`.
708 #[must_use]
709 pub fn gen_audio_info(&self) -> Option<MtmdGenAudioInfo> {
710 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
711 // remaining arguments are scalars or pointers valid for the call.
712 let info = unsafe { sys::mtmd_gen_audio_get_info(self.ptr.as_ptr()) };
713 if info.type_ == sys::MTMD_GEN_AUDIO_TYPE_NONE {
714 return None;
715 }
716 let variant = if info.model_variant.is_null() {
717 None
718 } else {
719 Some(
720 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
721 // handle-owned NUL-terminated string that outlives the borrow taken here.
722 unsafe { CStr::from_ptr(info.model_variant) }
723 .to_string_lossy()
724 .into_owned(),
725 )
726 };
727 Some(MtmdGenAudioInfo {
728 pipeline: MtmdGenAudioType::from_raw(info.type_),
729 sample_rate: info.sample_rate,
730 model_variant: variant,
731 })
732 }
733
734 /// Whether this model and projector can be used for chat.
735 ///
736 /// Wraps `mtmd_helper_model_can_chat`.
737 #[must_use]
738 #[deprecated(
739 since = "0.10.0",
740 note = "upstream deprecated `mtmd_helper_model_can_chat`; use `capabilities(model).chat`"
741 )]
742 pub fn model_can_chat(&self, ctx: &crate::context::LlamaContext<'_>) -> bool {
743 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
744 // remaining arguments are scalars or pointers valid for the call.
745 unsafe { sys::mtmd_helper_model_can_chat(ctx.context.as_ptr(), self.ptr.as_ptr()) }
746 }
747
748 /// Encode a single input chunk (image or audio) and store the resulting
749 /// embeddings inside the context.
750 ///
751 /// After a successful call, the embeddings can be retrieved with
752 /// [`MtmdContext::output_embd`].
753 ///
754 /// This call is **NOT thread-safe**.
755 ///
756 /// # Errors
757 ///
758 /// Returns [`MtmdError::EncodeError`] if encoding fails.
759 pub fn encode_chunk(&mut self, model: &LlamaModel, chunk: &MtmdInputChunk<'_>) -> Result<()> {
760 // SAFETY: a live `&mut self` gives an exclusive, non-null context, and
761 // `chunk` is owned by the caller and valid for the call.
762 let ret = unsafe { sys::mtmd_encode_chunk(self.ptr.as_ptr(), chunk.ptr) };
763 if ret != 0 {
764 return Err(MtmdError::EncodeError(ret));
765 }
766 // Record what the output buffer now holds, while it is knowable.
767 // Saturating rather than wrapping: a product this large cannot be a
768 // real buffer, and a wrapped value would be a short slice over a long
769 // one, which is the dangerous direction.
770 self.last_output_elements = usize::try_from(model.n_embd_inp())
771 .unwrap_or(0)
772 .saturating_mul(chunk.n_tokens());
773 Ok(())
774 }
775
776 /// Return a slice over the embeddings produced by the last
777 /// [`encode_chunk`](Self::encode_chunk) call.
778 ///
779 /// The length (in `f32` elements) is:
780 /// ```text
781 /// n_embd_inp(model) * chunk.n_tokens()
782 /// ```
783 ///
784 /// # Safety
785 ///
786 /// The returned slice is valid until the next call that mutates the
787 /// context (e.g. another `encode_chunk`).
788 #[must_use]
789 pub fn output_embd(&self) -> &[f32] {
790 if self.last_output_elements == 0 {
791 return &[];
792 }
793 // SAFETY: the pointer belongs to this context and is non-null once an
794 // encode has run — which `last_output_elements != 0` implies, since it
795 // is only set by a successful `encode_chunk`. That same call recorded
796 // the element count from the chunk it encoded, so the slice stays
797 // inside the buffer. The borrow is tied to `&self` and `encode_chunk`
798 // takes `&mut self`, so the slice cannot outlive the data.
799 unsafe {
800 let ptr = sys::mtmd_get_output_embd(self.ptr.as_ptr());
801 if ptr.is_null() {
802 return &[];
803 }
804 slice::from_raw_parts(ptr, self.last_output_elements)
805 }
806 }
807
808 // ── Helper API ────────────────────────────────────────────────────────
809
810 /// High-level helper: evaluate (decode) all chunks in sequence.
811 ///
812 /// * Text chunks are decoded via `llama_decode`.
813 /// * Image/audio chunks are first encoded with `mtmd_encode_chunk` and
814 /// then decoded via `llama_decode`.
815 ///
816 /// On success `new_n_past` is updated with the new past position.
817 ///
818 /// This call is **NOT thread-safe**.
819 ///
820 /// # Parameters
821 ///
822 /// * `lctx` – raw pointer to the llama context (from [`LlamaContext::as_ptr`])
823 /// * `chunks` – the tokenized chunks to evaluate
824 /// * `n_past` – current KV-cache position
825 /// * `seq_id` – sequence ID
826 /// * `n_batch` – maximum batch size (must be ≥ 1)
827 /// * `logits_last` – if `true`, compute logits only for the final token
828 /// * `new_n_past` – updated KV-cache position after the call
829 ///
830 /// # Errors
831 ///
832 /// Returns [`MtmdError::EvalError`] if evaluation fails.
833 #[allow(clippy::too_many_arguments)]
834 pub fn eval_chunks(
835 &mut self,
836 lctx: &mut crate::context::LlamaContext<'_>,
837 chunks: &MtmdInputChunks,
838 n_past: i32,
839 seq_id: i32,
840 n_batch: i32,
841 logits_last: bool,
842 new_n_past: &mut i32,
843 ) -> Result<()> {
844 // SAFETY: both contexts are live and non-null — the mtmd one
845 // through `&self`, the llama one through an exclusive `&mut`
846 // borrow, which is what makes decoding into it sound. The
847 // remaining arguments are scalars llama.cpp range-checks, and
848 // `new_n_past` is a caller-owned `i32` written through.
849 let ret = unsafe {
850 sys::mtmd_helper_eval_chunks(
851 self.ptr.as_ptr(),
852 lctx.context.as_ptr(),
853 chunks.ptr.as_ptr(),
854 n_past,
855 seq_id,
856 n_batch,
857 logits_last,
858 new_n_past,
859 )
860 };
861 // These encode chunks internally, so the output buffer no longer
862 // matches what `encode_chunk` last recorded — and the chunk sizes are
863 // not visible from here. Forget the length rather than hand out a
864 // slice sized for a different chunk; `output_embd` then reports empty.
865 self.last_output_elements = 0;
866 if ret != 0 {
867 return Err(MtmdError::EvalError(ret));
868 }
869 Ok(())
870 }
871
872 /// High-level helper: evaluate a single chunk.
873 ///
874 /// Works identically to [`eval_chunks`](Self::eval_chunks) but operates on
875 /// one chunk at a time.
876 ///
877 /// # Errors
878 ///
879 /// Returns [`MtmdError::EvalError`] if evaluation fails.
880 #[allow(clippy::too_many_arguments)]
881 pub fn eval_chunk_single(
882 &mut self,
883 lctx: &mut crate::context::LlamaContext<'_>,
884 chunk: &MtmdInputChunk<'_>,
885 n_past: i32,
886 seq_id: i32,
887 n_batch: i32,
888 logits_last: bool,
889 new_n_past: &mut i32,
890 ) -> Result<()> {
891 // SAFETY: both contexts are live and non-null — the mtmd one
892 // through `&self`, the llama one through an exclusive `&mut`
893 // borrow, which is what makes decoding into it sound. The
894 // remaining arguments are scalars llama.cpp range-checks, and
895 // `new_n_past` is a caller-owned `i32` written through.
896 let ret = unsafe {
897 sys::mtmd_helper_eval_chunk_single(
898 self.ptr.as_ptr(),
899 lctx.context.as_ptr(),
900 chunk.ptr,
901 n_past,
902 seq_id,
903 n_batch,
904 logits_last,
905 new_n_past,
906 )
907 };
908 // These encode chunks internally, so the output buffer no longer
909 // matches what `encode_chunk` last recorded — and the chunk sizes are
910 // not visible from here. Forget the length rather than hand out a
911 // slice sized for a different chunk; `output_embd` then reports empty.
912 self.last_output_elements = 0;
913 if ret != 0 {
914 return Err(MtmdError::EvalError(ret));
915 }
916 Ok(())
917 }
918
919 /// Decode an image/audio chunk whose embeddings have already been
920 /// computed (e.g. via [`encode_chunk`](Self::encode_chunk) followed by
921 /// [`output_embd`](Self::output_embd)).
922 ///
923 /// Unlike [`eval_chunk_single`](Self::eval_chunk_single), this helper
924 /// handles batching plus the non-causal-attention setup required by
925 /// some models (e.g. Gemma 3, Gemma 4 audio) and the M-RoPE position
926 /// layout. Use it when the embeddings are already in hand and you want
927 /// the helper to take care of `llama_decode` plumbing.
928 ///
929 /// `encoded_embd` must contain `mtmd_image_tokens_get_n_tokens(chunk) *
930 /// llama_model_n_embd_inp(model)` `f32` elements. This call is **NOT
931 /// thread-safe**.
932 ///
933 /// # Errors
934 ///
935 /// Returns [`MtmdError::EvalError`] with code `-1` if `chunk` is not an
936 /// image/audio chunk, or `1` if `llama_decode` fails.
937 #[allow(clippy::too_many_arguments)]
938 pub fn decode_image_chunk(
939 &mut self,
940 lctx: &mut crate::context::LlamaContext<'_>,
941 chunk: &MtmdInputChunk<'_>,
942 encoded_embd: &[f32],
943 n_past: i32,
944 seq_id: i32,
945 n_batch: i32,
946 new_n_past: &mut i32,
947 ) -> Result<()> {
948 // SAFETY: both contexts are live and non-null — the mtmd one
949 // through `&self`, the llama one through an exclusive `&mut`
950 // borrow, which is what makes decoding into it sound. The
951 // remaining arguments are scalars llama.cpp range-checks, and
952 // `new_n_past` is a caller-owned `i32` written through.
953 let ret = unsafe {
954 sys::mtmd_helper_decode_image_chunk(
955 self.ptr.as_ptr(),
956 lctx.context.as_ptr(),
957 chunk.ptr,
958 encoded_embd.as_ptr().cast_mut(),
959 n_past,
960 seq_id,
961 n_batch,
962 new_n_past,
963 // No post-decode callback; preserves prior single-shot behavior.
964 None,
965 std::ptr::null_mut(),
966 )
967 };
968 // These encode chunks internally, so the output buffer no longer
969 // matches what `encode_chunk` last recorded — and the chunk sizes are
970 // not visible from here. Forget the length rather than hand out a
971 // slice sized for a different chunk; `output_embd` then reports empty.
972 self.last_output_elements = 0;
973 if ret != 0 {
974 return Err(MtmdError::EvalError(ret));
975 }
976 Ok(())
977 }
978
979 /// Returns a raw pointer to the underlying `mtmd_context`.
980 ///
981 /// # Safety
982 ///
983 /// The returned pointer is valid for the lifetime of this `MtmdContext`.
984 /// The caller must not free it.
985 #[must_use]
986 pub fn as_ptr(&self) -> *mut sys::mtmd_context {
987 self.ptr.as_ptr()
988 }
989}
990
991// ─────────────────────────────────────────────────────────────────────────────
992// MtmdInputText
993// ─────────────────────────────────────────────────────────────────────────────
994
995/// Text input for [`MtmdContext::tokenize`].
996///
997/// The prompt string must contain the media marker (see
998/// [`MtmdContext::default_marker`]) once for every bitmap to be embedded.
999///
1000/// The prompt is passed to llama.cpp as an explicit pointer + length
1001/// (`mtmd_input_text::text_len`), so interior NUL bytes are preserved rather
1002/// than truncating the prompt — use [`MtmdInputText::from_bytes`] when the
1003/// prompt is not guaranteed NUL-free.
1004#[derive(Debug)]
1005pub struct MtmdInputText<'a> {
1006 /// Prompt bytes followed by a trailing NUL sentinel. The sentinel keeps the
1007 /// buffer usable by any C code that still treats `text` as a C string; it is
1008 /// excluded from `text_len`.
1009 text: Vec<u8>,
1010 /// Prompt length in bytes, excluding the trailing NUL sentinel. Passed
1011 /// verbatim as `mtmd_input_text::text_len`, so interior NULs are honoured.
1012 text_len: usize,
1013 add_special: bool,
1014 parse_special: bool,
1015 _marker: std::marker::PhantomData<&'a ()>,
1016}
1017
1018impl<'a> MtmdInputText<'a> {
1019 /// Borrow this as the C struct.
1020 ///
1021 /// The result points into `self`, so it must not outlive it.
1022 pub(crate) fn as_raw(&self) -> sys::mtmd_input_text {
1023 sys::mtmd_input_text {
1024 // Upstream reads exactly `text_len` bytes, so interior NULs survive.
1025 text: self.text.as_ptr().cast(),
1026 text_len: self.text_len,
1027 add_special: self.add_special,
1028 parse_special: self.parse_special,
1029 }
1030 }
1031
1032 /// Create a new `MtmdInputText` from a string prompt.
1033 ///
1034 /// * `text` – the prompt (interior NUL bytes are permitted and
1035 /// preserved)
1036 /// * `add_special` – whether to add BOS/EOS tokens
1037 /// * `parse_special` – whether to parse special tokens embedded in the text
1038 #[must_use]
1039 pub fn new(text: &'a str, add_special: bool, parse_special: bool) -> Self {
1040 Self::from_bytes(text.as_bytes(), add_special, parse_special)
1041 }
1042
1043 /// Create a new `MtmdInputText` from raw prompt bytes.
1044 ///
1045 /// Unlike a C string, the prompt length is carried explicitly, so `text`
1046 /// may contain interior NUL bytes without truncating the prompt. The bytes
1047 /// are copied into an owned, NUL-terminated buffer.
1048 ///
1049 /// * `text` – the prompt bytes (typically UTF-8)
1050 /// * `add_special` – whether to add BOS/EOS tokens
1051 /// * `parse_special` – whether to parse special tokens embedded in the text
1052 #[must_use]
1053 pub fn from_bytes(text: &'a [u8], add_special: bool, parse_special: bool) -> Self {
1054 let text_len = text.len();
1055 let mut buf = Vec::with_capacity(text_len + 1);
1056 buf.extend_from_slice(text);
1057 buf.push(0); // NUL sentinel, not counted in `text_len`
1058 Self {
1059 text: buf,
1060 text_len,
1061 add_special,
1062 parse_special,
1063 _marker: std::marker::PhantomData,
1064 }
1065 }
1066
1067 /// Try to create a new `MtmdInputText` from a string prompt.
1068 ///
1069 /// Retained for backwards compatibility. Interior NUL bytes are now
1070 /// permitted (see [`MtmdInputText::new`]), so this never returns `Err`;
1071 /// prefer [`new`](MtmdInputText::new).
1072 ///
1073 /// # Errors
1074 ///
1075 /// Never returns an error; the `Result` is kept for API stability.
1076 pub fn try_new(
1077 text: &'a str,
1078 add_special: bool,
1079 parse_special: bool,
1080 ) -> std::result::Result<Self, std::ffi::NulError> {
1081 Ok(Self::new(text, add_special, parse_special))
1082 }
1083}
1084
1085// ─────────────────────────────────────────────────────────────────────────────
1086// MtmdBitmap
1087// ─────────────────────────────────────────────────────────────────────────────
1088
1089/// An image or audio bitmap ready for multimodal encoding.
1090///
1091/// # Image bitmaps
1092///
1093/// The raw pixel data must be in RGBRGBRGB… (interleaved) format. The total
1094/// number of bytes must be `nx * ny * 3`.
1095///
1096/// # Audio bitmaps
1097///
1098/// The raw sample data must be little-endian `f32` PCM samples. The total
1099/// number of bytes must be `n_samples * 4`.
1100pub struct MtmdBitmap {
1101 ptr: NonNull<sys::mtmd_bitmap>,
1102}
1103
1104// SAFETY: a bitmap owns its `mtmd_bitmap` outright and nothing in it is tied
1105// to the creating thread.
1106unsafe impl Send for MtmdBitmap {}
1107// SAFETY: every method that mutates a bitmap takes `&mut self`, so a shared
1108// reference reaches only reads.
1109unsafe impl Sync for MtmdBitmap {}
1110
1111impl std::fmt::Debug for MtmdBitmap {
1112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1113 f.debug_struct("MtmdBitmap")
1114 .field("nx", &self.nx())
1115 .field("ny", &self.ny())
1116 .field("n_bytes", &self.n_bytes())
1117 .field("is_audio", &self.is_audio())
1118 .finish()
1119 }
1120}
1121
1122impl Drop for MtmdBitmap {
1123 fn drop(&mut self) {
1124 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
1125 // reads it afterwards.
1126 unsafe { sys::mtmd_bitmap_free(self.ptr.as_ptr()) }
1127 }
1128}
1129
1130impl MtmdBitmap {
1131 /// Create a bitmap from raw RGB pixel data.
1132 ///
1133 /// * `nx` – image width in pixels
1134 /// * `ny` – image height in pixels
1135 /// * `data` – raw pixel bytes in RGBRGB… format; must be `nx * ny * 3` bytes
1136 ///
1137 /// # Errors
1138 ///
1139 /// Returns [`MtmdError::BitmapCreateFailed`] if the underlying C call
1140 /// returns null.
1141 pub fn from_rgb(nx: u32, ny: u32, data: &[u8]) -> Result<Self> {
1142 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1143 // and the model/context handles are borrowed — and the result is checked
1144 // for null before being wrapped.
1145 let ptr = unsafe { sys::mtmd_bitmap_init(nx, ny, data.as_ptr()) };
1146 let ptr = NonNull::new(ptr).ok_or(MtmdError::BitmapCreateFailed)?;
1147 Ok(Self { ptr })
1148 }
1149
1150 /// Create an audio bitmap from PCM `f32` samples.
1151 ///
1152 /// * `samples` – slice of PCM float samples
1153 ///
1154 /// # Errors
1155 ///
1156 /// Returns [`MtmdError::BitmapCreateFailed`] if the underlying C call
1157 /// returns null.
1158 pub fn from_audio(samples: &[f32]) -> Result<Self> {
1159 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1160 // and the model/context handles are borrowed — and the result is checked
1161 // for null before being wrapped.
1162 let ptr = unsafe { sys::mtmd_bitmap_init_from_audio(samples.len(), samples.as_ptr()) };
1163 let ptr = NonNull::new(ptr).ok_or(MtmdError::BitmapCreateFailed)?;
1164 Ok(Self { ptr })
1165 }
1166
1167 /// Build an `MtmdBitmap` from a `mtmd_helper_bitmap_wrapper`, taking
1168 /// ownership of the `bitmap` and freeing any `video_ctx`.
1169 ///
1170 /// The `from_file`/`from_buf` constructors only support image/audio input.
1171 /// When the input is a video the helper returns a non-null `video_ctx`
1172 /// (an open ffmpeg stream) which is not representable as an `MtmdBitmap`;
1173 /// we free it here to avoid leaking it. Use [`MtmdVideo`] for video input.
1174 fn from_wrapper(wrapper: sys::mtmd_helper_bitmap_wrapper) -> Result<Self> {
1175 if !wrapper.video_ctx.is_null() {
1176 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
1177 // reads it afterwards.
1178 unsafe { sys::mtmd_helper_video_free(wrapper.video_ctx) };
1179 }
1180 let ptr = NonNull::new(wrapper.bitmap).ok_or(MtmdError::BitmapCreateFailed)?;
1181 Ok(Self { ptr })
1182 }
1183
1184 /// Load a bitmap from a file (image or audio).
1185 ///
1186 /// Supported image formats: JPEG, PNG, BMP, GIF, and others handled by
1187 /// `stb_image`. Supported audio formats: WAV, MP3, FLAC (via miniaudio).
1188 ///
1189 /// # Errors
1190 ///
1191 /// Returns [`MtmdError::BitmapCreateFailed`] if the file cannot be loaded.
1192 pub fn from_file(ctx: &MtmdContext, path: impl AsRef<Path>) -> Result<Self> {
1193 let path = path.as_ref().to_str().ok_or(MtmdError::PathNotUtf8)?;
1194 let c_path = CString::new(path)?;
1195
1196 // `placeholder = false`: load the real bitmap data (not a token-count
1197 // placeholder). For image/audio the returned `video_ctx` is always null.
1198 // SAFETY: the context handle and the `CString` path are both alive for
1199 // the call, and `mtmd_helper_init_opt_default()` returns an options
1200 // struct by value. The result is checked before being wrapped.
1201 let wrapper = unsafe {
1202 sys::mtmd_helper_bitmap_init_from_file(
1203 ctx.ptr.as_ptr(),
1204 c_path.as_ptr(),
1205 false,
1206 sys::mtmd_helper_init_opt_default(),
1207 )
1208 };
1209 Self::from_wrapper(wrapper)
1210 }
1211
1212 /// Load a bitmap from an in-memory buffer containing a file.
1213 ///
1214 /// The format is auto-detected (image vs audio via magic bytes).
1215 ///
1216 /// # Errors
1217 ///
1218 /// Returns [`MtmdError::BitmapCreateFailed`] if decoding fails.
1219 pub fn from_buf(ctx: &MtmdContext, buf: &[u8]) -> Result<Self> {
1220 // `placeholder = false`: load the real bitmap data (not a token-count
1221 // placeholder). For image/audio the returned `video_ctx` is always null.
1222 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1223 // and the model/context handles are borrowed — and the result is checked
1224 // for null before being wrapped.
1225 let wrapper = unsafe {
1226 sys::mtmd_helper_bitmap_init_from_buf(
1227 ctx.ptr.as_ptr(),
1228 buf.as_ptr(),
1229 buf.len(),
1230 false,
1231 sys::mtmd_helper_init_opt_default(),
1232 )
1233 };
1234 Self::from_wrapper(wrapper)
1235 }
1236
1237 /// Mark this bitmap as mergeable with an adjacent mergeable bitmap.
1238 ///
1239 /// Video-capable models such as Qwen-VL merge consecutive frames into one
1240 /// chunk (a temporal merge). [`MtmdVideo::read_next`] already sets this on
1241 /// the frames it produces; you only need it when you build frames yourself
1242 /// with [`Self::from_rgb`] and expect them to merge. Without it each frame
1243 /// becomes its own chunk, which costs tokens and loses temporal structure.
1244 ///
1245 /// Wraps `mtmd_bitmap_set_mergeable`.
1246 pub fn set_mergeable(&mut self, mergeable: bool) {
1247 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1248 // remaining arguments are scalars or pointers valid for the call.
1249 unsafe { sys::mtmd_bitmap_set_mergeable(self.ptr.as_ptr(), mergeable) }
1250 }
1251
1252 /// Mark this bitmap as a video frame rather than a still image.
1253 ///
1254 /// A video-capable model needs **both** this and
1255 /// [`set_mergeable`](Self::set_mergeable) on *every* frame; one without the
1256 /// other does not get you a merged video chunk. As with `set_mergeable`,
1257 /// [`MtmdVideo::read_next`] already sets both on the frames it produces —
1258 /// this is for frames you build yourself with [`Self::from_rgb`].
1259 ///
1260 /// New in `b12032`, which split the flag out so a caller can mark frames
1261 /// that did not come from the video reader.
1262 ///
1263 /// Wraps `mtmd_bitmap_set_is_video`.
1264 pub fn set_is_video(&mut self, is_video: bool) {
1265 // SAFETY: a live `&mut self` gives an exclusive, non-null bitmap; the
1266 // call only flips a flag on it.
1267 unsafe { sys::mtmd_bitmap_set_is_video(self.ptr.as_ptr(), is_video) }
1268 }
1269
1270 // ── Getters ───────────────────────────────────────────────────────────
1271
1272 /// Width in pixels (for images) or 0 (for audio).
1273 #[must_use]
1274 pub fn nx(&self) -> u32 {
1275 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1276 // remaining arguments are scalars or pointers valid for the call.
1277 unsafe { sys::mtmd_bitmap_get_nx(self.ptr.as_ptr()) }
1278 }
1279
1280 /// Height in pixels (for images) or 0 (for audio).
1281 #[must_use]
1282 pub fn ny(&self) -> u32 {
1283 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1284 // remaining arguments are scalars or pointers valid for the call.
1285 unsafe { sys::mtmd_bitmap_get_ny(self.ptr.as_ptr()) }
1286 }
1287
1288 /// Total number of bytes in the bitmap data.
1289 #[must_use]
1290 pub fn n_bytes(&self) -> usize {
1291 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1292 // remaining arguments are scalars or pointers valid for the call.
1293 unsafe { sys::mtmd_bitmap_get_n_bytes(self.ptr.as_ptr()) }
1294 }
1295
1296 /// Returns `true` if this bitmap contains audio (rather than image) data.
1297 #[must_use]
1298 pub fn is_audio(&self) -> bool {
1299 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1300 // remaining arguments are scalars or pointers valid for the call.
1301 unsafe { sys::mtmd_bitmap_is_audio(self.ptr.as_ptr()) }
1302 }
1303
1304 /// Return the raw pixel / sample data.
1305 #[must_use]
1306 pub fn data(&self) -> &[u8] {
1307 let n = self.n_bytes();
1308 if n == 0 {
1309 return &[];
1310 }
1311 // SAFETY: a read through a live, non-null bitmap handle.
1312 let ptr = unsafe { sys::mtmd_bitmap_get_data(self.ptr.as_ptr()) };
1313 // SAFETY: `n` is this bitmap's own `n_bytes()`, which is exactly how
1314 // many bytes upstream allocated for the buffer `ptr` points at, and
1315 // the borrow is tied to `&self`.
1316 unsafe { slice::from_raw_parts(ptr, n) }
1317 }
1318
1319 /// Return the optional ID string attached to this bitmap (used for KV
1320 /// cache tracking), or `None` if no ID has been set.
1321 #[must_use]
1322 pub fn id(&self) -> Option<&str> {
1323 // SAFETY: a read through a live handle (or no handle at all, for the
1324 // library-wide accessors); the returned pointer is only converted
1325 // below, never freed here.
1326 let ptr = unsafe { sys::mtmd_bitmap_get_id(self.ptr.as_ptr()) };
1327 if ptr.is_null() {
1328 return None;
1329 }
1330 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
1331 // handle-owned NUL-terminated string that outlives the borrow taken here.
1332 unsafe { CStr::from_ptr(ptr) }.to_str().ok()
1333 }
1334
1335 /// Attach an optional ID string to this bitmap (used for KV cache
1336 /// tracking).
1337 ///
1338 /// # Errors
1339 ///
1340 /// Returns an error if `id` contains an interior NUL byte.
1341 pub fn set_id(&mut self, id: &str) -> std::result::Result<(), std::ffi::NulError> {
1342 let cs = CString::new(id)?;
1343 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1344 // remaining arguments are scalars or pointers valid for the call.
1345 unsafe { sys::mtmd_bitmap_set_id(self.ptr.as_ptr(), cs.as_ptr()) };
1346 Ok(())
1347 }
1348}
1349
1350// ─────────────────────────────────────────────────────────────────────────────
1351// Video input
1352// ─────────────────────────────────────────────────────────────────────────────
1353
1354// `free()` from libc — used to release the heap-allocated text returned by
1355// `mtmd_helper_video_read_next` (the C side allocates it with strdup/malloc and
1356// documents that the caller must release it with `free()`).
1357extern "C" {
1358 fn free(ptr: *mut std::os::raw::c_void);
1359 /// `strdup` from libc. Used for the text a lazy-bitmap callback yields:
1360 /// mtmd releases it with `free()`, which Rust's allocator is not
1361 /// compatible with, so the copy has to come from malloc.
1362 fn strdup(s: *const std::os::raw::c_char) -> *mut std::os::raw::c_char;
1363}
1364
1365/// Parameters controlling how a [`MtmdVideo`] stream is opened and sampled.
1366///
1367/// Obtain a default-initialised instance via [`MtmdVideoParams::default()`]
1368/// (which mirrors `mtmd_helper_video_init_params_default`: ~4 fps, native
1369/// `ffmpeg`/`ffprobe` from `PATH`, and a 5 s timestamp interval) and tweak it
1370/// with the builder methods.
1371pub struct MtmdVideoParams {
1372 params: sys::mtmd_helper_video_init_params,
1373 // Keeps the `ffmpeg_bin_dir` C string alive for as long as `params`
1374 // borrows it via a raw pointer.
1375 ffmpeg_bin_dir: Option<CString>,
1376}
1377
1378impl std::fmt::Debug for MtmdVideoParams {
1379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1380 f.debug_struct("MtmdVideoParams")
1381 .field("fps_target", &self.params.fps_target)
1382 .field("timestamp_interval_ms", &self.params.timestamp_interval_ms)
1383 .field("ffmpeg_bin_dir", &self.ffmpeg_bin_dir)
1384 .finish()
1385 }
1386}
1387
1388impl Default for MtmdVideoParams {
1389 fn default() -> Self {
1390 // SAFETY: no pointer is involved; upstream returns a fully initialised struct by value.
1391 let params = unsafe { sys::mtmd_helper_video_init_params_default() };
1392 Self {
1393 params,
1394 ffmpeg_bin_dir: None,
1395 }
1396 }
1397}
1398
1399impl MtmdVideoParams {
1400 /// Desired output frame rate. Values `<= 0` mean "use the video's native
1401 /// fps" (the default is ~4 fps).
1402 #[must_use]
1403 pub fn fps_target(mut self, fps: f32) -> Self {
1404 self.params.fps_target = fps;
1405 self
1406 }
1407
1408 /// Interval, in milliseconds, between inserted timestamp text chunks (e.g.
1409 /// `"[10m50.5s]"`). Values `<= 0` disable timestamps (default 5000 ms).
1410 #[must_use]
1411 pub fn timestamp_interval_ms(mut self, ms: i64) -> Self {
1412 self.params.timestamp_interval_ms = ms;
1413 self
1414 }
1415
1416 /// Directory containing the `ffmpeg`/`ffprobe` binaries. Pass `None` to
1417 /// search `PATH` (the default).
1418 ///
1419 /// # Errors
1420 ///
1421 /// Returns an error if `dir` contains an interior NUL byte.
1422 pub fn ffmpeg_bin_dir(mut self, dir: Option<&str>) -> Result<Self> {
1423 match dir {
1424 None => {
1425 self.params.ffmpeg_bin_dir = std::ptr::null();
1426 self.ffmpeg_bin_dir = None;
1427 }
1428 Some(d) => {
1429 let cs = CString::new(d)?;
1430 self.params.ffmpeg_bin_dir = cs.as_ptr();
1431 // Store the owner so the pointer above stays valid.
1432 self.ffmpeg_bin_dir = Some(cs);
1433 }
1434 }
1435 Ok(self)
1436 }
1437}
1438
1439/// Metadata describing an open [`MtmdVideo`] stream.
1440#[derive(Debug, Clone, Copy, PartialEq)]
1441pub struct MtmdVideoInfo {
1442 /// Frame width in pixels.
1443 pub width: u32,
1444 /// Frame height in pixels.
1445 pub height: u32,
1446 /// Effective frames-per-second (the `fps_target` if set, else native fps).
1447 pub fps: f32,
1448 /// Estimated total frame count at the effective fps (`-1` if unknown).
1449 pub n_frames: i32,
1450}
1451
1452/// One item read from a [`MtmdVideo`] stream by [`MtmdVideo::read_next`].
1453#[derive(Debug)]
1454pub enum MtmdVideoItem {
1455 /// A decoded video frame, ready to be tokenized like any other image
1456 /// [`MtmdBitmap`].
1457 Frame(MtmdBitmap),
1458 /// A timestamp text marker (e.g. `"[10m50.5s]"`) to be inserted into the
1459 /// prompt between frames.
1460 Text(String),
1461}
1462
1463/// An open video stream, decoded frame-by-frame via `ffmpeg`.
1464///
1465/// The notion of "video" exists only at the helper level — it is decoded into
1466/// a sequence of image [frames](MtmdVideoItem::Frame) and timestamp
1467/// [text markers](MtmdVideoItem::Text) which are then fed through the normal
1468/// multimodal pipeline.
1469///
1470/// Requires a build with video support (see [`MtmdContext::supports_video`])
1471/// and `ffmpeg`/`ffprobe` available at runtime.
1472///
1473/// # Example
1474///
1475/// ```no_run
1476/// # #[cfg(feature = "mtmd")]
1477/// # fn run(mtmd_ctx: &llama_cpp_4::mtmd::MtmdContext) -> Result<(), llama_cpp_4::mtmd::MtmdError> {
1478/// use std::path::Path;
1479/// use llama_cpp_4::mtmd::{MtmdVideo, MtmdVideoParams, MtmdVideoItem};
1480///
1481/// let mut video = MtmdVideo::from_file(mtmd_ctx, Path::new("clip.mp4"),
1482/// &MtmdVideoParams::default())?;
1483/// while let Some(item) = video.read_next()? {
1484/// match item {
1485/// MtmdVideoItem::Frame(bitmap) => { /* tokenize the frame */ }
1486/// MtmdVideoItem::Text(ts) => { /* insert the timestamp marker */ }
1487/// }
1488/// }
1489/// # Ok(())
1490/// # }
1491/// ```
1492pub struct MtmdVideo {
1493 ptr: NonNull<sys::mtmd_helper_video>,
1494}
1495
1496impl std::fmt::Debug for MtmdVideo {
1497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1498 f.debug_struct("MtmdVideo")
1499 .field("info", &self.info())
1500 .finish()
1501 }
1502}
1503
1504impl Drop for MtmdVideo {
1505 fn drop(&mut self) {
1506 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
1507 // reads it afterwards.
1508 unsafe { sys::mtmd_helper_video_free(self.ptr.as_ptr()) }
1509 }
1510}
1511
1512impl MtmdVideo {
1513 /// Open a video file for frame-by-frame decoding.
1514 ///
1515 /// # Errors
1516 ///
1517 /// Returns [`MtmdError::VideoInitFailed`] if the stream cannot be opened
1518 /// (no video support compiled in, `ffprobe` not found, file unreadable,
1519 /// …), or [`MtmdError::InvalidPath`] / [`MtmdError::PathNotUtf8`] for a bad
1520 /// path.
1521 pub fn from_file(
1522 ctx: &MtmdContext,
1523 path: impl AsRef<Path>,
1524 params: &MtmdVideoParams,
1525 ) -> Result<Self> {
1526 let path = path.as_ref().to_str().ok_or(MtmdError::PathNotUtf8)?;
1527 let c_path = CString::new(path)?;
1528 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1529 // and the model/context handles are borrowed — and the result is checked
1530 // for null before being wrapped.
1531 let ptr = unsafe {
1532 sys::mtmd_helper_video_init(ctx.ptr.as_ptr(), c_path.as_ptr(), params.params)
1533 };
1534 let ptr = NonNull::new(ptr).ok_or(MtmdError::VideoInitFailed)?;
1535 Ok(Self { ptr })
1536 }
1537
1538 /// Open a video from an in-memory buffer. The buffer is copied internally,
1539 /// so it need not outlive this call.
1540 ///
1541 /// # Errors
1542 ///
1543 /// Returns [`MtmdError::VideoInitFailed`] if the stream cannot be opened.
1544 pub fn from_buf(ctx: &MtmdContext, buf: &[u8], params: &MtmdVideoParams) -> Result<Self> {
1545 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1546 // and the model/context handles are borrowed — and the result is checked
1547 // for null before being wrapped.
1548 let ptr = unsafe {
1549 sys::mtmd_helper_video_init_from_buf(
1550 ctx.ptr.as_ptr(),
1551 buf.as_ptr(),
1552 buf.len(),
1553 params.params,
1554 )
1555 };
1556 let ptr = NonNull::new(ptr).ok_or(MtmdError::VideoInitFailed)?;
1557 Ok(Self { ptr })
1558 }
1559
1560 /// Return metadata (resolution, effective fps, estimated frame count) for
1561 /// this stream.
1562 #[must_use]
1563 pub fn info(&self) -> MtmdVideoInfo {
1564 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1565 // remaining arguments are scalars or pointers valid for the call.
1566 let info = unsafe { sys::mtmd_helper_video_get_info(self.ptr.as_ptr()) };
1567 MtmdVideoInfo {
1568 width: info.width,
1569 height: info.height,
1570 fps: info.fps,
1571 n_frames: info.n_frames,
1572 }
1573 }
1574
1575 /// Read the next item from the stream.
1576 ///
1577 /// Returns `Ok(Some(item))` for each frame or timestamp marker, and
1578 /// `Ok(None)` once the end of the stream is reached.
1579 ///
1580 /// # Errors
1581 ///
1582 /// Returns [`MtmdError::VideoReadError`] on a decode error.
1583 pub fn read_next(&mut self) -> Result<Option<MtmdVideoItem>> {
1584 let mut out_bitmap: *mut sys::mtmd_bitmap = std::ptr::null_mut();
1585 let mut out_text: *mut std::os::raw::c_char = std::ptr::null_mut();
1586 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1587 // remaining arguments are scalars or pointers valid for the call.
1588 let ret = unsafe {
1589 sys::mtmd_helper_video_read_next(
1590 self.ptr.as_ptr(),
1591 &raw mut out_bitmap,
1592 &raw mut out_text,
1593 )
1594 };
1595 match ret {
1596 0 => {
1597 if let Some(ptr) = NonNull::new(out_bitmap) {
1598 Ok(Some(MtmdVideoItem::Frame(MtmdBitmap { ptr })))
1599 } else if !out_text.is_null() {
1600 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
1601 // handle-owned NUL-terminated string that outlives the borrow taken here.
1602 let text = unsafe { CStr::from_ptr(out_text) }
1603 .to_string_lossy()
1604 .into_owned();
1605 // The C side allocated this with strdup/malloc; release it.
1606 // SAFETY: `out_text` was allocated by llama.cpp with `strdup`, so it must be
1607 // released with `free` rather than a Rust allocator, and it is not read
1608 // again afterwards.
1609 unsafe { free(out_text.cast()) };
1610 Ok(Some(MtmdVideoItem::Text(text)))
1611 } else {
1612 // Success but nothing produced — treat as end of stream.
1613 Ok(None)
1614 }
1615 }
1616 -1 => Ok(None), // EOF
1617 other => Err(MtmdError::VideoReadError(other)),
1618 }
1619 }
1620}
1621
1622// ─────────────────────────────────────────────────────────────────────────────
1623// MtmdInputChunks
1624// ─────────────────────────────────────────────────────────────────────────────
1625
1626/// A list of tokenized input chunks produced by [`MtmdContext::tokenize`].
1627///
1628/// Each chunk is either a text token sequence or a set of image/audio tokens.
1629pub struct MtmdInputChunks {
1630 ptr: NonNull<sys::mtmd_input_chunks>,
1631}
1632
1633impl std::fmt::Debug for MtmdInputChunks {
1634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1635 f.debug_struct("MtmdInputChunks")
1636 .field("len", &self.len())
1637 .finish()
1638 }
1639}
1640
1641impl Drop for MtmdInputChunks {
1642 fn drop(&mut self) {
1643 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
1644 // reads it afterwards.
1645 unsafe { sys::mtmd_input_chunks_free(self.ptr.as_ptr()) }
1646 }
1647}
1648
1649impl MtmdInputChunks {
1650 /// Create a new, empty chunk list. Populated by
1651 /// [`MtmdContext::tokenize`].
1652 ///
1653 /// # Panics
1654 ///
1655 /// Panics if the underlying C allocation fails (OOM).
1656 #[must_use]
1657 pub fn new() -> Self {
1658 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
1659 // and the model/context handles are borrowed — and the result is checked
1660 // for null before being wrapped.
1661 let ptr = unsafe { sys::mtmd_input_chunks_init() };
1662 let ptr = NonNull::new(ptr).expect("mtmd_input_chunks_init returned null");
1663 Self { ptr }
1664 }
1665
1666 /// Number of chunks in this list.
1667 #[must_use]
1668 pub fn len(&self) -> usize {
1669 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1670 // remaining arguments are scalars or pointers valid for the call.
1671 unsafe { sys::mtmd_input_chunks_size(self.ptr.as_ptr()) }
1672 }
1673
1674 /// Restore a chunk previously serialized with
1675 /// [`MtmdInputChunk::save`], returning it as an owned placeholder.
1676 ///
1677 /// The result carries only metadata — token count, position count, type —
1678 /// so it can line a restored KV cache up with the prompt that produced it.
1679 /// It cannot be re-encoded; the pixels are gone.
1680 ///
1681 /// Wraps `mtmd_input_chunk_load`.
1682 ///
1683 /// # Errors
1684 ///
1685 /// Returns [`MtmdError::ChunkLoadFailed`] if the buffer is not a chunk
1686 /// this build can restore.
1687 pub fn load_chunk(buf: &[u8]) -> Result<OwnedMtmdInputChunk> {
1688 // SAFETY: `buf` is a Rust slice whose pointer and length are passed together,
1689 // so the read stays in bounds; the result is checked for null below.
1690 let ptr = unsafe {
1691 sys::mtmd_input_chunk_load(buf.as_ptr().cast::<std::os::raw::c_char>(), buf.len())
1692 };
1693 NonNull::new(ptr)
1694 .map(|ptr| OwnedMtmdInputChunk { ptr })
1695 .ok_or(MtmdError::ChunkLoadFailed)
1696 }
1697
1698 /// Returns `true` if there are no chunks.
1699 #[must_use]
1700 pub fn is_empty(&self) -> bool {
1701 self.len() == 0
1702 }
1703
1704 /// Get the `idx`-th chunk. Returns `None` if `idx >= len()`.
1705 #[must_use]
1706 pub fn get(&self, idx: usize) -> Option<MtmdInputChunk<'_>> {
1707 if idx >= self.len() {
1708 return None;
1709 }
1710 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1711 // remaining arguments are scalars or pointers valid for the call.
1712 let ptr = unsafe { sys::mtmd_input_chunks_get(self.ptr.as_ptr(), idx) };
1713 if ptr.is_null() {
1714 return None;
1715 }
1716 Some(MtmdInputChunk {
1717 ptr,
1718 _marker: std::marker::PhantomData,
1719 })
1720 }
1721
1722 /// Iterate over all chunks.
1723 pub fn iter(&self) -> impl Iterator<Item = MtmdInputChunk<'_>> {
1724 (0..self.len()).filter_map(|i| self.get(i))
1725 }
1726
1727 /// Total number of tokens across all chunks.
1728 ///
1729 /// Equivalent to `mtmd_helper_get_n_tokens`.
1730 #[must_use]
1731 pub fn n_tokens(&self) -> usize {
1732 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1733 // remaining arguments are scalars or pointers valid for the call.
1734 unsafe { sys::mtmd_helper_get_n_tokens(self.ptr.as_ptr()) }
1735 }
1736
1737 /// Total number of *positions* across all chunks (used for KV-cache
1738 /// tracking with M-RoPE models where positions ≠ tokens).
1739 ///
1740 /// Equivalent to `mtmd_helper_get_n_pos`.
1741 #[must_use]
1742 pub fn n_pos(&self) -> i32 {
1743 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1744 // remaining arguments are scalars or pointers valid for the call.
1745 unsafe { sys::mtmd_helper_get_n_pos(self.ptr.as_ptr()) }
1746 }
1747}
1748
1749impl Default for MtmdInputChunks {
1750 fn default() -> Self {
1751 Self::new()
1752 }
1753}
1754
1755// ─────────────────────────────────────────────────────────────────────────────
1756// MtmdInputChunkType
1757// ─────────────────────────────────────────────────────────────────────────────
1758
1759/// The type of an [`MtmdInputChunk`].
1760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1761pub enum MtmdInputChunkType {
1762 /// Plain text tokens.
1763 Text,
1764 /// Image tokens (embeddings produced by the vision encoder).
1765 Image,
1766 /// Audio tokens (embeddings produced by the audio encoder).
1767 Audio,
1768}
1769
1770impl From<sys::mtmd_input_chunk_type> for MtmdInputChunkType {
1771 fn from(v: sys::mtmd_input_chunk_type) -> Self {
1772 // mtmd_input_chunk_type is a plain C `typedef unsigned int`.
1773 // The variants are exported as free-standing constants.
1774 if v == sys::MTMD_INPUT_CHUNK_TYPE_IMAGE {
1775 Self::Image
1776 } else if v == sys::MTMD_INPUT_CHUNK_TYPE_AUDIO {
1777 Self::Audio
1778 } else {
1779 Self::Text
1780 }
1781 }
1782}
1783
1784// ─────────────────────────────────────────────────────────────────────────────
1785// MtmdInputChunk
1786// ─────────────────────────────────────────────────────────────────────────────
1787
1788/// A single tokenized input chunk (text, image, or audio).
1789///
1790/// Instances are borrowed from an [`MtmdInputChunks`] list and live as long
1791/// as that list.
1792#[derive(Debug)]
1793pub struct MtmdInputChunk<'chunks> {
1794 ptr: *const sys::mtmd_input_chunk,
1795 _marker: std::marker::PhantomData<&'chunks MtmdInputChunks>,
1796}
1797
1798impl<'chunks> MtmdInputChunk<'chunks> {
1799 /// The type of this chunk.
1800 #[must_use]
1801 pub fn chunk_type(&self) -> MtmdInputChunkType {
1802 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1803 // remaining arguments are scalars or pointers valid for the call.
1804 let t = unsafe { sys::mtmd_input_chunk_get_type(self.ptr) };
1805 MtmdInputChunkType::from(t)
1806 }
1807
1808 /// Total number of tokens in this chunk.
1809 #[must_use]
1810 pub fn n_tokens(&self) -> usize {
1811 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1812 // remaining arguments are scalars or pointers valid for the call.
1813 unsafe { sys::mtmd_input_chunk_get_n_tokens(self.ptr) }
1814 }
1815
1816 /// Number of temporal positions (equals `n_tokens` for non-M-RoPE models).
1817 #[must_use]
1818 pub fn n_pos(&self) -> i32 {
1819 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1820 // remaining arguments are scalars or pointers valid for the call.
1821 unsafe { sys::mtmd_input_chunk_get_n_pos(self.ptr) }
1822 }
1823
1824 /// Serialize this chunk's metadata to a byte buffer.
1825 ///
1826 /// Only metadata is saved — never the image or audio payload. A chunk
1827 /// restored with [`MtmdInputChunks::load_chunk`] is a *placeholder*: it
1828 /// carries the token and position counts needed to line a cached KV state
1829 /// back up with its prompt, but cannot be re-encoded. That is the intended
1830 /// use, and it is why this is cheap enough to store alongside a session
1831 /// file written by
1832 /// [`state_seq_save_file`](crate::context::LlamaContext::state_seq_save_file).
1833 ///
1834 /// Wraps `mtmd_input_chunk_save`.
1835 ///
1836 /// # Errors
1837 ///
1838 /// Returns [`MtmdError::ChunkSaveFailed`] if llama.cpp cannot serialize
1839 /// this chunk.
1840 pub fn save(&self) -> Result<Vec<u8>> {
1841 // Two-call protocol: query the length, then fill.
1842 let mut needed: usize = 0;
1843 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1844 // remaining arguments are scalars or pointers valid for the call.
1845 let rc = unsafe {
1846 sys::mtmd_input_chunk_save(self.ptr, std::ptr::null_mut(), 0, &raw mut needed)
1847 };
1848 if rc != 0 && needed == 0 {
1849 return Err(MtmdError::ChunkSaveFailed(rc));
1850 }
1851 let mut buf = vec![0u8; needed];
1852 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1853 // remaining arguments are scalars or pointers valid for the call.
1854 let rc = unsafe {
1855 sys::mtmd_input_chunk_save(
1856 self.ptr,
1857 buf.as_mut_ptr().cast::<std::os::raw::c_char>(),
1858 buf.len(),
1859 &raw mut needed,
1860 )
1861 };
1862 if rc != 0 {
1863 return Err(MtmdError::ChunkSaveFailed(rc));
1864 }
1865 buf.truncate(needed);
1866 Ok(buf)
1867 }
1868
1869 /// Copy this chunk, payload and all, into an owned handle.
1870 ///
1871 /// [`MtmdInputChunk`] borrows from the [`MtmdInputChunks`] list holding it,
1872 /// so it dies with that list. Take a copy when a chunk has to outlive the
1873 /// tokenization it came from — caching encoded media across requests, for
1874 /// instance. Unlike [`Self::to_placeholder`], the result is still usable
1875 /// for encoding.
1876 ///
1877 /// Wraps `mtmd_input_chunk_copy`.
1878 ///
1879 /// # Errors
1880 ///
1881 /// Returns [`MtmdError::ChunkLoadFailed`] if llama.cpp returns null.
1882 pub fn to_owned_chunk(&self) -> Result<OwnedMtmdInputChunk> {
1883 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1884 // remaining arguments are scalars or pointers valid for the call.
1885 let ptr = unsafe { sys::mtmd_input_chunk_copy(self.ptr) };
1886 NonNull::new(ptr)
1887 .map(|ptr| OwnedMtmdInputChunk { ptr })
1888 .ok_or(MtmdError::ChunkLoadFailed)
1889 }
1890
1891 /// Copy this chunk as a standalone placeholder — metadata only, no payload.
1892 ///
1893 /// Same shape as a round trip through [`Self::save`] and
1894 /// [`MtmdInputChunks::load_chunk`], without the serialization. Useful for
1895 /// keeping a prompt's structure alive after the pixels have been dropped.
1896 ///
1897 /// Wraps `mtmd_input_chunk_get_placeholder`.
1898 ///
1899 /// # Errors
1900 ///
1901 /// Returns [`MtmdError::ChunkLoadFailed`] if llama.cpp returns null.
1902 pub fn to_placeholder(&self) -> Result<OwnedMtmdInputChunk> {
1903 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1904 // remaining arguments are scalars or pointers valid for the call.
1905 let ptr = unsafe { sys::mtmd_input_chunk_get_placeholder(self.ptr) };
1906 NonNull::new(ptr)
1907 .map(|ptr| OwnedMtmdInputChunk { ptr })
1908 .ok_or(MtmdError::ChunkLoadFailed)
1909 }
1910
1911 /// Return the raw llama token IDs for a **text** chunk.
1912 ///
1913 /// Returns `None` if this chunk is not a text chunk.
1914 #[must_use]
1915 pub fn text_tokens(&self) -> Option<&[i32]> {
1916 if self.chunk_type() != MtmdInputChunkType::Text {
1917 return None;
1918 }
1919 let mut n: usize = 0;
1920 // SAFETY: a read through a live chunk; upstream writes the token count
1921 // through `n` and returns a pointer into the chunk's own storage.
1922 let ptr = unsafe { sys::mtmd_input_chunk_get_tokens_text(self.ptr, &raw mut n) };
1923 if ptr.is_null() || n == 0 {
1924 return Some(&[]);
1925 }
1926 // SAFETY: `ptr` is non-null (checked above) and `n` is the count upstream
1927 // just wrote for this same buffer.
1928 Some(unsafe { slice::from_raw_parts(ptr, n) })
1929 }
1930
1931 /// Return the image token metadata for an **image** or **audio** chunk.
1932 ///
1933 /// Returns `None` for text chunks.
1934 #[must_use]
1935 pub fn image_tokens(&self) -> Option<MtmdImageTokens<'chunks>> {
1936 match self.chunk_type() {
1937 MtmdInputChunkType::Image | MtmdInputChunkType::Audio => {}
1938 MtmdInputChunkType::Text => return None,
1939 }
1940 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
1941 // remaining arguments are scalars or pointers valid for the call.
1942 let ptr = unsafe { sys::mtmd_input_chunk_get_tokens_image(self.ptr) };
1943 if ptr.is_null() {
1944 return None;
1945 }
1946 Some(MtmdImageTokens {
1947 ptr,
1948 _marker: std::marker::PhantomData,
1949 })
1950 }
1951
1952 /// Optional ID attached to this chunk (used for KV cache tracking).
1953 #[must_use]
1954 pub fn id(&self) -> Option<&str> {
1955 // SAFETY: a read through a live handle (or no handle at all, for the
1956 // library-wide accessors); the returned pointer is only converted
1957 // below, never freed here.
1958 let ptr = unsafe { sys::mtmd_input_chunk_get_id(self.ptr) };
1959 if ptr.is_null() {
1960 return None;
1961 }
1962 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
1963 // handle-owned NUL-terminated string that outlives the borrow taken here.
1964 unsafe { CStr::from_ptr(ptr) }.to_str().ok()
1965 }
1966
1967 /// Returns the raw `*const mtmd_input_chunk` pointer.
1968 ///
1969 /// # Safety
1970 ///
1971 /// The returned pointer is valid for the lifetime of the parent
1972 /// `MtmdInputChunks`.
1973 #[must_use]
1974 pub fn as_ptr(&self) -> *const sys::mtmd_input_chunk {
1975 self.ptr
1976 }
1977}
1978
1979// ─────────────────────────────────────────────────────────────────────────────
1980// MtmdDecoderPos
1981// ─────────────────────────────────────────────────────────────────────────────
1982
1983/// Per-token position used by M-RoPE decoder attention.
1984///
1985/// `t` is the temporal axis, `x`/`y` the spatial axes. `z` is reserved for
1986/// future use. Values are *relative* to a base `pos_0` provided when the
1987/// position is computed.
1988#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1989#[repr(C)]
1990pub struct MtmdDecoderPos {
1991 /// Temporal index.
1992 pub t: u32,
1993 /// Spatial X.
1994 pub x: u32,
1995 /// Spatial Y.
1996 pub y: u32,
1997 /// Reserved.
1998 pub z: u32,
1999}
2000
2001// ─────────────────────────────────────────────────────────────────────────────
2002// MtmdImageTokens
2003// ─────────────────────────────────────────────────────────────────────────────
2004
2005/// Image/audio token metadata attached to a non-text [`MtmdInputChunk`].
2006#[derive(Debug)]
2007pub struct MtmdImageTokens<'chunks> {
2008 ptr: *const sys::mtmd_image_tokens,
2009 _marker: std::marker::PhantomData<&'chunks MtmdInputChunks>,
2010}
2011
2012impl MtmdImageTokens<'_> {
2013 /// Total number of embedding tokens.
2014 #[must_use]
2015 pub fn n_tokens(&self) -> usize {
2016 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2017 // remaining arguments are scalars or pointers valid for the call.
2018 unsafe { sys::mtmd_image_tokens_get_n_tokens(self.ptr) }
2019 }
2020
2021 /// Width of the token grid.
2022 #[must_use]
2023 pub fn nx(&self) -> usize {
2024 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2025 // remaining arguments are scalars or pointers valid for the call.
2026 unsafe { sys::mtmd_image_tokens_get_nx(self.ptr) }
2027 }
2028
2029 /// Height of the token grid.
2030 #[must_use]
2031 pub fn ny(&self) -> usize {
2032 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2033 // remaining arguments are scalars or pointers valid for the call.
2034 unsafe { sys::mtmd_image_tokens_get_ny(self.ptr) }
2035 }
2036
2037 /// Number of temporal positions (M-RoPE variant; equals `n_tokens` otherwise).
2038 #[must_use]
2039 pub fn n_pos(&self) -> i32 {
2040 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2041 // remaining arguments are scalars or pointers valid for the call.
2042 unsafe { sys::mtmd_image_tokens_get_n_pos(self.ptr) }
2043 }
2044
2045 /// Optional ID for KV cache tracking.
2046 #[must_use]
2047 pub fn id(&self) -> Option<&str> {
2048 // SAFETY: a read through a live handle (or no handle at all, for the
2049 // library-wide accessors); the returned pointer is only converted
2050 // below, never freed here.
2051 let ptr = unsafe { sys::mtmd_image_tokens_get_id(self.ptr) };
2052 if ptr.is_null() {
2053 return None;
2054 }
2055 // SAFETY: the pointer comes from an mtmd accessor, which returns a static or
2056 // handle-owned NUL-terminated string that outlives the borrow taken here.
2057 unsafe { CStr::from_ptr(ptr) }.to_str().ok()
2058 }
2059
2060 /// Compute the per-token decoder positions used by M-RoPE models.
2061 ///
2062 /// Returns a vector of length [`n_tokens`](Self::n_tokens). Each entry
2063 /// is relative to `pos_0`; for non-M-RoPE models this typically reduces
2064 /// to `(0, i, 0, 0)` for the i-th token.
2065 ///
2066 /// Wraps `mtmd_helper_image_get_decoder_pos`.
2067 #[must_use]
2068 pub fn decoder_positions(&self, pos_0: i32) -> Vec<MtmdDecoderPos> {
2069 let n = self.n_tokens();
2070 let mut out = vec![MtmdDecoderPos::default(); n];
2071 if n == 0 {
2072 return out;
2073 }
2074 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2075 // remaining arguments are scalars or pointers valid for the call.
2076 unsafe {
2077 sys::mtmd_helper_image_get_decoder_pos(
2078 self.ptr,
2079 pos_0,
2080 out.as_mut_ptr().cast::<sys::mtmd_decoder_pos>(),
2081 );
2082 }
2083 out
2084 }
2085}
2086
2087// ─────────────────────────────────────────────────────────────────────────────
2088// LlamaContext extension
2089// ─────────────────────────────────────────────────────────────────────────────
2090
2091use crate::context::LlamaContext;
2092
2093impl LlamaContext<'_> {
2094 /// Expose the raw `llama_context` pointer for use with mtmd helpers.
2095 ///
2096 /// # Safety
2097 ///
2098 /// The pointer is valid for the lifetime of this `LlamaContext` and must
2099 /// not be freed by the caller.
2100 #[must_use]
2101 pub fn as_ptr(&self) -> *mut sys::llama_context {
2102 self.context.as_ptr()
2103 }
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108 use super::*;
2109
2110 #[test]
2111 fn decoder_pos_layout_matches_sys() {
2112 // The Rust MtmdDecoderPos is cast to sys::mtmd_decoder_pos at the
2113 // FFI boundary in `MtmdImageTokens::decoder_positions`. Verify the
2114 // assumption.
2115 assert_eq!(
2116 std::mem::size_of::<MtmdDecoderPos>(),
2117 std::mem::size_of::<sys::mtmd_decoder_pos>(),
2118 );
2119 assert_eq!(
2120 std::mem::align_of::<MtmdDecoderPos>(),
2121 std::mem::align_of::<sys::mtmd_decoder_pos>(),
2122 );
2123 assert_eq!(std::mem::offset_of!(MtmdDecoderPos, t), 0);
2124 assert_eq!(std::mem::offset_of!(MtmdDecoderPos, x), 4);
2125 assert_eq!(std::mem::offset_of!(MtmdDecoderPos, y), 8);
2126 assert_eq!(std::mem::offset_of!(MtmdDecoderPos, z), 12);
2127 }
2128
2129 #[test]
2130 fn input_text_records_byte_length_and_nul_terminates() {
2131 let input = MtmdInputText::new("hello", true, false);
2132 // text_len is the prompt length, excluding the trailing NUL sentinel.
2133 assert_eq!(input.text_len, 5);
2134 assert_eq!(input.text, b"hello\0");
2135 assert!(input.add_special);
2136 assert!(!input.parse_special);
2137 }
2138
2139 #[test]
2140 fn input_text_preserves_interior_nul() {
2141 // The whole point of upstream's `text_len`: a prompt with an embedded
2142 // NUL must keep its full length rather than truncating at the NUL.
2143 let input = MtmdInputText::from_bytes(b"a\0b", false, true);
2144 assert_eq!(input.text_len, 3);
2145 assert_eq!(input.text, b"a\0b\0");
2146 }
2147
2148 /// Restoring a chunk from garbage must return an error, not abort. The
2149 /// underlying C returns null on failure, and a null deref here would take
2150 /// the process with it.
2151 /// Probing a file that is not an mmproj must report "no modalities"
2152 /// rather than crash — a server calls this on a user-supplied path.
2153 #[test]
2154 fn mmproj_caps_on_a_non_mmproj_file_reports_nothing() {
2155 let caps = mmproj_caps("/definitely/not/a/model.gguf").expect("no NUL in path");
2156 assert!(!caps.vision);
2157 assert!(!caps.audio);
2158 }
2159
2160 #[test]
2161 fn mmproj_caps_rejects_interior_nul() {
2162 assert!(mmproj_caps("a\0b").is_err());
2163 }
2164
2165 #[test]
2166 fn load_chunk_rejects_garbage() {
2167 let err = MtmdInputChunks::load_chunk(b"not a serialized chunk").unwrap_err();
2168 assert!(matches!(err, MtmdError::ChunkLoadFailed), "got {err:?}");
2169 }
2170
2171 #[test]
2172 fn load_chunk_rejects_empty_input() {
2173 assert!(MtmdInputChunks::load_chunk(&[]).is_err());
2174 }
2175
2176 /// A truncated buffer is the realistic corruption case for a chunk read
2177 /// back off disk beside a session file.
2178 #[test]
2179 fn load_chunk_rejects_truncated_input() {
2180 assert!(MtmdInputChunks::load_chunk(&[0u8; 4]).is_err());
2181 }
2182
2183 #[test]
2184 fn input_text_try_new_is_infallible() {
2185 let input = MtmdInputText::try_new("marker \u{1} data", true, true)
2186 .expect("try_new no longer rejects any input");
2187 assert_eq!(input.text_len, "marker \u{1} data".len());
2188 }
2189}
2190
2191// ─────────────────────────────────────────────────────────────────────────────
2192// OwnedMtmdInputChunk
2193// ─────────────────────────────────────────────────────────────────────────────
2194
2195/// A chunk that owns its allocation, as returned by
2196/// [`MtmdInputChunks::load_chunk`] or [`MtmdInputChunk::to_placeholder`].
2197///
2198/// [`MtmdInputChunk`] borrows from the [`MtmdInputChunks`] list that holds it;
2199/// this one stands alone and frees itself on drop.
2200pub struct OwnedMtmdInputChunk {
2201 ptr: NonNull<sys::mtmd_input_chunk>,
2202}
2203
2204impl std::fmt::Debug for OwnedMtmdInputChunk {
2205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2206 f.debug_struct("OwnedMtmdInputChunk")
2207 .field("chunk_type", &self.chunk_type())
2208 .field("n_tokens", &self.n_tokens())
2209 .finish()
2210 }
2211}
2212
2213impl Drop for OwnedMtmdInputChunk {
2214 fn drop(&mut self) {
2215 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
2216 // reads it afterwards.
2217 unsafe { sys::mtmd_input_chunk_free(self.ptr.as_ptr()) }
2218 }
2219}
2220
2221impl OwnedMtmdInputChunk {
2222 /// The type of this chunk.
2223 #[must_use]
2224 pub fn chunk_type(&self) -> MtmdInputChunkType {
2225 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2226 // remaining arguments are scalars or pointers valid for the call.
2227 MtmdInputChunkType::from(unsafe { sys::mtmd_input_chunk_get_type(self.ptr.as_ptr()) })
2228 }
2229
2230 /// Total number of tokens in this chunk.
2231 #[must_use]
2232 pub fn n_tokens(&self) -> usize {
2233 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2234 // remaining arguments are scalars or pointers valid for the call.
2235 unsafe { sys::mtmd_input_chunk_get_n_tokens(self.ptr.as_ptr()) }
2236 }
2237
2238 /// Number of temporal positions.
2239 #[must_use]
2240 pub fn n_pos(&self) -> i32 {
2241 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2242 // remaining arguments are scalars or pointers valid for the call.
2243 unsafe { sys::mtmd_input_chunk_get_n_pos(self.ptr.as_ptr()) }
2244 }
2245
2246 /// Serialize this chunk's metadata, as [`MtmdInputChunk::save`] does.
2247 ///
2248 /// # Errors
2249 ///
2250 /// Returns [`MtmdError::ChunkSaveFailed`] if llama.cpp cannot serialize it.
2251 pub fn save(&self) -> Result<Vec<u8>> {
2252 let mut needed: usize = 0;
2253 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2254 // remaining arguments are scalars or pointers valid for the call.
2255 let rc = unsafe {
2256 sys::mtmd_input_chunk_save(self.ptr.as_ptr(), std::ptr::null_mut(), 0, &raw mut needed)
2257 };
2258 if rc != 0 && needed == 0 {
2259 return Err(MtmdError::ChunkSaveFailed(rc));
2260 }
2261 let mut buf = vec![0u8; needed];
2262 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2263 // remaining arguments are scalars or pointers valid for the call.
2264 let rc = unsafe {
2265 sys::mtmd_input_chunk_save(
2266 self.ptr.as_ptr(),
2267 buf.as_mut_ptr().cast::<std::os::raw::c_char>(),
2268 buf.len(),
2269 &raw mut needed,
2270 )
2271 };
2272 if rc != 0 {
2273 return Err(MtmdError::ChunkSaveFailed(rc));
2274 }
2275 buf.truncate(needed);
2276 Ok(buf)
2277 }
2278}
2279
2280// ─────────────────────────────────────────────────────────────────────────────
2281// MtmdBatch
2282// ─────────────────────────────────────────────────────────────────────────────
2283
2284/// Encode several media chunks in one pass.
2285///
2286/// [`MtmdContext::encode_chunk`] handles one chunk at a time; this batches
2287/// them, which is what you want for a multi-image prompt or a run of video
2288/// frames — the vision encoder runs once over the whole set instead of once per
2289/// image.
2290///
2291/// A batch belongs to the context that created it and borrows it for its
2292/// lifetime. Chunks are *not* owned by the batch, so they must outlive it too.
2293///
2294/// Wraps `mtmd_batch_init` / `mtmd_batch_add_chunk` / `mtmd_batch_encode`.
2295pub struct MtmdBatch<'ctx> {
2296 ptr: NonNull<sys::mtmd_batch>,
2297 _ctx: std::marker::PhantomData<&'ctx MtmdContext>,
2298}
2299
2300impl std::fmt::Debug for MtmdBatch<'_> {
2301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2302 f.debug_struct("MtmdBatch").finish_non_exhaustive()
2303 }
2304}
2305
2306impl Drop for MtmdBatch<'_> {
2307 fn drop(&mut self) {
2308 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
2309 // reads it afterwards.
2310 unsafe { sys::mtmd_batch_free(self.ptr.as_ptr()) }
2311 }
2312}
2313
2314impl<'ctx> MtmdBatch<'ctx> {
2315 /// Start a batch against `ctx`.
2316 ///
2317 /// # Errors
2318 ///
2319 /// Returns [`MtmdError::BatchCreateFailed`] if llama.cpp returns null.
2320 pub fn new(ctx: &'ctx MtmdContext) -> Result<Self> {
2321 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
2322 // and the model/context handles are borrowed — and the result is checked
2323 // for null before being wrapped.
2324 let ptr = unsafe { sys::mtmd_batch_init(ctx.ptr.as_ptr()) };
2325 NonNull::new(ptr)
2326 .map(|ptr| Self {
2327 ptr,
2328 _ctx: std::marker::PhantomData,
2329 })
2330 .ok_or(MtmdError::BatchCreateFailed)
2331 }
2332
2333 /// Add a media chunk. Text chunks are rejected.
2334 ///
2335 /// # Errors
2336 ///
2337 /// Returns [`MtmdError::BatchAddFailed`] — code `2` means the batch is
2338 /// full and the chunk was not added (start a new batch), code `3` means it
2339 /// cannot be batched with what is already there (differing image
2340 /// geometry, say).
2341 pub fn add_chunk(&mut self, chunk: &MtmdInputChunk<'_>) -> Result<()> {
2342 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2343 // remaining arguments are scalars or pointers valid for the call.
2344 let rc = unsafe { sys::mtmd_batch_add_chunk(self.ptr.as_ptr(), chunk.ptr) };
2345 if rc == 0 {
2346 Ok(())
2347 } else {
2348 Err(MtmdError::BatchAddFailed(rc))
2349 }
2350 }
2351
2352 /// Encode every chunk added so far.
2353 ///
2354 /// # Errors
2355 ///
2356 /// Returns [`MtmdError::EncodeError`] on failure.
2357 pub fn encode(&mut self) -> Result<()> {
2358 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2359 // remaining arguments are scalars or pointers valid for the call.
2360 let rc = unsafe { sys::mtmd_batch_encode(self.ptr.as_ptr()) };
2361 if rc == 0 {
2362 Ok(())
2363 } else {
2364 Err(MtmdError::EncodeError(rc))
2365 }
2366 }
2367
2368 /// Borrow the embeddings produced for `chunk` by the last [`Self::encode`].
2369 ///
2370 /// Returns `None` if the chunk was not part of this batch or encoding has
2371 /// not run. The slice is owned by the batch and is invalidated by the next
2372 /// `encode`.
2373 ///
2374 /// # Safety of the returned length
2375 ///
2376 /// llama.cpp reports only a pointer, so the length is derived from the
2377 /// chunk's token count and the context's embedding dimension.
2378 #[must_use]
2379 pub fn output_embd(&self, model: &LlamaModel, chunk: &MtmdInputChunk<'_>) -> Option<&[f32]> {
2380 // The width is `n_embd_inp`, and `chunk` is the key the buffer is
2381 // looked up by — so deriving the length from that same chunk cannot
2382 // disagree with the buffer it describes. Taking a bare `usize` here
2383 // let a caller name any length at all.
2384 let n_embd_inp = usize::try_from(model.n_embd_inp()).ok()?;
2385 let len = chunk.n_tokens().checked_mul(n_embd_inp)?;
2386 if len == 0 {
2387 return Some(&[]);
2388 }
2389 // SAFETY: `mtmd_batch_get_output_embd` returns either null or the
2390 // buffer it holds for exactly this chunk, and `len` is that chunk's
2391 // token count times the input embedding width — upstream's own
2392 // documented size. The borrow is tied to `&self`.
2393 unsafe {
2394 let ptr = sys::mtmd_batch_get_output_embd(self.ptr.as_ptr(), chunk.ptr);
2395 if ptr.is_null() {
2396 return None;
2397 }
2398 Some(slice::from_raw_parts(ptr, len))
2399 }
2400 }
2401}
2402
2403// ─────────────────────────────────────────────────────────────────────────────
2404// Audio generation
2405// ─────────────────────────────────────────────────────────────────────────────
2406
2407/// Container for generated audio.
2408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2409pub enum MtmdAudioOutType {
2410 /// Raw PCM samples.
2411 Pcm,
2412 /// A complete WAV file: PCM 16-bit little-endian, mono.
2413 Wav,
2414}
2415
2416impl MtmdAudioOutType {
2417 fn as_raw(self) -> sys::mtmd_helper_gen_audio_outtype {
2418 match self {
2419 Self::Pcm => sys::MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM,
2420 Self::Wav => sys::MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV,
2421 }
2422 }
2423}
2424
2425/// What to synthesize, and how.
2426#[derive(Debug, Clone)]
2427pub struct MtmdAudioRequest {
2428 /// Sequence id to generate under.
2429 pub seq_id: i32,
2430 /// Text to speak.
2431 pub prompt: String,
2432 /// BCP-47-ish language hint, if the pipeline takes one.
2433 pub lang: Option<String>,
2434 /// Top-k for the backbone sampler.
2435 pub top_k: i32,
2436 /// Top-p for the backbone sampler.
2437 pub top_p: f32,
2438 /// Seed; `u32::MAX` means random.
2439 pub seed: u32,
2440 /// Container for [`MtmdAudioGen::output`].
2441 pub out_type: MtmdAudioOutType,
2442}
2443
2444impl MtmdAudioRequest {
2445 /// A request to speak `prompt` with upstream's defaults.
2446 #[must_use]
2447 pub fn new(prompt: impl Into<String>) -> Self {
2448 Self {
2449 seq_id: 0,
2450 prompt: prompt.into(),
2451 lang: None,
2452 top_k: 40,
2453 top_p: 0.9,
2454 seed: u32::MAX,
2455 out_type: MtmdAudioOutType::Wav,
2456 }
2457 }
2458}
2459
2460/// Text-to-speech through an mmproj audio-generation pipeline.
2461///
2462/// This is the *other* direction of multimodal: where [`MtmdBitmap`] feeds
2463/// audio in, this drives a pipeline that emits it. The loop is explicitly
2464/// stateless on llama.cpp's side, so it runs in two phases:
2465///
2466/// 1. [`Self::set_input`], then [`Self::step_prompt`] until it returns `0` —
2467/// the prompt is consumed `n_batch` tokens at a time.
2468/// 2. [`Self::step_gen`] per frame until it reports stop.
2469/// 3. [`Self::output`] for the finished audio.
2470///
2471/// Wraps `mtmd_helper_gen_audio_*`.
2472pub struct MtmdAudioGen<'ctx> {
2473 ptr: NonNull<sys::mtmd_helper_gen_audio>,
2474 _ctx: std::marker::PhantomData<&'ctx MtmdContext>,
2475}
2476
2477impl std::fmt::Debug for MtmdAudioGen<'_> {
2478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2479 f.debug_struct("MtmdAudioGen").finish_non_exhaustive()
2480 }
2481}
2482
2483impl Drop for MtmdAudioGen<'_> {
2484 fn drop(&mut self) {
2485 // SAFETY: this runs once from `Drop`, the handle is non-null, and nothing
2486 // reads it afterwards.
2487 unsafe { sys::mtmd_helper_gen_audio_free(self.ptr.as_ptr()) }
2488 }
2489}
2490
2491impl<'ctx> MtmdAudioGen<'ctx> {
2492 /// Attach a generator to a llama context and an mtmd context.
2493 ///
2494 /// # Errors
2495 ///
2496 /// Returns [`MtmdError::ContextCreateFailed`] if the mmproj has no
2497 /// audio-generation pipeline.
2498 pub fn new(
2499 lctx: &mut crate::context::LlamaContext<'_>,
2500 mctx: &'ctx MtmdContext,
2501 ) -> Result<Self> {
2502 let ptr =
2503 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
2504 // and the model/context handles are borrowed — and the result is checked
2505 // for null before being wrapped.
2506 unsafe { sys::mtmd_helper_gen_audio_init(lctx.context.as_ptr(), mctx.ptr.as_ptr()) };
2507 NonNull::new(ptr)
2508 .map(|ptr| Self {
2509 ptr,
2510 _ctx: std::marker::PhantomData,
2511 })
2512 .ok_or(MtmdError::ContextCreateFailed)
2513 }
2514
2515 /// Clear all state, ready for another utterance.
2516 pub fn reset(&mut self) {
2517 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2518 // remaining arguments are scalars or pointers valid for the call.
2519 unsafe { sys::mtmd_helper_gen_audio_reset(self.ptr.as_ptr()) }
2520 }
2521
2522 /// Set what to synthesize. `speaker_ref` is an optional voice reference for
2523 /// pipelines that support cloning.
2524 ///
2525 /// # Errors
2526 ///
2527 /// Returns [`MtmdError::EvalError`] if llama.cpp rejects the request, or
2528 /// [`MtmdError::InvalidPath`] if a string contains an interior NUL.
2529 pub fn set_input(
2530 &mut self,
2531 request: &MtmdAudioRequest,
2532 speaker_ref: Option<&MtmdBitmap>,
2533 ) -> Result<()> {
2534 let prompt = CString::new(request.prompt.as_str())?;
2535 let lang = request.lang.as_deref().map(CString::new).transpose()?;
2536 // Start from upstream's defaults and override, rather than naming every
2537 // field: a struct literal here breaks on every field llama.cpp adds,
2538 // which is how `stream` broke the b11829 bump. The same reason
2539 // `LlamaContextParams` is seeded from `llama_context_default_params`.
2540 //
2541 // SAFETY: `mtmd_helper_gen_audio_inp_default` takes no arguments and
2542 // returns a by-value POD struct.
2543 let mut inp = unsafe { sys::mtmd_helper_gen_audio_inp_default() };
2544 inp.seq_id = request.seq_id;
2545 inp.prompt = prompt.as_ptr();
2546 inp.prompt_len = request.prompt.len();
2547 inp.speaker_ref = speaker_ref.map_or(std::ptr::null_mut(), |b| b.ptr.as_ptr());
2548 inp.lang = lang.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
2549 inp.top_k = request.top_k;
2550 inp.top_p = request.top_p;
2551 inp.seed = request.seed;
2552 inp.out_type = request.out_type.as_raw();
2553 // `stream` (new in b11829) requires `output()` after every `step_gen`.
2554 // [`MtmdAudioGen`] collects the whole clip and reads it once, so leave
2555 // it off regardless of what upstream defaults it to.
2556 inp.stream = false;
2557 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2558 // remaining arguments are scalars or pointers valid for the call.
2559 let rc = unsafe { sys::mtmd_helper_gen_audio_set_input(self.ptr.as_ptr(), &raw const inp) };
2560 if rc == 0 {
2561 Ok(())
2562 } else {
2563 Err(MtmdError::EvalError(rc))
2564 }
2565 }
2566
2567 /// Consume up to `n_batch` prompt tokens.
2568 ///
2569 /// Returns the number of prompt tokens still outstanding; call again until
2570 /// it returns `0`, then move on to [`Self::step_gen`].
2571 ///
2572 /// # Errors
2573 ///
2574 /// Returns [`MtmdError::EvalError`] if llama.cpp reports a negative code.
2575 pub fn step_prompt(&mut self, n_batch: i32) -> Result<i32> {
2576 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2577 // remaining arguments are scalars or pointers valid for the call.
2578 let rc = unsafe { sys::mtmd_helper_gen_audio_step_prompt(self.ptr.as_ptr(), n_batch) };
2579 if rc < 0 {
2580 return Err(MtmdError::EvalError(rc));
2581 }
2582 Ok(rc)
2583 }
2584
2585 /// Whether the next [`step_gen`](Self::step_gen) wants a *text* token.
2586 ///
2587 /// Interleaved pipelines alternate: when this is `true` the caller samples
2588 /// a token from the logits and passes it as `sampled`; when `false` the
2589 /// next step produces an audio frame from `h_state_in` instead. Getting
2590 /// this backwards feeds a stale token into a frame step, so check it each
2591 /// iteration rather than assuming the pattern.
2592 ///
2593 /// Wraps `mtmd_helper_gen_audio_next_is_text`, new in `b12032`.
2594 #[must_use]
2595 pub fn next_is_text(&mut self) -> bool {
2596 // SAFETY: a live `&mut self` gives an exclusive, non-null generator.
2597 unsafe { sys::mtmd_helper_gen_audio_next_is_text(self.ptr.as_ptr()) }
2598 }
2599
2600 /// Logit bias to apply before sampling the next text token.
2601 ///
2602 /// This is how the model keeps a text-only response from switching to
2603 /// audio mid-sentence, so sampling without it can produce a token the
2604 /// pipeline cannot act on. Feed it to
2605 /// [`LlamaSampler::logit_bias`](crate::sampling::LlamaSampler::logit_bias).
2606 ///
2607 /// Empty when the pipeline has no bias for this step. Wraps
2608 /// `mtmd_helper_gen_audio_get_logit_bias`, new in `b12032`.
2609 #[must_use]
2610 pub fn logit_bias(&mut self) -> Vec<llama_cpp_sys_4::llama_logit_bias> {
2611 let mut n_bias: usize = 0;
2612 // SAFETY: exclusive non-null generator; upstream writes the count
2613 // through `n_bias` and returns a pointer owned by the generator that
2614 // stays valid until the next step. Copying out here means the returned
2615 // Vec does not alias it.
2616 unsafe {
2617 let ptr = sys::mtmd_helper_gen_audio_get_logit_bias(self.ptr.as_ptr(), &raw mut n_bias);
2618 if ptr.is_null() || n_bias == 0 {
2619 return Vec::new();
2620 }
2621 std::slice::from_raw_parts(ptr, n_bias).to_vec()
2622 }
2623 }
2624
2625 /// Generate one audio frame.
2626 ///
2627 /// `sampled` is the backbone token just sampled, or `None` for pipelines
2628 /// with no discrete backbone token. `h_state_in` is the hidden state fed
2629 /// back from the previous step.
2630 ///
2631 /// Returns `(hidden_state, stop)`. `stop` marks end-of-speech: the caller
2632 /// must break the loop. The hidden state borrows generator memory that the
2633 /// next `step_gen` or [`Self::reset`] invalidates, hence the `&mut self`
2634 /// borrow being released before you can call again.
2635 ///
2636 /// # Errors
2637 ///
2638 /// Returns [`MtmdError::EvalError`] on a negative code.
2639 pub fn step_gen(
2640 &mut self,
2641 sampled: Option<crate::token::LlamaToken>,
2642 h_state_in: Option<&[f32]>,
2643 model: &LlamaModel,
2644 ) -> Result<(Option<Vec<f32>>, bool)> {
2645 let token = sampled.map_or(sys::LLAMA_TOKEN_NULL, |t| t.0);
2646 let in_ptr = h_state_in.map_or(std::ptr::null(), <[f32]>::as_ptr);
2647 let mut out_ptr: *const f32 = std::ptr::null();
2648 let mut stop = false;
2649 // SAFETY: a live `&mut self` gives an exclusive, non-null generator.
2650 // `in_ptr` is either null or borrowed from `h_state_in` for the call,
2651 // and the two out-params are stack locals upstream writes through.
2652 let rc = unsafe {
2653 sys::mtmd_helper_gen_audio_step_gen(
2654 self.ptr.as_ptr(),
2655 token,
2656 in_ptr,
2657 &raw mut out_ptr,
2658 &raw mut stop,
2659 )
2660 };
2661 if rc < 0 {
2662 return Err(MtmdError::EvalError(rc));
2663 }
2664 // The hidden state is one row of `n_embd` floats — upstream builds it
2665 // with `h_state_buf.assign(he, he + n_embd)` where `n_embd` is
2666 // `llama_model_n_embd(model)`. Taking that from the model rather than
2667 // from the caller is what keeps this a safe function.
2668 let n_embd = usize::try_from(model.n_embd()).unwrap_or(0);
2669 // Copy rather than borrow: upstream documents the buffer as valid only
2670 // until the next step_gen/reset, which a returned slice could outlive.
2671 let state = if out_ptr.is_null() || n_embd == 0 {
2672 None
2673 } else {
2674 // SAFETY: `out_ptr` is non-null and points at the generator's
2675 // hidden-state buffer, which upstream sized at `n_embd` floats.
2676 // The copy happens before any call that could invalidate it.
2677 Some(unsafe { slice::from_raw_parts(out_ptr, n_embd) }.to_vec())
2678 };
2679 Ok((state, stop))
2680 }
2681
2682 /// Collect the generated audio.
2683 ///
2684 /// Returns `(sample_rate, bytes, n_samples)`. `bytes` is raw PCM or a
2685 /// complete WAV file depending on the request's
2686 /// [`MtmdAudioOutType`].
2687 ///
2688 /// # Errors
2689 ///
2690 /// Returns [`MtmdError::EvalError`] if nothing has been generated.
2691 pub fn output(&mut self) -> Result<(i32, Vec<u8>, i64)> {
2692 let mut sample_rate: i32 = 0;
2693 let mut data: *const std::os::raw::c_char = std::ptr::null();
2694 let mut data_len: usize = 0;
2695 let mut n_samples: i64 = 0;
2696 // SAFETY: `self.ptr` is a live, non-null handle kept alive by this borrow, and the
2697 // remaining arguments are scalars or pointers valid for the call.
2698 let rc = unsafe {
2699 sys::mtmd_helper_gen_audio_get_output(
2700 self.ptr.as_ptr(),
2701 &raw mut sample_rate,
2702 &raw mut data,
2703 &raw mut data_len,
2704 &raw mut n_samples,
2705 )
2706 };
2707 if rc != 0 {
2708 return Err(MtmdError::EvalError(rc));
2709 }
2710 let bytes = if data.is_null() || data_len == 0 {
2711 Vec::new()
2712 } else {
2713 // Copied for the same reason as step_gen: valid only until the next
2714 // get_output/reset.
2715 // SAFETY: `data` and `data_len` are the pointer and byte count upstream wrote
2716 // through its out-params for this generator's audio buffer.
2717 unsafe { slice::from_raw_parts(data.cast::<u8>(), data_len) }.to_vec()
2718 };
2719 Ok((sample_rate, bytes, n_samples))
2720 }
2721}
2722
2723// ─────────────────────────────────────────────────────────────────────────────
2724// Explicit input parts
2725// ─────────────────────────────────────────────────────────────────────────────
2726
2727/// One element of a marker-free prompt, for
2728/// [`MtmdContext::tokenize_from_parts`].
2729///
2730/// Borrows rather than owns, so the caller keeps control of bitmap lifetimes —
2731/// a bitmap is usually reused across several prompts.
2732#[derive(Debug)]
2733pub enum MtmdInputPart<'a> {
2734 /// A run of text, with its own `parse_special` setting.
2735 Text(&'a MtmdInputText<'a>),
2736 /// An image or audio bitmap spliced in at this position.
2737 Bitmap(&'a MtmdBitmap),
2738}
2739
2740// ─────────────────────────────────────────────────────────────────────────────
2741// Audio-generation capabilities
2742// ─────────────────────────────────────────────────────────────────────────────
2743
2744/// Which audio-generation pipeline an mmproj implements.
2745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2746pub enum MtmdGenAudioType {
2747 /// Qwen3-TTS.
2748 Qwen3Tts,
2749 /// `PocketTTS`.
2750 PocketTts,
2751 /// A pipeline this crate does not know, added upstream since this release.
2752 Unknown,
2753}
2754
2755impl MtmdGenAudioType {
2756 fn from_raw(raw: sys::mtmd_gen_audio_type) -> Self {
2757 match raw {
2758 sys::MTMD_GEN_AUDIO_TYPE_QWEN3TTS => Self::Qwen3Tts,
2759 sys::MTMD_GEN_AUDIO_TYPE_POCKETTTS => Self::PocketTts,
2760 _ => Self::Unknown,
2761 }
2762 }
2763}
2764
2765/// What a model and projector pair can take in and produce.
2766///
2767/// From `mtmd_helper_get_capabilities`. The three input flags answer "what can
2768/// I feed this?"; the three output flags answer "what will it give me back?" —
2769/// a text-to-speech model, for example, reports `text_gen: false` and
2770/// `audio_gen: true`, which no earlier predicate could express.
2771// A faithful 1:1 mirror of upstream's `mtmd_helper_cap`, which is eight bools.
2772// The lint's usual remedy — collapse them into an enum or bitflags — would put
2773// a translation layer between this and the C struct, making it harder to check
2774// that the fields still line up after a bump. Upstream's shape is the contract.
2775#[allow(clippy::struct_excessive_bools)]
2776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2777pub struct MtmdCapabilities {
2778 /// Usable for chat. `false` for tts-only models (no chat template) and for
2779 /// realtime transcription models.
2780 pub chat: bool,
2781
2782 /// Accepts image input.
2783 pub vision: bool,
2784 /// Accepts audio input.
2785 pub audio: bool,
2786 /// Accepts video input. Also depends on the `MTMD_VIDEO` build option, and
2787 /// on `ffmpeg`/`ffprobe` at runtime — see [`MtmdVideo`].
2788 pub video: bool,
2789
2790 /// Supports realtime transcription through llama.cpp's
2791 /// `mtmd_audio_stream_*` API, which this crate does not wrap yet — reach
2792 /// for `llama-cpp-sys-4` directly if you need it.
2793 pub audio_stream: bool,
2794
2795 /// Generates text. `false` for an audio-only model.
2796 pub text_gen: bool,
2797 /// Generates audio.
2798 pub audio_gen: bool,
2799 /// Accepts a speaker reference clip for voice cloning.
2800 pub audio_gen_spk_ref: bool,
2801}
2802
2803/// What [`MtmdContext::gen_audio_info`] reports about a speech pipeline.
2804#[derive(Debug, Clone, PartialEq, Eq)]
2805pub struct MtmdGenAudioInfo {
2806 /// The pipeline implemented by this projector.
2807 pub pipeline: MtmdGenAudioType,
2808 /// Output sample rate in Hz, e.g. 24000 for Qwen3-TTS. Needed to write a
2809 /// correct WAV header or resample.
2810 pub sample_rate: i32,
2811 /// Weight-variant name, when the pipeline has variants.
2812 pub model_variant: Option<String>,
2813}
2814
2815// ─────────────────────────────────────────────────────────────────────────────
2816// Projector capability probe
2817// ─────────────────────────────────────────────────────────────────────────────
2818
2819/// Which modalities an mmproj file accepts.
2820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2821pub struct MtmdCaps {
2822 /// Accepts image input.
2823 pub vision: bool,
2824 /// Accepts audio input.
2825 pub audio: bool,
2826}
2827
2828/// Read an mmproj file's input capabilities without loading it.
2829///
2830/// [`MtmdContext::init_from_file`] builds the full projector — weights, compute
2831/// buffers, the lot. This only reads enough metadata to answer "does this
2832/// accept images, audio, or both", which is what a server needs at startup to
2833/// decide whether a request is even servable.
2834///
2835/// Wraps `mtmd_get_cap_from_file`. Returns both flags `false` for a file that
2836/// is not a readable mmproj.
2837///
2838/// # Errors
2839///
2840/// Returns [`MtmdError::InvalidPath`] if the path contains an interior NUL, or
2841/// [`MtmdError::PathNotUtf8`] if it is not UTF-8.
2842pub fn mmproj_caps(path: impl AsRef<Path>) -> Result<MtmdCaps> {
2843 let path = path.as_ref().to_str().ok_or(MtmdError::PathNotUtf8)?;
2844 let c_path = CString::new(path)?;
2845 // SAFETY: the path is a `CString` alive for the call; upstream only reads the
2846 // file and returns a plain struct.
2847 let caps = unsafe { sys::mtmd_get_cap_from_file(c_path.as_ptr()) };
2848 Ok(MtmdCaps {
2849 vision: caps.inp_vision,
2850 audio: caps.inp_audio,
2851 })
2852}
2853
2854// ─────────────────────────────────────────────────────────────────────────────
2855// Lazy bitmaps
2856// ─────────────────────────────────────────────────────────────────────────────
2857
2858/// What a lazy-bitmap callback yields for one chunk index.
2859#[derive(Debug)]
2860pub enum MtmdLazyChunk {
2861 /// An image or audio bitmap. Ownership passes to llama.cpp.
2862 Bitmap(MtmdBitmap),
2863 /// A run of text to splice in at this position.
2864 Text(String),
2865 /// No more chunks; the placeholder removes itself from the prompt.
2866 End,
2867}
2868
2869/// A bitmap whose contents are produced on demand, during tokenization.
2870///
2871/// An ordinary [`MtmdBitmap`] holds decoded pixels or samples from the moment
2872/// it is built. This holds a callback instead, invoked with `0, 1, 2, …` while
2873/// the prompt is tokenized and expanding into however many chunks it yields.
2874/// That matters for two cases:
2875///
2876/// - a long video, where materialising every frame up front would not fit in
2877/// memory;
2878/// - media that may never be reached, because a stop sequence or a token
2879/// budget cuts the prompt short first.
2880///
2881/// The callback must outlive tokenization, so this owns it alongside the
2882/// bitmap and drops them in that order.
2883///
2884/// Wraps `mtmd_bitmap_init_lazy`.
2885pub struct MtmdLazyBitmap {
2886 // Declaration order is the drop order, and it matters: llama.cpp may touch
2887 // `user_data` while freeing the bitmap, so the bitmap must go first.
2888 bitmap: MtmdBitmap,
2889 _callback: Box<LazyCallbackState>,
2890}
2891
2892impl std::fmt::Debug for MtmdLazyBitmap {
2893 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2894 f.debug_struct("MtmdLazyBitmap").finish_non_exhaustive()
2895 }
2896}
2897
2898/// Heap home for the user closure, pointed at by `user_data`.
2899struct LazyCallbackState {
2900 func: Box<dyn FnMut(usize) -> MtmdLazyChunk>,
2901}
2902
2903impl MtmdLazyBitmap {
2904 /// Build a lazy bitmap identified by `id` (conventionally a file hash).
2905 ///
2906 /// `callback` is called with increasing chunk indices until it returns
2907 /// [`MtmdLazyChunk::End`].
2908 ///
2909 /// # Errors
2910 ///
2911 /// Returns [`MtmdError::BitmapCreateFailed`] if llama.cpp returns null, or
2912 /// [`MtmdError::InvalidPath`] if `id` contains an interior NUL.
2913 pub fn new<F>(ctx: &MtmdContext, id: &str, callback: F) -> Result<Self>
2914 where
2915 F: FnMut(usize) -> MtmdLazyChunk + 'static,
2916 {
2917 let c_id = CString::new(id)?;
2918 let mut state = Box::new(LazyCallbackState {
2919 func: Box::new(callback),
2920 });
2921 let user_data = std::ptr::from_mut(state.as_mut()).cast::<std::os::raw::c_void>();
2922
2923 // SAFETY: every pointer argument is alive for the call — the paths are `CString`s
2924 // and the model/context handles are borrowed — and the result is checked
2925 // for null before being wrapped.
2926 let ptr = unsafe {
2927 sys::mtmd_bitmap_init_lazy(
2928 ctx.ptr.as_ptr(),
2929 c_id.as_ptr(),
2930 user_data,
2931 Some(lazy_trampoline),
2932 )
2933 };
2934 let bitmap = MtmdBitmap {
2935 ptr: NonNull::new(ptr).ok_or(MtmdError::BitmapCreateFailed)?,
2936 };
2937 Ok(Self {
2938 bitmap,
2939 _callback: state,
2940 })
2941 }
2942
2943 /// Borrow this as an ordinary bitmap, for passing to
2944 /// [`MtmdContext::tokenize`].
2945 #[must_use]
2946 pub fn as_bitmap(&self) -> &MtmdBitmap {
2947 &self.bitmap
2948 }
2949}
2950
2951/// C entry point for [`MtmdLazyBitmap`].
2952///
2953/// Returns `0` when a chunk was produced, `-1` at EOF, `-2` on error. A Rust
2954/// panic must not unwind into C, so it is caught and reported as `-2`.
2955extern "C" fn lazy_trampoline(
2956 chunk_idx: usize,
2957 user_data: *mut std::os::raw::c_void,
2958 out_bitmap: *mut *mut sys::mtmd_bitmap,
2959 out_text: *mut *mut std::os::raw::c_char,
2960) -> std::os::raw::c_int {
2961 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2962 if user_data.is_null() {
2963 return -2;
2964 }
2965 // SAFETY: `user_data` is the pointer this crate handed to llama.cpp
2966 // when registering the callback, checked non-null just above. It
2967 // points at a `LazyCallbackState` that outlives the tokenize call,
2968 // and llama.cpp invokes the callback from one thread at a time, so
2969 // the exclusive reference is not aliased.
2970 let state = unsafe { &mut *user_data.cast::<LazyCallbackState>() };
2971 match (state.func)(chunk_idx) {
2972 MtmdLazyChunk::Bitmap(bitmap) => {
2973 // Ownership moves to llama.cpp, which frees it with
2974 // `mtmd_bitmap_free`; skip our own Drop.
2975 let raw = bitmap.ptr.as_ptr();
2976 std::mem::forget(bitmap);
2977 // SAFETY: `out_bitmap` is an out-param llama.cpp provided for this callback,
2978 // and `raw` is a bitmap whose ownership has just been released with
2979 // `mem::forget`, so llama.cpp is now responsible for freeing it.
2980 unsafe { *out_bitmap = raw };
2981 0
2982 }
2983 MtmdLazyChunk::Text(text) => {
2984 let Ok(c_text) = CString::new(text) else {
2985 return -2;
2986 };
2987 // llama.cpp releases this with `free()`, so it must come from
2988 // malloc — a Rust-allocated buffer would be freed by the wrong
2989 // allocator.
2990 // SAFETY: `c_text` is alive for the call, and `strdup` is used because
2991 // llama.cpp releases this string with `free()` — a Rust-allocated buffer
2992 // would be handed to the wrong allocator.
2993 let dup = unsafe { strdup(c_text.as_ptr()) };
2994 if dup.is_null() {
2995 return -2;
2996 }
2997 // SAFETY: `out_text` is an out-param llama.cpp provided, and `dup` is a
2998 // non-null malloc'd string (checked above) that llama.cpp now owns.
2999 unsafe { *out_text = dup };
3000 0
3001 }
3002 MtmdLazyChunk::End => -1,
3003 }
3004 }));
3005 result.unwrap_or(-2)
3006}