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}