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