Skip to main content

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}