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_helper_eval_chunks` (or single-chunk variant) returned a non-zero code.
95    #[error("eval error: code {0}")]
96    EvalError(i32),
97
98    /// A video stream could not be opened. Common causes: the build lacks
99    /// video support (`MTMD_VIDEO` was OFF), `ffmpeg`/`ffprobe` is not on
100    /// `PATH`, or the file is unreadable.
101    #[error("failed to open video stream (null return from mtmd_helper_video_init)")]
102    VideoInitFailed,
103
104    /// `mtmd_helper_video_read_next` returned an error code (`-2`).
105    #[error("video read error: code {0}")]
106    VideoReadError(i32),
107}
108
109/// A convenience `Result` alias for this module.
110pub type Result<T> = std::result::Result<T, MtmdError>;
111
112/// Progress callback invoked while the CLIP/mmproj weights are loading.
113///
114/// Receives a value in `[0.0, 1.0]`. Return `true` to continue loading or
115/// `false` to abort immediately.
116pub type MtmdProgressCallback = unsafe extern "C" fn(progress: f32, user_data: *mut c_void) -> bool;
117
118// ─────────────────────────────────────────────────────────────────────────────
119// MtmdContextParams
120// ─────────────────────────────────────────────────────────────────────────────
121
122/// Parameters used when creating an [`MtmdContext`].
123///
124/// Obtain a default-initialised instance via [`MtmdContextParams::default()`].
125pub struct MtmdContextParams {
126    pub(crate) params: sys::mtmd_context_params,
127}
128
129impl std::fmt::Debug for MtmdContextParams {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("MtmdContextParams")
132            .field("use_gpu", &self.params.use_gpu)
133            .field("print_timings", &self.params.print_timings)
134            .field("n_threads", &self.params.n_threads)
135            .field("warmup", &self.params.warmup)
136            .field("image_min_tokens", &self.params.image_min_tokens)
137            .field("image_max_tokens", &self.params.image_max_tokens)
138            .finish()
139    }
140}
141
142impl Default for MtmdContextParams {
143    fn default() -> Self {
144        let params = unsafe { sys::mtmd_context_params_default() };
145        Self { params }
146    }
147}
148
149impl MtmdContextParams {
150    /// Whether to run the vision/audio encoder on the GPU (default: `true`).
151    #[must_use]
152    pub fn use_gpu(mut self, v: bool) -> Self {
153        self.params.use_gpu = v;
154        self
155    }
156
157    /// Whether to print timing info after each encode (default: `false`).
158    #[must_use]
159    pub fn print_timings(mut self, v: bool) -> Self {
160        self.params.print_timings = v;
161        self
162    }
163
164    /// Number of threads used for the vision encoder (default taken from
165    /// `mtmd_context_params_default`).
166    #[must_use]
167    pub fn n_threads(mut self, n: i32) -> Self {
168        self.params.n_threads = n;
169        self
170    }
171
172    /// Whether to run a warm-up encode pass after initialisation.
173    #[must_use]
174    pub fn warmup(mut self, v: bool) -> Self {
175        self.params.warmup = v;
176        self
177    }
178
179    /// Minimum number of image tokens (0 = use model default).
180    #[must_use]
181    pub fn image_min_tokens(mut self, n: i32) -> Self {
182        self.params.image_min_tokens = n;
183        self
184    }
185
186    /// Maximum number of image tokens (0 = use model default).
187    #[must_use]
188    pub fn image_max_tokens(mut self, n: i32) -> Self {
189        self.params.image_max_tokens = n;
190        self
191    }
192
193    /// Maximum number of multimodal output tokens per batch.
194    ///
195    /// Maps to `mtmd_context_params.batch_max_tokens`. The upstream default
196    /// is `1024`. Increase for large images or long audio segments.
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// # #[cfg(feature = "mtmd")]
202    /// # {
203    /// use llama_cpp_4::mtmd::MtmdContextParams;
204    /// let params = MtmdContextParams::default().with_batch_max_tokens(2048);
205    /// assert_eq!(params.batch_max_tokens(), 2048);
206    /// # }
207    /// ```
208    #[must_use]
209    pub fn with_batch_max_tokens(mut self, n: i32) -> Self {
210        self.params.batch_max_tokens = n;
211        self
212    }
213
214    /// Get the configured batch token cap (`batch_max_tokens`).
215    #[must_use]
216    pub fn batch_max_tokens(&self) -> i32 {
217        self.params.batch_max_tokens
218    }
219
220    /// Set flash-attention mode for the vision encoder.
221    ///
222    /// Maps to `mtmd_context_params.flash_attn_type`. Uses the same
223    /// [`crate::context::params::LlamaFlashAttnType`] enum as text contexts.
224    ///
225    /// # Examples
226    ///
227    /// ```rust
228    /// # #[cfg(feature = "mtmd")]
229    /// # {
230    /// use llama_cpp_4::context::params::LlamaFlashAttnType;
231    /// use llama_cpp_4::mtmd::MtmdContextParams;
232    /// let params = MtmdContextParams::default()
233    ///     .with_flash_attn_type(LlamaFlashAttnType::Auto);
234    /// assert_eq!(params.flash_attn_type(), LlamaFlashAttnType::Auto);
235    /// # }
236    /// ```
237    #[must_use]
238    pub fn with_flash_attn_type(
239        mut self,
240        flash_attn_type: crate::context::params::LlamaFlashAttnType,
241    ) -> Self {
242        self.params.flash_attn_type = flash_attn_type.into();
243        self
244    }
245
246    /// Get flash-attention mode for the vision encoder.
247    #[must_use]
248    pub fn flash_attn_type(&self) -> crate::context::params::LlamaFlashAttnType {
249        crate::context::params::LlamaFlashAttnType::from(self.params.flash_attn_type)
250    }
251
252    /// Register a callback invoked while mmproj weights load.
253    ///
254    /// Maps to `mtmd_context_params.progress_callback`. Pass `None` to disable
255    /// progress reporting. The callback may return `false` to abort loading
256    /// early; see [`MtmdProgressCallback`].
257    ///
258    /// `user_data` is forwarded to each invocation and must remain valid until
259    /// [`MtmdContext::init_from_file`] returns.
260    #[must_use]
261    pub fn with_progress_callback(
262        mut self,
263        callback: Option<MtmdProgressCallback>,
264        user_data: *mut c_void,
265    ) -> Self {
266        self.params.progress_callback = callback;
267        self.params.progress_callback_user_data = user_data;
268        self
269    }
270
271    /// Override the media marker string (e.g. `"<image>"`).
272    ///
273    /// The provided string must not contain interior NUL bytes.  Pass `None`
274    /// to use the library default (`mtmd_default_marker()`).
275    ///
276    /// **Note:** the `CString` is stored inside the params so the pointer
277    /// remains valid as long as this `MtmdContextParams` lives.
278    /// # Errors
279    ///
280    /// Returns [`MtmdError`] if the marker string contains a NUL byte.
281    pub fn media_marker(mut self, marker: Option<&str>) -> std::result::Result<Self, MtmdError> {
282        match marker {
283            None => {
284                self.params.media_marker = std::ptr::null();
285                Ok(self)
286            }
287            Some(s) => {
288                let cs = CString::new(s)?;
289                self.params.media_marker = cs.as_ptr();
290                // Leak the CString so the raw pointer stays valid; the caller
291                // must ensure the params don't outlive the string.  Since
292                // MtmdContextParams is consumed by MtmdContext::init_from_file,
293                // this is safe.
294                std::mem::forget(cs);
295                Ok(self)
296            }
297        }
298    }
299}
300
301// ─────────────────────────────────────────────────────────────────────────────
302// MtmdContext
303// ─────────────────────────────────────────────────────────────────────────────
304
305/// The main multimodal context.
306///
307/// Wraps a `mtmd_context *`.  This context is tied to a specific mmproj model
308/// file and a loaded [`LlamaModel`].  It is safe to share across threads for
309/// `tokenize` calls (read-only), but `encode_chunk` / eval helpers mutate
310/// internal state and must not be called concurrently.
311pub struct MtmdContext {
312    ptr: NonNull<sys::mtmd_context>,
313}
314
315// The underlying mtmd_context is internally synchronised for tokenize().
316// encode / decode must be called from a single thread at a time (caller's
317// responsibility, enforced by the inference semaphore in the server).
318unsafe impl Send for MtmdContext {}
319unsafe impl Sync for MtmdContext {}
320
321impl std::fmt::Debug for MtmdContext {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_struct("MtmdContext")
324            .field("ptr", &self.ptr)
325            .finish()
326    }
327}
328
329impl Drop for MtmdContext {
330    fn drop(&mut self) {
331        unsafe { sys::mtmd_free(self.ptr.as_ptr()) }
332    }
333}
334
335impl MtmdContext {
336    /// Returns the default media marker string used in prompts
337    /// (currently `"<__media__>"`).
338    #[must_use]
339    pub fn default_marker() -> &'static str {
340        let ptr = unsafe { sys::mtmd_default_marker() };
341        unsafe { CStr::from_ptr(ptr) }
342            .to_str()
343            .unwrap_or("<__media__>")
344    }
345
346    /// Initialise a multimodal context from an mmproj GGUF file.
347    ///
348    /// # Parameters
349    ///
350    /// * `mmproj_path` – path to the mmproj `.gguf` file
351    /// * `text_model`  – the already-loaded text model
352    /// * `params`      – context parameters (use [`MtmdContextParams::default()`])
353    ///
354    /// # Errors
355    ///
356    /// Returns [`MtmdError::ContextCreateFailed`] if the underlying C call
357    /// returns a null pointer.
358    #[allow(clippy::needless_pass_by_value)]
359    pub fn init_from_file(
360        mmproj_path: impl AsRef<Path>,
361        text_model: &LlamaModel,
362        params: MtmdContextParams,
363    ) -> Result<Self> {
364        let path = mmproj_path
365            .as_ref()
366            .to_str()
367            .ok_or(MtmdError::PathNotUtf8)?;
368        let c_path = CString::new(path)?;
369
370        let ptr = unsafe {
371            sys::mtmd_init_from_file(c_path.as_ptr(), text_model.model.as_ptr(), params.params)
372        };
373
374        let ptr = NonNull::new(ptr).ok_or(MtmdError::ContextCreateFailed)?;
375        Ok(Self { ptr })
376    }
377
378    // ── Logging ──────────────────────────────────────────────────────────
379
380    /// Silence all clip/mtmd log output by installing a no-op callback.
381    ///
382    /// Call this right after [`init_from_file`](Self::init_from_file) to
383    /// suppress the verbose `clip_model_loader: tensor[N]…` lines that
384    /// clip.cpp emits to its own private logger (separate from `llama_log_set`).
385    pub fn void_logs() {
386        unsafe extern "C" fn noop(
387            _level: sys::ggml_log_level,
388            _text: *const ::std::os::raw::c_char,
389            _ud: *mut ::std::os::raw::c_void,
390        ) {
391        }
392        unsafe { sys::mtmd_log_set(Some(noop), std::ptr::null_mut()) };
393    }
394
395    /// Like [`void_logs`](Self::void_logs), but additionally silences logs
396    /// emitted by the `mtmd_helper_*` layer (e.g. eval/decode helpers).
397    ///
398    /// Internally calls `mtmd_helper_log_set` which also routes through
399    /// `mtmd_log_set`, so this is a strict superset of `void_logs`.
400    pub fn void_helper_logs() {
401        unsafe extern "C" fn noop(
402            _level: sys::ggml_log_level,
403            _text: *const ::std::os::raw::c_char,
404            _ud: *mut ::std::os::raw::c_void,
405        ) {
406        }
407        unsafe { sys::mtmd_helper_log_set(Some(noop), std::ptr::null_mut()) };
408    }
409
410    // ── Capability queries ────────────────────────────────────────────────
411
412    /// Returns `true` if the model supports vision (image) input.
413    #[must_use]
414    pub fn supports_vision(&self) -> bool {
415        unsafe { sys::mtmd_support_vision(self.ptr.as_ptr()) }
416    }
417
418    /// Returns `true` if the model supports audio input.
419    #[must_use]
420    pub fn supports_audio(&self) -> bool {
421        unsafe { sys::mtmd_support_audio(self.ptr.as_ptr()) }
422    }
423
424    /// Returns `true` if this build and model support video input.
425    ///
426    /// Video support additionally requires `ffmpeg`/`ffprobe` to be available
427    /// at runtime (see [`MtmdVideo`]). Wraps `mtmd_helper_support_video`.
428    #[must_use]
429    pub fn supports_video(&self) -> bool {
430        unsafe { sys::mtmd_helper_support_video(self.ptr.as_ptr()) }
431    }
432
433    /// Returns the media marker string configured for *this* context.
434    ///
435    /// Unlike [`default_marker`](Self::default_marker) (the library-wide
436    /// default), this reflects any override passed via
437    /// [`MtmdContextParams::media_marker`]. Wraps `mtmd_get_marker`.
438    #[must_use]
439    pub fn marker(&self) -> &str {
440        let ptr = unsafe { sys::mtmd_get_marker(self.ptr.as_ptr()) };
441        if ptr.is_null() {
442            return Self::default_marker();
443        }
444        unsafe { CStr::from_ptr(ptr) }
445            .to_str()
446            .unwrap_or_else(|_| Self::default_marker())
447    }
448
449    /// Returns the audio sample rate in Hz (e.g. `16_000` for Whisper), or `-1` if
450    /// audio is not supported.
451    #[must_use]
452    pub fn audio_sample_rate(&self) -> i32 {
453        unsafe { sys::mtmd_get_audio_sample_rate(self.ptr.as_ptr()) }
454    }
455
456    /// Whether `llama_decode` must use a non-causal attention mask when
457    /// decoding image embeddings for this model.
458    #[must_use]
459    pub fn decode_use_non_causal(&self, chunk: &MtmdInputChunk<'_>) -> bool {
460        unsafe { sys::mtmd_decode_use_non_causal(self.ptr.as_ptr(), chunk.as_ptr()) }
461    }
462
463    /// Whether the model uses M-RoPE for `llama_decode`.
464    #[must_use]
465    pub fn decode_use_mrope(&self) -> bool {
466        unsafe { sys::mtmd_decode_use_mrope(self.ptr.as_ptr()) }
467    }
468
469    // ── Core API ──────────────────────────────────────────────────────────
470
471    /// Tokenize a text prompt that contains one or more media markers.
472    ///
473    /// The number of `bitmaps` must equal the number of media markers in the
474    /// prompt text, otherwise [`MtmdError::TokenizeError`] with code `1` is returned.
475    ///
476    /// This call is **thread-safe** (shared `&self`).
477    ///
478    /// # Parameters
479    ///
480    /// * `text`    – text + tokenisation options
481    /// * `bitmaps` – slice of [`MtmdBitmap`] references, one per media marker
482    /// * `output`  – an [`MtmdInputChunks`] that will be populated with the result
483    ///
484    /// # Errors
485    ///
486    /// Returns [`MtmdError::TokenizeError`] if tokenization fails.
487    pub fn tokenize(
488        &self,
489        text: &MtmdInputText<'_>,
490        bitmaps: &[&MtmdBitmap],
491        output: &mut MtmdInputChunks,
492    ) -> Result<()> {
493        // The C signature is: mtmd_tokenize(..., mtmd_bitmap ** bitmaps, ...)
494        // where each element is a `const mtmd_bitmap *`.  We build a Vec of
495        // `*const mtmd_bitmap` and pass a mutable pointer to its first element
496        // (i.e. `*mut *const mtmd_bitmap`) to satisfy the C API.
497        let mut bitmap_ptrs: Vec<*const sys::mtmd_bitmap> = bitmaps
498            .iter()
499            .map(|b| b.ptr.as_ptr().cast_const())
500            .collect();
501
502        let c_text = sys::mtmd_input_text {
503            // Upstream reads exactly `text_len` bytes from `text`
504            // (llama.cpp #25548), so the prompt is length-delimited and interior
505            // NUL bytes are preserved instead of truncating it.
506            text: text.text.as_ptr().cast(),
507            text_len: text.text_len,
508            add_special: text.add_special,
509            parse_special: text.parse_special,
510        };
511
512        let ret = unsafe {
513            sys::mtmd_tokenize(
514                self.ptr.as_ptr(),
515                output.ptr.as_ptr(),
516                &raw const c_text,
517                bitmap_ptrs.as_mut_ptr(),
518                bitmap_ptrs.len(),
519            )
520        };
521
522        if ret != 0 {
523            return Err(MtmdError::TokenizeError(ret));
524        }
525        Ok(())
526    }
527
528    /// Encode a single input chunk (image or audio) and store the resulting
529    /// embeddings inside the context.
530    ///
531    /// After a successful call, the embeddings can be retrieved with
532    /// [`MtmdContext::output_embd`].
533    ///
534    /// This call is **NOT thread-safe**.
535    ///
536    /// # Errors
537    ///
538    /// Returns [`MtmdError::EncodeError`] if encoding fails.
539    pub fn encode_chunk(&self, chunk: &MtmdInputChunk<'_>) -> Result<()> {
540        let ret = unsafe { sys::mtmd_encode_chunk(self.ptr.as_ptr(), chunk.ptr) };
541        if ret != 0 {
542            return Err(MtmdError::EncodeError(ret));
543        }
544        Ok(())
545    }
546
547    /// Return a slice over the embeddings produced by the last
548    /// [`encode_chunk`](Self::encode_chunk) call.
549    ///
550    /// The length (in `f32` elements) is:
551    /// ```text
552    /// n_embd_inp(model)  *  chunk.n_tokens()
553    /// ```
554    ///
555    /// # Safety
556    ///
557    /// The returned slice is valid until the next call that mutates the
558    /// context (e.g. another `encode_chunk`).
559    #[must_use]
560    pub fn output_embd(&self, n_elements: usize) -> &[f32] {
561        let ptr = unsafe { sys::mtmd_get_output_embd(self.ptr.as_ptr()) };
562        if ptr.is_null() || n_elements == 0 {
563            return &[];
564        }
565        unsafe { slice::from_raw_parts(ptr, n_elements) }
566    }
567
568    // ── Helper API ────────────────────────────────────────────────────────
569
570    /// High-level helper: evaluate (decode) all chunks in sequence.
571    ///
572    /// * Text chunks are decoded via `llama_decode`.
573    /// * Image/audio chunks are first encoded with `mtmd_encode_chunk` and
574    ///   then decoded via `llama_decode`.
575    ///
576    /// On success `new_n_past` is updated with the new past position.
577    ///
578    /// This call is **NOT thread-safe**.
579    ///
580    /// # Parameters
581    ///
582    /// * `lctx`        – raw pointer to the llama context (from [`LlamaContext::as_ptr`])
583    /// * `chunks`      – the tokenized chunks to evaluate
584    /// * `n_past`      – current KV-cache position
585    /// * `seq_id`      – sequence ID
586    /// * `n_batch`     – maximum batch size (must be ≥ 1)
587    /// * `logits_last` – if `true`, compute logits only for the final token
588    /// * `new_n_past`  – updated KV-cache position after the call
589    ///
590    /// # Errors
591    ///
592    /// Returns [`MtmdError::EvalError`] if evaluation fails.
593    #[allow(clippy::too_many_arguments, clippy::not_unsafe_ptr_arg_deref)]
594    pub fn eval_chunks(
595        &self,
596        lctx: *mut sys::llama_context,
597        chunks: &MtmdInputChunks,
598        n_past: i32,
599        seq_id: i32,
600        n_batch: i32,
601        logits_last: bool,
602        new_n_past: &mut i32,
603    ) -> Result<()> {
604        let ret = unsafe {
605            sys::mtmd_helper_eval_chunks(
606                self.ptr.as_ptr(),
607                lctx,
608                chunks.ptr.as_ptr(),
609                n_past,
610                seq_id,
611                n_batch,
612                logits_last,
613                new_n_past,
614            )
615        };
616        if ret != 0 {
617            return Err(MtmdError::EvalError(ret));
618        }
619        Ok(())
620    }
621
622    /// High-level helper: evaluate a single chunk.
623    ///
624    /// Works identically to [`eval_chunks`](Self::eval_chunks) but operates on
625    /// one chunk at a time.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`MtmdError::EvalError`] if evaluation fails.
630    #[allow(clippy::too_many_arguments, clippy::not_unsafe_ptr_arg_deref)]
631    pub fn eval_chunk_single(
632        &self,
633        lctx: *mut sys::llama_context,
634        chunk: &MtmdInputChunk<'_>,
635        n_past: i32,
636        seq_id: i32,
637        n_batch: i32,
638        logits_last: bool,
639        new_n_past: &mut i32,
640    ) -> Result<()> {
641        let ret = unsafe {
642            sys::mtmd_helper_eval_chunk_single(
643                self.ptr.as_ptr(),
644                lctx,
645                chunk.ptr,
646                n_past,
647                seq_id,
648                n_batch,
649                logits_last,
650                new_n_past,
651            )
652        };
653        if ret != 0 {
654            return Err(MtmdError::EvalError(ret));
655        }
656        Ok(())
657    }
658
659    /// Decode an image/audio chunk whose embeddings have already been
660    /// computed (e.g. via [`encode_chunk`](Self::encode_chunk) followed by
661    /// [`output_embd`](Self::output_embd)).
662    ///
663    /// Unlike [`eval_chunk_single`](Self::eval_chunk_single), this helper
664    /// handles batching plus the non-causal-attention setup required by
665    /// some models (e.g. Gemma 3, Gemma 4 audio) and the M-RoPE position
666    /// layout. Use it when the embeddings are already in hand and you want
667    /// the helper to take care of `llama_decode` plumbing.
668    ///
669    /// `encoded_embd` must contain `mtmd_image_tokens_get_n_tokens(chunk) *
670    /// llama_model_n_embd_inp(model)` `f32` elements. This call is **NOT
671    /// thread-safe**.
672    ///
673    /// # Errors
674    ///
675    /// Returns [`MtmdError::EvalError`] with code `-1` if `chunk` is not an
676    /// image/audio chunk, or `1` if `llama_decode` fails.
677    #[allow(clippy::too_many_arguments, clippy::not_unsafe_ptr_arg_deref)]
678    pub fn decode_image_chunk(
679        &self,
680        lctx: *mut sys::llama_context,
681        chunk: &MtmdInputChunk<'_>,
682        encoded_embd: &[f32],
683        n_past: i32,
684        seq_id: i32,
685        n_batch: i32,
686        new_n_past: &mut i32,
687    ) -> Result<()> {
688        let ret = unsafe {
689            sys::mtmd_helper_decode_image_chunk(
690                self.ptr.as_ptr(),
691                lctx,
692                chunk.ptr,
693                encoded_embd.as_ptr().cast_mut(),
694                n_past,
695                seq_id,
696                n_batch,
697                new_n_past,
698                // No post-decode callback; preserves prior single-shot behavior.
699                None,
700                std::ptr::null_mut(),
701            )
702        };
703        if ret != 0 {
704            return Err(MtmdError::EvalError(ret));
705        }
706        Ok(())
707    }
708
709    /// Returns a raw pointer to the underlying `mtmd_context`.
710    ///
711    /// # Safety
712    ///
713    /// The returned pointer is valid for the lifetime of this `MtmdContext`.
714    /// The caller must not free it.
715    #[must_use]
716    pub fn as_ptr(&self) -> *mut sys::mtmd_context {
717        self.ptr.as_ptr()
718    }
719}
720
721// ─────────────────────────────────────────────────────────────────────────────
722// MtmdInputText
723// ─────────────────────────────────────────────────────────────────────────────
724
725/// Text input for [`MtmdContext::tokenize`].
726///
727/// The prompt string must contain the media marker (see
728/// [`MtmdContext::default_marker`]) once for every bitmap to be embedded.
729///
730/// The prompt is passed to llama.cpp as an explicit pointer + length
731/// (`mtmd_input_text::text_len`), so interior NUL bytes are preserved rather
732/// than truncating the prompt — use [`MtmdInputText::from_bytes`] when the
733/// prompt is not guaranteed NUL-free.
734#[derive(Debug)]
735pub struct MtmdInputText<'a> {
736    /// Prompt bytes followed by a trailing NUL sentinel. The sentinel keeps the
737    /// buffer usable by any C code that still treats `text` as a C string; it is
738    /// excluded from `text_len`.
739    text: Vec<u8>,
740    /// Prompt length in bytes, excluding the trailing NUL sentinel. Passed
741    /// verbatim as `mtmd_input_text::text_len`, so interior NULs are honoured.
742    text_len: usize,
743    add_special: bool,
744    parse_special: bool,
745    _marker: std::marker::PhantomData<&'a ()>,
746}
747
748impl<'a> MtmdInputText<'a> {
749    /// Create a new `MtmdInputText` from a string prompt.
750    ///
751    /// * `text`          – the prompt (interior NUL bytes are permitted and
752    ///   preserved)
753    /// * `add_special`   – whether to add BOS/EOS tokens
754    /// * `parse_special` – whether to parse special tokens embedded in the text
755    #[must_use]
756    pub fn new(text: &'a str, add_special: bool, parse_special: bool) -> Self {
757        Self::from_bytes(text.as_bytes(), add_special, parse_special)
758    }
759
760    /// Create a new `MtmdInputText` from raw prompt bytes.
761    ///
762    /// Unlike a C string, the prompt length is carried explicitly, so `text`
763    /// may contain interior NUL bytes without truncating the prompt. The bytes
764    /// are copied into an owned, NUL-terminated buffer.
765    ///
766    /// * `text`          – the prompt bytes (typically UTF-8)
767    /// * `add_special`   – whether to add BOS/EOS tokens
768    /// * `parse_special` – whether to parse special tokens embedded in the text
769    #[must_use]
770    pub fn from_bytes(text: &'a [u8], add_special: bool, parse_special: bool) -> Self {
771        let text_len = text.len();
772        let mut buf = Vec::with_capacity(text_len + 1);
773        buf.extend_from_slice(text);
774        buf.push(0); // NUL sentinel, not counted in `text_len`
775        Self {
776            text: buf,
777            text_len,
778            add_special,
779            parse_special,
780            _marker: std::marker::PhantomData,
781        }
782    }
783
784    /// Try to create a new `MtmdInputText` from a string prompt.
785    ///
786    /// Retained for backwards compatibility. Interior NUL bytes are now
787    /// permitted (see [`MtmdInputText::new`]), so this never returns `Err`;
788    /// prefer [`new`](MtmdInputText::new).
789    ///
790    /// # Errors
791    ///
792    /// Never returns an error; the `Result` is kept for API stability.
793    pub fn try_new(
794        text: &'a str,
795        add_special: bool,
796        parse_special: bool,
797    ) -> std::result::Result<Self, std::ffi::NulError> {
798        Ok(Self::new(text, add_special, parse_special))
799    }
800}
801
802// ─────────────────────────────────────────────────────────────────────────────
803// MtmdBitmap
804// ─────────────────────────────────────────────────────────────────────────────
805
806/// An image or audio bitmap ready for multimodal encoding.
807///
808/// # Image bitmaps
809///
810/// The raw pixel data must be in RGBRGBRGB… (interleaved) format.  The total
811/// number of bytes must be `nx * ny * 3`.
812///
813/// # Audio bitmaps
814///
815/// The raw sample data must be little-endian `f32` PCM samples.  The total
816/// number of bytes must be `n_samples * 4`.
817pub struct MtmdBitmap {
818    ptr: NonNull<sys::mtmd_bitmap>,
819}
820
821unsafe impl Send for MtmdBitmap {}
822unsafe impl Sync for MtmdBitmap {}
823
824impl std::fmt::Debug for MtmdBitmap {
825    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
826        f.debug_struct("MtmdBitmap")
827            .field("nx", &self.nx())
828            .field("ny", &self.ny())
829            .field("n_bytes", &self.n_bytes())
830            .field("is_audio", &self.is_audio())
831            .finish()
832    }
833}
834
835impl Drop for MtmdBitmap {
836    fn drop(&mut self) {
837        unsafe { sys::mtmd_bitmap_free(self.ptr.as_ptr()) }
838    }
839}
840
841impl MtmdBitmap {
842    /// Create a bitmap from raw RGB pixel data.
843    ///
844    /// * `nx`   – image width in pixels
845    /// * `ny`   – image height in pixels
846    /// * `data` – raw pixel bytes in RGBRGB… format; must be `nx * ny * 3` bytes
847    ///
848    /// # Errors
849    ///
850    /// Returns [`MtmdError::BitmapCreateFailed`] if the underlying C call
851    /// returns null.
852    pub fn from_rgb(nx: u32, ny: u32, data: &[u8]) -> Result<Self> {
853        let ptr = unsafe { sys::mtmd_bitmap_init(nx, ny, data.as_ptr()) };
854        let ptr = NonNull::new(ptr).ok_or(MtmdError::BitmapCreateFailed)?;
855        Ok(Self { ptr })
856    }
857
858    /// Create an audio bitmap from PCM `f32` samples.
859    ///
860    /// * `samples` – slice of PCM float samples
861    ///
862    /// # Errors
863    ///
864    /// Returns [`MtmdError::BitmapCreateFailed`] if the underlying C call
865    /// returns null.
866    pub fn from_audio(samples: &[f32]) -> Result<Self> {
867        let ptr = unsafe { sys::mtmd_bitmap_init_from_audio(samples.len(), samples.as_ptr()) };
868        let ptr = NonNull::new(ptr).ok_or(MtmdError::BitmapCreateFailed)?;
869        Ok(Self { ptr })
870    }
871
872    /// Build an `MtmdBitmap` from a `mtmd_helper_bitmap_wrapper`, taking
873    /// ownership of the `bitmap` and freeing any `video_ctx`.
874    ///
875    /// The `from_file`/`from_buf` constructors only support image/audio input.
876    /// When the input is a video the helper returns a non-null `video_ctx`
877    /// (an open ffmpeg stream) which is not representable as an `MtmdBitmap`;
878    /// we free it here to avoid leaking it. Use [`MtmdVideo`] for video input.
879    fn from_wrapper(wrapper: sys::mtmd_helper_bitmap_wrapper) -> Result<Self> {
880        if !wrapper.video_ctx.is_null() {
881            unsafe { sys::mtmd_helper_video_free(wrapper.video_ctx) };
882        }
883        let ptr = NonNull::new(wrapper.bitmap).ok_or(MtmdError::BitmapCreateFailed)?;
884        Ok(Self { ptr })
885    }
886
887    /// Load a bitmap from a file (image or audio).
888    ///
889    /// Supported image formats: JPEG, PNG, BMP, GIF, and others handled by
890    /// `stb_image`.  Supported audio formats: WAV, MP3, FLAC (via miniaudio).
891    ///
892    /// # Errors
893    ///
894    /// Returns [`MtmdError::BitmapCreateFailed`] if the file cannot be loaded.
895    pub fn from_file(ctx: &MtmdContext, path: impl AsRef<Path>) -> Result<Self> {
896        let path = path.as_ref().to_str().ok_or(MtmdError::PathNotUtf8)?;
897        let c_path = CString::new(path)?;
898
899        // `placeholder = false`: load the real bitmap data (not a token-count
900        // placeholder). For image/audio the returned `video_ctx` is always null.
901        let wrapper = unsafe {
902            sys::mtmd_helper_bitmap_init_from_file(ctx.ptr.as_ptr(), c_path.as_ptr(), false)
903        };
904        Self::from_wrapper(wrapper)
905    }
906
907    /// Load a bitmap from an in-memory buffer containing a file.
908    ///
909    /// The format is auto-detected (image vs audio via magic bytes).
910    ///
911    /// # Errors
912    ///
913    /// Returns [`MtmdError::BitmapCreateFailed`] if decoding fails.
914    pub fn from_buf(ctx: &MtmdContext, buf: &[u8]) -> Result<Self> {
915        // `placeholder = false`: load the real bitmap data (not a token-count
916        // placeholder). For image/audio the returned `video_ctx` is always null.
917        let wrapper = unsafe {
918            sys::mtmd_helper_bitmap_init_from_buf(ctx.ptr.as_ptr(), buf.as_ptr(), buf.len(), false)
919        };
920        Self::from_wrapper(wrapper)
921    }
922
923    // ── Getters ───────────────────────────────────────────────────────────
924
925    /// Width in pixels (for images) or 0 (for audio).
926    #[must_use]
927    pub fn nx(&self) -> u32 {
928        unsafe { sys::mtmd_bitmap_get_nx(self.ptr.as_ptr()) }
929    }
930
931    /// Height in pixels (for images) or 0 (for audio).
932    #[must_use]
933    pub fn ny(&self) -> u32 {
934        unsafe { sys::mtmd_bitmap_get_ny(self.ptr.as_ptr()) }
935    }
936
937    /// Total number of bytes in the bitmap data.
938    #[must_use]
939    pub fn n_bytes(&self) -> usize {
940        unsafe { sys::mtmd_bitmap_get_n_bytes(self.ptr.as_ptr()) }
941    }
942
943    /// Returns `true` if this bitmap contains audio (rather than image) data.
944    #[must_use]
945    pub fn is_audio(&self) -> bool {
946        unsafe { sys::mtmd_bitmap_is_audio(self.ptr.as_ptr()) }
947    }
948
949    /// Return the raw pixel / sample data.
950    #[must_use]
951    pub fn data(&self) -> &[u8] {
952        let n = self.n_bytes();
953        if n == 0 {
954            return &[];
955        }
956        let ptr = unsafe { sys::mtmd_bitmap_get_data(self.ptr.as_ptr()) };
957        unsafe { slice::from_raw_parts(ptr, n) }
958    }
959
960    /// Return the optional ID string attached to this bitmap (used for KV
961    /// cache tracking), or `None` if no ID has been set.
962    #[must_use]
963    pub fn id(&self) -> Option<&str> {
964        let ptr = unsafe { sys::mtmd_bitmap_get_id(self.ptr.as_ptr()) };
965        if ptr.is_null() {
966            return None;
967        }
968        unsafe { CStr::from_ptr(ptr) }.to_str().ok()
969    }
970
971    /// Attach an optional ID string to this bitmap (used for KV cache
972    /// tracking).
973    ///
974    /// # Errors
975    ///
976    /// Returns an error if `id` contains an interior NUL byte.
977    pub fn set_id(&mut self, id: &str) -> std::result::Result<(), std::ffi::NulError> {
978        let cs = CString::new(id)?;
979        unsafe { sys::mtmd_bitmap_set_id(self.ptr.as_ptr(), cs.as_ptr()) };
980        Ok(())
981    }
982}
983
984// ─────────────────────────────────────────────────────────────────────────────
985// Video input
986// ─────────────────────────────────────────────────────────────────────────────
987
988// `free()` from libc — used to release the heap-allocated text returned by
989// `mtmd_helper_video_read_next` (the C side allocates it with strdup/malloc and
990// documents that the caller must release it with `free()`).
991extern "C" {
992    fn free(ptr: *mut std::os::raw::c_void);
993}
994
995/// Parameters controlling how a [`MtmdVideo`] stream is opened and sampled.
996///
997/// Obtain a default-initialised instance via [`MtmdVideoParams::default()`]
998/// (which mirrors `mtmd_helper_video_init_params_default`: ~4 fps, native
999/// `ffmpeg`/`ffprobe` from `PATH`, and a 5 s timestamp interval) and tweak it
1000/// with the builder methods.
1001pub struct MtmdVideoParams {
1002    params: sys::mtmd_helper_video_init_params,
1003    // Keeps the `ffmpeg_bin_dir` C string alive for as long as `params`
1004    // borrows it via a raw pointer.
1005    ffmpeg_bin_dir: Option<CString>,
1006}
1007
1008impl std::fmt::Debug for MtmdVideoParams {
1009    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1010        f.debug_struct("MtmdVideoParams")
1011            .field("fps_target", &self.params.fps_target)
1012            .field("timestamp_interval_ms", &self.params.timestamp_interval_ms)
1013            .field("ffmpeg_bin_dir", &self.ffmpeg_bin_dir)
1014            .finish()
1015    }
1016}
1017
1018impl Default for MtmdVideoParams {
1019    fn default() -> Self {
1020        let params = unsafe { sys::mtmd_helper_video_init_params_default() };
1021        Self {
1022            params,
1023            ffmpeg_bin_dir: None,
1024        }
1025    }
1026}
1027
1028impl MtmdVideoParams {
1029    /// Desired output frame rate. Values `<= 0` mean "use the video's native
1030    /// fps" (the default is ~4 fps).
1031    #[must_use]
1032    pub fn fps_target(mut self, fps: f32) -> Self {
1033        self.params.fps_target = fps;
1034        self
1035    }
1036
1037    /// Interval, in milliseconds, between inserted timestamp text chunks (e.g.
1038    /// `"[10m50.5s]"`). Values `<= 0` disable timestamps (default 5000 ms).
1039    #[must_use]
1040    pub fn timestamp_interval_ms(mut self, ms: i64) -> Self {
1041        self.params.timestamp_interval_ms = ms;
1042        self
1043    }
1044
1045    /// Directory containing the `ffmpeg`/`ffprobe` binaries. Pass `None` to
1046    /// search `PATH` (the default).
1047    ///
1048    /// # Errors
1049    ///
1050    /// Returns an error if `dir` contains an interior NUL byte.
1051    pub fn ffmpeg_bin_dir(mut self, dir: Option<&str>) -> Result<Self> {
1052        match dir {
1053            None => {
1054                self.params.ffmpeg_bin_dir = std::ptr::null();
1055                self.ffmpeg_bin_dir = None;
1056            }
1057            Some(d) => {
1058                let cs = CString::new(d)?;
1059                self.params.ffmpeg_bin_dir = cs.as_ptr();
1060                // Store the owner so the pointer above stays valid.
1061                self.ffmpeg_bin_dir = Some(cs);
1062            }
1063        }
1064        Ok(self)
1065    }
1066}
1067
1068/// Metadata describing an open [`MtmdVideo`] stream.
1069#[derive(Debug, Clone, Copy, PartialEq)]
1070pub struct MtmdVideoInfo {
1071    /// Frame width in pixels.
1072    pub width: u32,
1073    /// Frame height in pixels.
1074    pub height: u32,
1075    /// Effective frames-per-second (the `fps_target` if set, else native fps).
1076    pub fps: f32,
1077    /// Estimated total frame count at the effective fps (`-1` if unknown).
1078    pub n_frames: i32,
1079}
1080
1081/// One item read from a [`MtmdVideo`] stream by [`MtmdVideo::read_next`].
1082#[derive(Debug)]
1083pub enum MtmdVideoItem {
1084    /// A decoded video frame, ready to be tokenized like any other image
1085    /// [`MtmdBitmap`].
1086    Frame(MtmdBitmap),
1087    /// A timestamp text marker (e.g. `"[10m50.5s]"`) to be inserted into the
1088    /// prompt between frames.
1089    Text(String),
1090}
1091
1092/// An open video stream, decoded frame-by-frame via `ffmpeg`.
1093///
1094/// The notion of "video" exists only at the helper level — it is decoded into
1095/// a sequence of image [frames](MtmdVideoItem::Frame) and timestamp
1096/// [text markers](MtmdVideoItem::Text) which are then fed through the normal
1097/// multimodal pipeline.
1098///
1099/// Requires a build with video support (see [`MtmdContext::supports_video`])
1100/// and `ffmpeg`/`ffprobe` available at runtime.
1101///
1102/// # Example
1103///
1104/// ```no_run
1105/// # #[cfg(feature = "mtmd")]
1106/// # fn run(mtmd_ctx: &llama_cpp_4::mtmd::MtmdContext) -> Result<(), llama_cpp_4::mtmd::MtmdError> {
1107/// use std::path::Path;
1108/// use llama_cpp_4::mtmd::{MtmdVideo, MtmdVideoParams, MtmdVideoItem};
1109///
1110/// let mut video = MtmdVideo::from_file(mtmd_ctx, Path::new("clip.mp4"),
1111///                                      &MtmdVideoParams::default())?;
1112/// while let Some(item) = video.read_next()? {
1113///     match item {
1114///         MtmdVideoItem::Frame(bitmap) => { /* tokenize the frame */ }
1115///         MtmdVideoItem::Text(ts)      => { /* insert the timestamp marker */ }
1116///     }
1117/// }
1118/// # Ok(())
1119/// # }
1120/// ```
1121pub struct MtmdVideo {
1122    ptr: NonNull<sys::mtmd_helper_video>,
1123}
1124
1125impl std::fmt::Debug for MtmdVideo {
1126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1127        f.debug_struct("MtmdVideo")
1128            .field("info", &self.info())
1129            .finish()
1130    }
1131}
1132
1133impl Drop for MtmdVideo {
1134    fn drop(&mut self) {
1135        unsafe { sys::mtmd_helper_video_free(self.ptr.as_ptr()) }
1136    }
1137}
1138
1139impl MtmdVideo {
1140    /// Open a video file for frame-by-frame decoding.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns [`MtmdError::VideoInitFailed`] if the stream cannot be opened
1145    /// (no video support compiled in, `ffprobe` not found, file unreadable,
1146    /// …), or [`MtmdError::InvalidPath`] / [`MtmdError::PathNotUtf8`] for a bad
1147    /// path.
1148    pub fn from_file(
1149        ctx: &MtmdContext,
1150        path: impl AsRef<Path>,
1151        params: &MtmdVideoParams,
1152    ) -> Result<Self> {
1153        let path = path.as_ref().to_str().ok_or(MtmdError::PathNotUtf8)?;
1154        let c_path = CString::new(path)?;
1155        let ptr = unsafe {
1156            sys::mtmd_helper_video_init(ctx.ptr.as_ptr(), c_path.as_ptr(), params.params)
1157        };
1158        let ptr = NonNull::new(ptr).ok_or(MtmdError::VideoInitFailed)?;
1159        Ok(Self { ptr })
1160    }
1161
1162    /// Open a video from an in-memory buffer. The buffer is copied internally,
1163    /// so it need not outlive this call.
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns [`MtmdError::VideoInitFailed`] if the stream cannot be opened.
1168    pub fn from_buf(ctx: &MtmdContext, buf: &[u8], params: &MtmdVideoParams) -> Result<Self> {
1169        let ptr = unsafe {
1170            sys::mtmd_helper_video_init_from_buf(
1171                ctx.ptr.as_ptr(),
1172                buf.as_ptr(),
1173                buf.len(),
1174                params.params,
1175            )
1176        };
1177        let ptr = NonNull::new(ptr).ok_or(MtmdError::VideoInitFailed)?;
1178        Ok(Self { ptr })
1179    }
1180
1181    /// Return metadata (resolution, effective fps, estimated frame count) for
1182    /// this stream.
1183    #[must_use]
1184    pub fn info(&self) -> MtmdVideoInfo {
1185        let info = unsafe { sys::mtmd_helper_video_get_info(self.ptr.as_ptr()) };
1186        MtmdVideoInfo {
1187            width: info.width,
1188            height: info.height,
1189            fps: info.fps,
1190            n_frames: info.n_frames,
1191        }
1192    }
1193
1194    /// Read the next item from the stream.
1195    ///
1196    /// Returns `Ok(Some(item))` for each frame or timestamp marker, and
1197    /// `Ok(None)` once the end of the stream is reached.
1198    ///
1199    /// # Errors
1200    ///
1201    /// Returns [`MtmdError::VideoReadError`] on a decode error.
1202    pub fn read_next(&mut self) -> Result<Option<MtmdVideoItem>> {
1203        let mut out_bitmap: *mut sys::mtmd_bitmap = std::ptr::null_mut();
1204        let mut out_text: *mut std::os::raw::c_char = std::ptr::null_mut();
1205        let ret = unsafe {
1206            sys::mtmd_helper_video_read_next(
1207                self.ptr.as_ptr(),
1208                &raw mut out_bitmap,
1209                &raw mut out_text,
1210            )
1211        };
1212        match ret {
1213            0 => {
1214                if let Some(ptr) = NonNull::new(out_bitmap) {
1215                    Ok(Some(MtmdVideoItem::Frame(MtmdBitmap { ptr })))
1216                } else if !out_text.is_null() {
1217                    let text = unsafe { CStr::from_ptr(out_text) }
1218                        .to_string_lossy()
1219                        .into_owned();
1220                    // The C side allocated this with strdup/malloc; release it.
1221                    unsafe { free(out_text.cast()) };
1222                    Ok(Some(MtmdVideoItem::Text(text)))
1223                } else {
1224                    // Success but nothing produced — treat as end of stream.
1225                    Ok(None)
1226                }
1227            }
1228            -1 => Ok(None), // EOF
1229            other => Err(MtmdError::VideoReadError(other)),
1230        }
1231    }
1232}
1233
1234// ─────────────────────────────────────────────────────────────────────────────
1235// MtmdInputChunks
1236// ─────────────────────────────────────────────────────────────────────────────
1237
1238/// A list of tokenized input chunks produced by [`MtmdContext::tokenize`].
1239///
1240/// Each chunk is either a text token sequence or a set of image/audio tokens.
1241pub struct MtmdInputChunks {
1242    ptr: NonNull<sys::mtmd_input_chunks>,
1243}
1244
1245impl std::fmt::Debug for MtmdInputChunks {
1246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247        f.debug_struct("MtmdInputChunks")
1248            .field("len", &self.len())
1249            .finish()
1250    }
1251}
1252
1253impl Drop for MtmdInputChunks {
1254    fn drop(&mut self) {
1255        unsafe { sys::mtmd_input_chunks_free(self.ptr.as_ptr()) }
1256    }
1257}
1258
1259impl MtmdInputChunks {
1260    /// Create a new, empty chunk list.  Populated by
1261    /// [`MtmdContext::tokenize`].
1262    ///
1263    /// # Panics
1264    ///
1265    /// Panics if the underlying C allocation fails (OOM).
1266    #[must_use]
1267    pub fn new() -> Self {
1268        let ptr = unsafe { sys::mtmd_input_chunks_init() };
1269        let ptr = NonNull::new(ptr).expect("mtmd_input_chunks_init returned null");
1270        Self { ptr }
1271    }
1272
1273    /// Number of chunks in this list.
1274    #[must_use]
1275    pub fn len(&self) -> usize {
1276        unsafe { sys::mtmd_input_chunks_size(self.ptr.as_ptr()) }
1277    }
1278
1279    /// Returns `true` if there are no chunks.
1280    #[must_use]
1281    pub fn is_empty(&self) -> bool {
1282        self.len() == 0
1283    }
1284
1285    /// Get the `idx`-th chunk.  Returns `None` if `idx >= len()`.
1286    #[must_use]
1287    pub fn get(&self, idx: usize) -> Option<MtmdInputChunk<'_>> {
1288        if idx >= self.len() {
1289            return None;
1290        }
1291        let ptr = unsafe { sys::mtmd_input_chunks_get(self.ptr.as_ptr(), idx) };
1292        if ptr.is_null() {
1293            return None;
1294        }
1295        Some(MtmdInputChunk {
1296            ptr,
1297            _marker: std::marker::PhantomData,
1298        })
1299    }
1300
1301    /// Iterate over all chunks.
1302    pub fn iter(&self) -> impl Iterator<Item = MtmdInputChunk<'_>> {
1303        (0..self.len()).filter_map(|i| self.get(i))
1304    }
1305
1306    /// Total number of tokens across all chunks.
1307    ///
1308    /// Equivalent to `mtmd_helper_get_n_tokens`.
1309    #[must_use]
1310    pub fn n_tokens(&self) -> usize {
1311        unsafe { sys::mtmd_helper_get_n_tokens(self.ptr.as_ptr()) }
1312    }
1313
1314    /// Total number of *positions* across all chunks (used for KV-cache
1315    /// tracking with M-RoPE models where positions ≠ tokens).
1316    ///
1317    /// Equivalent to `mtmd_helper_get_n_pos`.
1318    #[must_use]
1319    pub fn n_pos(&self) -> i32 {
1320        unsafe { sys::mtmd_helper_get_n_pos(self.ptr.as_ptr()) }
1321    }
1322}
1323
1324impl Default for MtmdInputChunks {
1325    fn default() -> Self {
1326        Self::new()
1327    }
1328}
1329
1330// ─────────────────────────────────────────────────────────────────────────────
1331// MtmdInputChunkType
1332// ─────────────────────────────────────────────────────────────────────────────
1333
1334/// The type of an [`MtmdInputChunk`].
1335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1336pub enum MtmdInputChunkType {
1337    /// Plain text tokens.
1338    Text,
1339    /// Image tokens (embeddings produced by the vision encoder).
1340    Image,
1341    /// Audio tokens (embeddings produced by the audio encoder).
1342    Audio,
1343}
1344
1345impl From<sys::mtmd_input_chunk_type> for MtmdInputChunkType {
1346    fn from(v: sys::mtmd_input_chunk_type) -> Self {
1347        // mtmd_input_chunk_type is a plain C `typedef unsigned int`.
1348        // The variants are exported as free-standing constants.
1349        if v == sys::MTMD_INPUT_CHUNK_TYPE_IMAGE {
1350            Self::Image
1351        } else if v == sys::MTMD_INPUT_CHUNK_TYPE_AUDIO {
1352            Self::Audio
1353        } else {
1354            Self::Text
1355        }
1356    }
1357}
1358
1359// ─────────────────────────────────────────────────────────────────────────────
1360// MtmdInputChunk
1361// ─────────────────────────────────────────────────────────────────────────────
1362
1363/// A single tokenized input chunk (text, image, or audio).
1364///
1365/// Instances are borrowed from an [`MtmdInputChunks`] list and live as long
1366/// as that list.
1367#[derive(Debug)]
1368pub struct MtmdInputChunk<'chunks> {
1369    ptr: *const sys::mtmd_input_chunk,
1370    _marker: std::marker::PhantomData<&'chunks MtmdInputChunks>,
1371}
1372
1373impl<'chunks> MtmdInputChunk<'chunks> {
1374    /// The type of this chunk.
1375    #[must_use]
1376    pub fn chunk_type(&self) -> MtmdInputChunkType {
1377        let t = unsafe { sys::mtmd_input_chunk_get_type(self.ptr) };
1378        MtmdInputChunkType::from(t)
1379    }
1380
1381    /// Total number of tokens in this chunk.
1382    #[must_use]
1383    pub fn n_tokens(&self) -> usize {
1384        unsafe { sys::mtmd_input_chunk_get_n_tokens(self.ptr) }
1385    }
1386
1387    /// Number of temporal positions (equals `n_tokens` for non-M-RoPE models).
1388    #[must_use]
1389    pub fn n_pos(&self) -> i32 {
1390        unsafe { sys::mtmd_input_chunk_get_n_pos(self.ptr) }
1391    }
1392
1393    /// Return the raw llama token IDs for a **text** chunk.
1394    ///
1395    /// Returns `None` if this chunk is not a text chunk.
1396    #[must_use]
1397    pub fn text_tokens(&self) -> Option<&[i32]> {
1398        if self.chunk_type() != MtmdInputChunkType::Text {
1399            return None;
1400        }
1401        let mut n: usize = 0;
1402        let ptr = unsafe { sys::mtmd_input_chunk_get_tokens_text(self.ptr, &raw mut n) };
1403        if ptr.is_null() || n == 0 {
1404            return Some(&[]);
1405        }
1406        Some(unsafe { slice::from_raw_parts(ptr, n) })
1407    }
1408
1409    /// Return the image token metadata for an **image** or **audio** chunk.
1410    ///
1411    /// Returns `None` for text chunks.
1412    #[must_use]
1413    pub fn image_tokens(&self) -> Option<MtmdImageTokens<'chunks>> {
1414        match self.chunk_type() {
1415            MtmdInputChunkType::Image | MtmdInputChunkType::Audio => {}
1416            MtmdInputChunkType::Text => return None,
1417        }
1418        let ptr = unsafe { sys::mtmd_input_chunk_get_tokens_image(self.ptr) };
1419        if ptr.is_null() {
1420            return None;
1421        }
1422        Some(MtmdImageTokens {
1423            ptr,
1424            _marker: std::marker::PhantomData,
1425        })
1426    }
1427
1428    /// Optional ID attached to this chunk (used for KV cache tracking).
1429    #[must_use]
1430    pub fn id(&self) -> Option<&str> {
1431        let ptr = unsafe { sys::mtmd_input_chunk_get_id(self.ptr) };
1432        if ptr.is_null() {
1433            return None;
1434        }
1435        unsafe { CStr::from_ptr(ptr) }.to_str().ok()
1436    }
1437
1438    /// Returns the raw `*const mtmd_input_chunk` pointer.
1439    ///
1440    /// # Safety
1441    ///
1442    /// The returned pointer is valid for the lifetime of the parent
1443    /// `MtmdInputChunks`.
1444    #[must_use]
1445    pub fn as_ptr(&self) -> *const sys::mtmd_input_chunk {
1446        self.ptr
1447    }
1448}
1449
1450// ─────────────────────────────────────────────────────────────────────────────
1451// MtmdDecoderPos
1452// ─────────────────────────────────────────────────────────────────────────────
1453
1454/// Per-token position used by M-RoPE decoder attention.
1455///
1456/// `t` is the temporal axis, `x`/`y` the spatial axes. `z` is reserved for
1457/// future use. Values are *relative* to a base `pos_0` provided when the
1458/// position is computed.
1459#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1460#[repr(C)]
1461pub struct MtmdDecoderPos {
1462    /// Temporal index.
1463    pub t: u32,
1464    /// Spatial X.
1465    pub x: u32,
1466    /// Spatial Y.
1467    pub y: u32,
1468    /// Reserved.
1469    pub z: u32,
1470}
1471
1472// ─────────────────────────────────────────────────────────────────────────────
1473// MtmdImageTokens
1474// ─────────────────────────────────────────────────────────────────────────────
1475
1476/// Image/audio token metadata attached to a non-text [`MtmdInputChunk`].
1477#[derive(Debug)]
1478pub struct MtmdImageTokens<'chunks> {
1479    ptr: *const sys::mtmd_image_tokens,
1480    _marker: std::marker::PhantomData<&'chunks MtmdInputChunks>,
1481}
1482
1483impl MtmdImageTokens<'_> {
1484    /// Total number of embedding tokens.
1485    #[must_use]
1486    pub fn n_tokens(&self) -> usize {
1487        unsafe { sys::mtmd_image_tokens_get_n_tokens(self.ptr) }
1488    }
1489
1490    /// Width of the token grid.
1491    #[must_use]
1492    pub fn nx(&self) -> usize {
1493        unsafe { sys::mtmd_image_tokens_get_nx(self.ptr) }
1494    }
1495
1496    /// Height of the token grid.
1497    #[must_use]
1498    pub fn ny(&self) -> usize {
1499        unsafe { sys::mtmd_image_tokens_get_ny(self.ptr) }
1500    }
1501
1502    /// Number of temporal positions (M-RoPE variant; equals `n_tokens` otherwise).
1503    #[must_use]
1504    pub fn n_pos(&self) -> i32 {
1505        unsafe { sys::mtmd_image_tokens_get_n_pos(self.ptr) }
1506    }
1507
1508    /// Optional ID for KV cache tracking.
1509    #[must_use]
1510    pub fn id(&self) -> Option<&str> {
1511        let ptr = unsafe { sys::mtmd_image_tokens_get_id(self.ptr) };
1512        if ptr.is_null() {
1513            return None;
1514        }
1515        unsafe { CStr::from_ptr(ptr) }.to_str().ok()
1516    }
1517
1518    /// Compute the per-token decoder positions used by M-RoPE models.
1519    ///
1520    /// Returns a vector of length [`n_tokens`](Self::n_tokens). Each entry
1521    /// is relative to `pos_0`; for non-M-RoPE models this typically reduces
1522    /// to `(0, i, 0, 0)` for the i-th token.
1523    ///
1524    /// Wraps `mtmd_helper_image_get_decoder_pos`.
1525    #[must_use]
1526    pub fn decoder_positions(&self, pos_0: i32) -> Vec<MtmdDecoderPos> {
1527        let n = self.n_tokens();
1528        let mut out = vec![MtmdDecoderPos::default(); n];
1529        if n == 0 {
1530            return out;
1531        }
1532        unsafe {
1533            sys::mtmd_helper_image_get_decoder_pos(
1534                self.ptr,
1535                pos_0,
1536                out.as_mut_ptr().cast::<sys::mtmd_decoder_pos>(),
1537            );
1538        }
1539        out
1540    }
1541}
1542
1543// ─────────────────────────────────────────────────────────────────────────────
1544// LlamaContext extension
1545// ─────────────────────────────────────────────────────────────────────────────
1546
1547use crate::context::LlamaContext;
1548
1549impl LlamaContext<'_> {
1550    /// Expose the raw `llama_context` pointer for use with mtmd helpers.
1551    ///
1552    /// # Safety
1553    ///
1554    /// The pointer is valid for the lifetime of this `LlamaContext` and must
1555    /// not be freed by the caller.
1556    #[must_use]
1557    pub fn as_ptr(&self) -> *mut sys::llama_context {
1558        self.context.as_ptr()
1559    }
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564    use super::*;
1565
1566    #[test]
1567    fn decoder_pos_layout_matches_sys() {
1568        // The Rust MtmdDecoderPos is cast to sys::mtmd_decoder_pos at the
1569        // FFI boundary in `MtmdImageTokens::decoder_positions`. Verify the
1570        // assumption.
1571        assert_eq!(
1572            std::mem::size_of::<MtmdDecoderPos>(),
1573            std::mem::size_of::<sys::mtmd_decoder_pos>(),
1574        );
1575        assert_eq!(
1576            std::mem::align_of::<MtmdDecoderPos>(),
1577            std::mem::align_of::<sys::mtmd_decoder_pos>(),
1578        );
1579        assert_eq!(std::mem::offset_of!(MtmdDecoderPos, t), 0);
1580        assert_eq!(std::mem::offset_of!(MtmdDecoderPos, x), 4);
1581        assert_eq!(std::mem::offset_of!(MtmdDecoderPos, y), 8);
1582        assert_eq!(std::mem::offset_of!(MtmdDecoderPos, z), 12);
1583    }
1584
1585    #[test]
1586    fn input_text_records_byte_length_and_nul_terminates() {
1587        let input = MtmdInputText::new("hello", true, false);
1588        // text_len is the prompt length, excluding the trailing NUL sentinel.
1589        assert_eq!(input.text_len, 5);
1590        assert_eq!(input.text, b"hello\0");
1591        assert!(input.add_special);
1592        assert!(!input.parse_special);
1593    }
1594
1595    #[test]
1596    fn input_text_preserves_interior_nul() {
1597        // The whole point of upstream's `text_len`: a prompt with an embedded
1598        // NUL must keep its full length rather than truncating at the NUL.
1599        let input = MtmdInputText::from_bytes(b"a\0b", false, true);
1600        assert_eq!(input.text_len, 3);
1601        assert_eq!(input.text, b"a\0b\0");
1602    }
1603
1604    #[test]
1605    fn input_text_try_new_is_infallible() {
1606        let input = MtmdInputText::try_new("marker \u{1} data", true, true)
1607            .expect("try_new no longer rejects any input");
1608        assert_eq!(input.text_len, "marker \u{1} data".len());
1609    }
1610}