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