Commit 7d7f02875 for llama.cpp

commit 7d7f0287530bc8981690f361d8c1627dbae1c99a
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Sun Nov 8 15:49:06 2026 +0100

    mtmd: refactoring: remove clip.h, consolidate code into mtmd (#31865)

diff --git a/conversion/chatterbox.py b/conversion/chatterbox.py
index bca0a2633..d438228e5 100644
--- a/conversion/chatterbox.py
+++ b/conversion/chatterbox.py
@@ -355,7 +355,7 @@ class ChatterboxMmprojModel(MmprojModel):
     def set_gguf_parameters(self):
         self.gguf_writer.add_file_type(self.ftype)

-        # speaker encoder, the front-end hparams are hard-coded in clip.cpp
+        # speaker encoder, the front-end hparams are hard-coded in tools/mtmd
         self.gguf_writer.add_clip_has_audio_encoder(True)
         self.gguf_writer.add_clip_audio_projector_type(gguf.VisionProjectorType.CHATTERBOX_SPKENC)
         self.gguf_writer.add_audio_projection_dim(80)
diff --git a/conversion/deepseek.py b/conversion/deepseek.py
index 581111da5..d559b604e 100644
--- a/conversion/deepseek.py
+++ b/conversion/deepseek.py
@@ -1078,7 +1078,7 @@ class DeepseekV4FlashVisionModel(MmprojModel):
         self.gguf_writer.add_vision_use_silu(True) # SwiGLU MLP
         self.gguf_writer.add_vision_projector_scale_factor(self.hparams_vision["downsample_ratio"])
         self.gguf_writer.add_vision_min_pixels(self.hparams_vision["min_pixels"])
-        # hardcoded on the C++ side (see PROJECTOR_TYPE_DEEPSEEK4V in clip.cpp)
+        # hardcoded on the C++ side (see PROJECTOR_TYPE_DEEPSEEK4V in tools/mtmd)
         # if future models use different values, add GGUF keys for those
         assert self.global_config["vision_max_n_token"] == 384
         assert self.global_config["vision_max_wh_ratio"] == 8
diff --git a/conversion/deepseek41.py b/conversion/deepseek41.py
index 53980c1d0..6b098a037 100644
--- a/conversion/deepseek41.py
+++ b/conversion/deepseek41.py
@@ -458,7 +458,7 @@ class DeepseekV41VisionModel(DeepseekV4FlashVisionModel):
         self.gguf_writer.add_vision_use_silu(True)
         self.gguf_writer.add_vision_projector_scale_factor(self.hparams_vision["downsample_ratio"])
         self.gguf_writer.add_vision_min_pixels(self.hparams_vision["min_pixels"])
-        # hardcoded in clip.cpp (PROJECTOR_TYPE_DEEPSEEK41V); add GGUF keys if a later model differs
+        # hardcoded in tools/mtmd (PROJECTOR_TYPE_DEEPSEEK41V); add GGUF keys if a later model differs
         assert self.hparams_vision["max_image_tokens"] == 1024, self.hparams_vision["max_image_tokens"]
         assert self.hparams_vision["max_wh_ratio"] is None, self.hparams_vision["max_wh_ratio"]
         assert self.hparams_vision["rope_theta"] == 10000, self.hparams_vision["rope_theta"]
diff --git a/conversion/drama3.py b/conversion/drama3.py
index 7c590a014..ac01f481c 100644
--- a/conversion/drama3.py
+++ b/conversion/drama3.py
@@ -77,7 +77,7 @@ class Drama3MmprojModel(MmprojModel):
         self.gguf_writer.add_gen_audio_head_count(cfg["n_head"])
         self.gguf_writer.add_gen_audio_head_count_kv(cfg["n_local_heads"])
         self.gguf_writer.add_gen_audio_attention_layernorm_eps(cfg["norm_eps"])
-        # other tower and codec hparams are hardcoded in clip.cpp
+        # other tower and codec hparams are hardcoded in tools/mtmd

     @classmethod
     def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
diff --git a/conversion/lfm3.py b/conversion/lfm3.py
index a0287d4bb..38fbb5a31 100644
--- a/conversion/lfm3.py
+++ b/conversion/lfm3.py
@@ -181,11 +181,11 @@ class LFM3VLVisionMixin(MmprojModel):

     def _set_lfm3vl_gguf_parameters(self):
         assert self.hparams_vision is not None
-        # clip.cpp hardcodes the GELU projector and its input norm eps
+        # mtmd hardcodes the GELU projector and its input norm eps
         if (self.global_config["projector_hidden_act"] != "gelu" or not self.global_config["projector_bias"]
                 or self.global_config["projector_layer_norm_eps"] != 1e-6):
             raise ValueError("unsupported projector_hidden_act / projector_bias / projector_layer_norm_eps")
-        # clip.cpp hardcodes the vision rope base, the RGB patchifier and the 3x3 spatial conv
+        # mtmd hardcodes the vision rope base, the RGB patchifier and the 3x3 spatial conv
         if (self.hparams_vision["rope_parameters"]["rope_theta"] != 1e6 or self.hparams_vision["in_channels"] != 3
                 or self.hparams_vision["conv_kernel_size"] != 3):
             raise ValueError("unsupported vision rope_theta / in_channels / conv_kernel_size")
@@ -347,7 +347,7 @@ class LFM3AudioMmprojModel(MmprojModel):
         "feed_forward.w3":       gguf.MODEL_TENSOR.A_GEN_WAV_TFM_FFN_UP,
         "feed_forward.w2":       gguf.MODEL_TENSOR.A_GEN_WAV_TFM_FFN_DOWN,
     }
-    # the codec graph in clip.cpp hardcodes the liquid-codec FSQ front end and decoder shape, conversion fails on a mismatch
+    # the codec graph in mtmd hardcodes the liquid-codec FSQ front end and decoder shape, conversion fails on a mismatch
     _codec = {
         "model_type": "liquid_codec", "fsq_levels": [8, 7, 6, 6], "n_q": 12, "quantizer_dim": 256,
         "encoder_dim": 512, "stft_n_fft": 1280, "stft_hop_length": 320, "stft_win_length": 1280, "stft_frame_stack": 6,
@@ -405,7 +405,7 @@ class LFM3AudioMmprojModel(MmprojModel):
             if fe[k] != v:
                 raise ValueError(f"unsupported feature_extractor.{k}={fe[k]!r}, expected {v!r}")

-        # clip.cpp hardcodes the encoder rope base
+        # mtmd hardcodes the encoder rope base
         if self.hparams_audio["rope_parameters"]["rope_theta"] != 1e6:
             raise ValueError("unsupported audio encoder rope_theta")

@@ -420,7 +420,7 @@ class LFM3AudioMmprojModel(MmprojModel):
         if not self._audio_output:
             return

-        # audio generation: depthformer hparams, the codec ones are constant and live in clip.cpp
+        # audio generation: depthformer hparams, the codec ones are constant and live in mtmd
         gen = self.get_generation_config()
         for k, v in self._generation.items():
             if gen[k] != v:
diff --git a/conversion/minicpm.py b/conversion/minicpm.py
index d8fceb0c0..8c7be8a9a 100644
--- a/conversion/minicpm.py
+++ b/conversion/minicpm.py
@@ -161,7 +161,7 @@ class MiniCPMV4_6VisionModel(MmprojModel):
             # In MiniCPM-V 4.6 `vision_config.image_size` (980) describes the SigLIP
             # positional embedding bucket grid (70 x 70), while the per-slice processing
             # resolution is the preprocessor's `scale_resolution` (typically 448).
-            # The CLIP loader in tools/mtmd/clip.cpp consumes `clip.vision.image_size`
+            # The mmproj loader in tools/mtmd/mtmd-model.cpp consumes `clip.vision.image_size`
             # as the slice size and warmup resolution, so report `scale_resolution` there
             # to match the upstream MiniCPMV4_6ImageProcessorPil slicing rules.
             scale_resolution = self.preprocessor_config.get(
@@ -173,7 +173,7 @@ class MiniCPMV4_6VisionModel(MmprojModel):
         super().set_gguf_parameters()
         assert self.hparams_vision is not None

-        # projector type string is consumed by clip_projector_type_from_string() in clip.cpp
+        # projector type string is consumed by projector_type_from_string() in tools/mtmd/mtmd-impl.h
         self.gguf_writer.add_clip_projector_type(self.projector_type)

         self.gguf_writer.add_vision_projector_scale_factor(
@@ -243,7 +243,7 @@ class MiniCPMV4_7VisionModel(MiniCPMV4_6VisionModel):
     projector_type = gguf.VisionProjectorType.MINICPMV4_7
     # MiniCPMV4_7ImageProcessorPil default
     default_scale_resolution = 448
-    # rows of v.tok_embd_sep, the order must match clip_suffix_rows() in clip-impl.h
+    # rows of v.tok_embd_sep, the order must match mtmd_suffix_rows() in tools/mtmd/mtmd-impl.h
     tok_embd_sep = ["</image>", "<slice>", "</slice>", "\n"]

     def get_downsample_mode(self) -> str:
diff --git a/conversion/nemotron.py b/conversion/nemotron.py
index 066149744..c818a73e1 100644
--- a/conversion/nemotron.py
+++ b/conversion/nemotron.py
@@ -208,7 +208,7 @@ class NemotronNanoV2VLModel(MmprojModel):
         return name, gen

     def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
-        # RADIO's pos_embed doesn't have .weight suffix, but clip.cpp expects it
+        # RADIO's pos_embed doesn't have .weight suffix, but mtmd expects it
         if "patch_generator.pos_embed" in name:
             # Downsample position embeddings for fixed 512x512 image size
             import torch.nn.functional as F
diff --git a/conversion/pockettts.py b/conversion/pockettts.py
index 1b5c84111..c9198a221 100644
--- a/conversion/pockettts.py
+++ b/conversion/pockettts.py
@@ -302,7 +302,7 @@ class PocketTTSMmprojModel(MmprojModel):
         # voice-prompt encoder: mimi encoder + speaker_proj
         if self.has_audio_encoder:
             self.gguf_writer.add_clip_has_audio_encoder(True)
-            # note: the 24kHz sample rate is hardcoded on the clip.cpp side, like the other audio models
+            # note: the 24kHz sample rate is hardcoded on the mtmd side, like the other audio models
             self.gguf_writer.add_clip_audio_projector_type(gguf.VisionProjectorType.POCKETTTS_SPKENC)
             self.gguf_writer.add_audio_projection_dim(self.n_embd_text)
             self.gguf_writer.add_audio_block_count(self.hparams_audio["num_hidden_layers"])
@@ -314,7 +314,7 @@ class PocketTTSMmprojModel(MmprojModel):
             self.gguf_writer.add_audio_num_mel_bins(1)

         # generation: flow-matching decoder + mimi decoder
-        # the SEANet and flow net hparams are constant across the family, clip.cpp holds them
+        # the SEANet and flow net hparams are constant across the family, mtmd holds them
         self.gguf_writer.add_clip_has_gen_audio_encoder(True)
         self.gguf_writer.add_clip_gen_audio_projector_type(gguf.VisionProjectorType.POCKETTTS_GEN)
         self.gguf_writer.add_gen_audio_projection_dim(self.n_embd_text)
diff --git a/conversion/qwen3tts.py b/conversion/qwen3tts.py
index cdb1e0c9b..28bb6e095 100644
--- a/conversion/qwen3tts.py
+++ b/conversion/qwen3tts.py
@@ -235,11 +235,11 @@ class Qwen3TTSSpeakerEncoderModel(MmprojModel):

         # handle speaker encoder config
         self.gguf_writer.add_audio_projection_dim(self.n_embd_text)
-        # mel_spectrogram() front-end: sr=24000, n_fft=1024, hop=256, n_mels=128, fmin=0, fmax=12000 (=sr/2, the clip.cpp default)
+        # mel_spectrogram() front-end: sr=24000, n_fft=1024, hop=256, n_mels=128, fmin=0, fmax=12000 (=sr/2, the mtmd default)
         self.gguf_writer.add_audio_num_mel_bins(128)
         # 3 SE-Res2Net stages; the stem conv, mfa, asp and fc are not counted here
         self.gguf_writer.add_audio_block_count(3)
-        # ECAPA-TDNN has no attention/FFN, these are dummy to allow clip.cpp to load it
+        # ECAPA-TDNN has no attention/FFN, these are dummy to allow mtmd to load it
         self.gguf_writer.add_audio_embedding_length(1536)
         self.gguf_writer.add_audio_head_count(1)
         self.gguf_writer.add_audio_feed_forward_length(1536)
@@ -256,7 +256,7 @@ class Qwen3TTSSpeakerEncoderModel(MmprojModel):
         self.gguf_writer.add_gen_audio_head_count(code_predictor_config["num_attention_heads"])
         self.gguf_writer.add_gen_audio_head_count_kv(code_predictor_config["num_key_value_heads"])
         self.gguf_writer.add_gen_audio_attention_layernorm_eps(code_predictor_config["rms_norm_eps"])
-        # note: code2wav hparams are hardcoded on the mtmd/clip.cpp side for now, not written here
+        # note: code2wav hparams are hardcoded on the mtmd side for now, not written here

     def _wav_decoder_config(self) -> dict[str, Any] | None:
         # code2wav has its own config.json, inside the speech_tokenizer dir
diff --git a/docs/development/HOWTO-add-model.md b/docs/development/HOWTO-add-model.md
index abf6503f2..558363f4e 100644
--- a/docs/development/HOWTO-add-model.md
+++ b/docs/development/HOWTO-add-model.md
@@ -128,7 +128,7 @@ Note: to debug the inference graph: you can use [llama-eval-callback](/examples/
 If the new model supports multimodal inputs, you will need to add a new encoder definition in `libmtmd`. You can find more information about llama.cpp's multimodal support in [the docs](../multimodal.md) and in the `tools/mtmd` source directory.

 1. In the conversion script, make sure you add a subclass that extends `MmprojModel` or another class that inherits from the same base class.
-2. Add the encoder definition in `clip.cpp`.
+2. Add the encoder definition: hparams and tensor loading in `mtmd-model.cpp`, graph inputs in `mtmd-subcontext.cpp`.
 3. Implement the preprocessor in `mtmd.cpp`. In most cases, you can reuse an existing preprocessor.
 4. Implement the encoder GGML graph, either in a dedicated file if the model is truly different from existing ones, or by reusing an existing implementation (for example: siglip, pixtral, or qwen) and adding a model-specific projector.

diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py
index 61c51c8fd..5f9cbf1e3 100644
--- a/gguf-py/gguf/constants.py
+++ b/gguf-py/gguf/constants.py
@@ -589,7 +589,7 @@ class GGUFType:


 class MODEL_ARCH(IntEnum):
-    MMPROJ           = auto() # dummy arch for clip.cpp
+    MMPROJ           = auto() # dummy arch for mmproj
     LLAMA            = auto()
     LLAMA4           = auto()
     DECI             = auto()
@@ -1508,7 +1508,7 @@ class MODEL_TENSOR(IntEnum):


 MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = {
-    MODEL_ARCH.MMPROJ:           "clip", # dummy arch for clip.cpp
+    MODEL_ARCH.MMPROJ:           "clip", # dummy arch for mmproj files (tools/mtmd)
     MODEL_ARCH.LLAMA:            "llama",
     MODEL_ARCH.LLAMA4:           "llama4",
     MODEL_ARCH.DECI:             "deci",
@@ -2200,14 +2200,14 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = {
     MODEL_TENSOR.A_GEN_CODE_EMBD:           "a.gen.code.embd",
     MODEL_TENSOR.A_GEN_CODE_HEAD:           "a.gen.code.head",
     MODEL_TENSOR.A_GEN_CODE_OUT_EMBD:       "a.gen.code.out_embd",
-    MODEL_TENSOR.A_GEN_CODE_ATTN_NORM:      "a.gen.code.blk.{bid}.ln1", # reuses the generic clip.cpp block loader (TN_LN_1)
+    MODEL_TENSOR.A_GEN_CODE_ATTN_NORM:      "a.gen.code.blk.{bid}.ln1", # reuses the generic mtmd block loader (TN_LN_1)
     MODEL_TENSOR.A_GEN_CODE_ATTN_Q:         "a.gen.code.blk.{bid}.attn_q",
     MODEL_TENSOR.A_GEN_CODE_ATTN_Q_NORM:    "a.gen.code.blk.{bid}.attn_q_norm",
     MODEL_TENSOR.A_GEN_CODE_ATTN_K:         "a.gen.code.blk.{bid}.attn_k",
     MODEL_TENSOR.A_GEN_CODE_ATTN_K_NORM:    "a.gen.code.blk.{bid}.attn_k_norm",
     MODEL_TENSOR.A_GEN_CODE_ATTN_V:         "a.gen.code.blk.{bid}.attn_v",
     MODEL_TENSOR.A_GEN_CODE_ATTN_OUT:       "a.gen.code.blk.{bid}.attn_out",
-    MODEL_TENSOR.A_GEN_CODE_FFN_NORM:       "a.gen.code.blk.{bid}.ln2", # reuses the generic clip.cpp block loader (TN_LN_2)
+    MODEL_TENSOR.A_GEN_CODE_FFN_NORM:       "a.gen.code.blk.{bid}.ln2", # reuses the generic mtmd block loader (TN_LN_2)
     MODEL_TENSOR.A_GEN_CODE_FFN_GATE:       "a.gen.code.blk.{bid}.ffn_gate",
     MODEL_TENSOR.A_GEN_CODE_FFN_UP:         "a.gen.code.blk.{bid}.ffn_up",
     MODEL_TENSOR.A_GEN_CODE_FFN_DOWN:       "a.gen.code.blk.{bid}.ffn_down",
diff --git a/skills/add-new-model/SKILL.md b/skills/add-new-model/SKILL.md
index 710a1ebb4..3e4d178eb 100644
--- a/skills/add-new-model/SKILL.md
+++ b/skills/add-new-model/SKILL.md
@@ -50,7 +50,7 @@ Skill-specific addition: before writing `src/models/<name>.cpp`, read at least 1

 ## Step 4 - Optional: multimodal encoder

-Only do this if the contributor flagged a vision/audio encoder in Step 0. Follow HOWTO-add-model.md section 4 and `docs/multimodal.md` for the actual touch points (`MmprojModel` subclass, `clip.cpp`, `mtmd.cpp`, encoder graph in `tools/mtmd/models`, etc.).
+Only do this if the contributor flagged a vision/audio encoder in Step 0. Follow HOWTO-add-model.md section 4 and `docs/multimodal.md` for the actual touch points (`MmprojModel` subclass, `mtmd-model.cpp`, `mtmd-subcontext.cpp`, `mtmd.cpp`, encoder graph in `tools/mtmd/models`, etc.).

 Skill-specific addition, and read this carefully: **whether the multimodal encoder can be bundled into the same PR as the base text-model support depends on how conventional the change is.** It's OK to bundle it if the encoder support is conventional - i.e. no new infra or logic is needed, it's just a new cgraph reusing existing preprocessing/projector machinery (e.g. siglip/pixtral/qwen with just a new projector). If it requires anything beyond that - a new preprocessor, non-standard projector logic, or changes to shared `libmtmd` infra/logic - STOP, tell the contributor this is non-conventional, and have them land the text model first with the encoder as a dedicated follow-up PR. Do not let this decision pass silently - call it out explicitly to the contributor before writing any `clip.cpp`/`mtmd.cpp` code.

diff --git a/src/models/clip.cpp b/src/models/clip.cpp
index 537766aeb..3bf7ac3b8 100644
--- a/src/models/clip.cpp
+++ b/src/models/clip.cpp
@@ -14,5 +14,5 @@ void llama_model_clip::load_arch_tensors(llama_model_loader &) {

 [[noreturn]]
 std::unique_ptr<llm_graph_context> llama_model_clip::build_arch_graph(const llm_graph_params &) const {
-    GGML_ABORT("CLIP has no inference graph via llama_model dispatch; runtime lives in tools/mtmd/clip.cpp");
+    GGML_ABORT("CLIP has no inference graph via llama_model dispatch; runtime lives in tools/mtmd");
 }
diff --git a/tests/test-backend-ops.cpp b/tests/test-backend-ops.cpp
index 016fca3c3..a17f6b43e 100644
--- a/tests/test-backend-ops.cpp
+++ b/tests/test-backend-ops.cpp
@@ -6561,7 +6561,7 @@ struct test_rope : public test_case {
             a = ggml_view_4d(ctx, a, ne_a[0], ne_a[1], ne_a[2], ne_a[3], a->nb[1], a->nb[2], a->nb[3], 0);
             ggml_set_name(a, "view_of_a");
         } else if (v == 2) {
-            // second-half slice along dim 0 (mimics build_rope_2d in clip.cpp).
+            // second-half slice along dim 0 (mimics build_rope_2d in tools/mtmd/mtmd-graph.cpp).
             // The non-zero view offset (ne_a[0] * elem_size) often produces a
             // non-aligned buffer offset, which exercises backends' alignment paths.
             auto ne = ne_a; ne[0] *= 2;
diff --git a/tests/test-mtmd-impl.cpp b/tests/test-mtmd-impl.cpp
index e9c6ac856..a0201bdde 100644
--- a/tests/test-mtmd-impl.cpp
+++ b/tests/test-mtmd-impl.cpp
@@ -3,6 +3,7 @@
 #include "ggml-backend.h"
 #include "ggml-cpp.h"
 #include "mtmd-audio.h"
+#include "mtmd-subcontext.h"
 #include "mtmd-helper.h"
 #include "mtmd-image.h"
 #include "mtmd-internal.h"
@@ -52,12 +53,12 @@ struct test_registry {
 //

 MAKE_TEST(test_image_preprocessor_longest_edge) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.image_longest_edge = 1540;
     hparams.patch_size = 14;
     hparams.n_merge = 2;

-    const std::vector<std::pair<clip_image_size, clip_image_size>> cases = {
+    const std::vector<std::pair<mtmd_image_size, mtmd_image_size>> cases = {
         { {  463,  463 }, {  476,  476 } },
         { {  463,  300 }, {  476,  308 } },
         { {  448,  280 }, {  448,  280 } },
@@ -65,7 +66,7 @@ MAKE_TEST(test_image_preprocessor_longest_edge) {
     };

     for (const auto & [input, expected] : cases) {
-        const clip_image_size actual = mtmd_image_preprocessor_longest_edge::get_target_size(hparams, input);
+        const mtmd_image_size actual = mtmd_image_preprocessor_longest_edge::get_target_size(hparams, input);
         const std::string name = std::to_string(input.width) + "x" + std::to_string(input.height);
         t.assert_equal(name + " width", expected.width, actual.width);
         t.assert_equal(name + " height", expected.height, actual.height);
@@ -75,7 +76,7 @@ MAKE_TEST(test_image_preprocessor_longest_edge) {
 MAKE_TEST(test_image_preprocessor_dyn_size) {
     // { patch_size, image size, expected size }
     // a side that is an even number plus .5 merged patches rounds down, like Python round() in smart_resize
-    const std::vector<std::tuple<int, clip_image_size, clip_image_size>> cases = {
+    const std::vector<std::tuple<int, mtmd_image_size, mtmd_image_size>> cases = {
         { 16, {  768, 400 }, {  768, 384 } },
         { 16, {  800, 336 }, {  800, 320 } },
         { 16, {  400, 400 }, {  384, 384 } },
@@ -84,13 +85,13 @@ MAKE_TEST(test_image_preprocessor_dyn_size) {
     };

     for (const auto & [patch_size, input, expected] : cases) {
-        clip_hparams hparams;
+        mtmd_hparams hparams;
         hparams.patch_size = patch_size;
         hparams.n_merge = 2;
         hparams.image_round_half_even = true;
         hparams.set_limit_image_tokens(8, 4096);

-        const clip_image_size actual = mtmd_image_preprocessor_dyn_size::get_target_size(hparams, input);
+        const mtmd_image_size actual = mtmd_image_preprocessor_dyn_size::get_target_size(hparams, input);
         const std::string name = std::to_string(input.width) + "x" + std::to_string(input.height);
         t.assert_equal(name + " width", expected.width, actual.width);
         t.assert_equal(name + " height", expected.height, actual.height);
@@ -98,13 +99,13 @@ MAKE_TEST(test_image_preprocessor_dyn_size) {
 }

 MAKE_TEST(test_image_preprocessor_qwen3vl_video) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     const mtmd_image_preprocessor_qwen3vl preproc(hparams);

     // { n_frames, frame size, expected size from Qwen3VLVideoProcessor smart_resize }
-    const std::vector<std::tuple<int, clip_image_size, clip_image_size>> cases = {
+    const std::vector<std::tuple<int, mtmd_image_size, mtmd_image_size>> cases = {
         {  20, {  720,  358 }, {  704,  352 } }, // 720 / 32 = 22.5 rounds half to even
         { 768, { 1920, 1080 }, {  224,  128 } }, // over the longest_edge budget
         {   4, {   16,   40 }, {   32,   64 } }, // edge below the factor is upscaled first
@@ -114,7 +115,7 @@ MAKE_TEST(test_image_preprocessor_qwen3vl_video) {
     };

     for (const auto & [n_frames, input, expected] : cases) {
-        clip_image_size actual;
+        mtmd_image_size actual;
         const std::string name = std::to_string(n_frames) + " frames of " + std::to_string(input.width) + "x" + std::to_string(input.height);
         if (!t.assert_true(name + " has a frame size", preproc.get_video_frame_size(n_frames, input.width, input.height, actual))) {
             continue;
@@ -124,7 +125,7 @@ MAKE_TEST(test_image_preprocessor_qwen3vl_video) {
     }
 }

-static std::string image_size_to_str(const clip_image_size & size) {
+static std::string image_size_to_str(const mtmd_image_size & size) {
     return std::to_string(size.width) + "x" + std::to_string(size.height);
 }

@@ -143,8 +144,8 @@ static std::string resize_algo_to_str(resize_algo algo) {
 }

 // pseudo-random pixels: s = s * 1103515245 + 12345 from s = 1, one byte (s >> 16) & 255 per channel
-static clip_image_u8 make_test_image(const clip_image_size & size) {
-    clip_image_u8 img;
+static mtmd_image_u8 make_test_image(const mtmd_image_size & size) {
+    mtmd_image_u8 img;
     img.set_size(size, false);
     uint32_t s = 1;
     auto next = [&s]() {
@@ -160,7 +161,7 @@ static clip_image_u8 make_test_image(const clip_image_size & size) {
 }

 // FNV-1a of the pixels
-static uint32_t hash_pixels(const clip_image_u8 & img, uint32_t hash = 2166136261u) {
+static uint32_t hash_pixels(const mtmd_image_u8 & img, uint32_t hash = 2166136261u) {
     for (uint8_t v : img.get_ro_buf()) {
         hash = (hash ^ v) * 16777619u;
     }
@@ -168,7 +169,7 @@ static uint32_t hash_pixels(const clip_image_u8 & img, uint32_t hash = 216613626
 }

 MAKE_TEST(test_image_preprocessor_lfm2) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     hparams.set_limit_image_tokens(64, 256);
@@ -178,7 +179,7 @@ MAKE_TEST(test_image_preprocessor_lfm2) {
     hparams.preproc_max_pixels_tolerance = 2.0f;

     // { image size, expected tiling }
-    const std::vector<std::pair<clip_image_size, bool>> cases = {
+    const std::vector<std::pair<mtmd_image_size, bool>> cases = {
         { {  704, 704 }, false },
         // 720 / (patch_size * n_merge) is exactly 22.5, so this only matches HF
         // if round_by_factor rounds half to even (22) instead of away from zero (23)
@@ -198,7 +199,7 @@ MAKE_TEST(test_image_preprocessor_lfm2) {
     }

     // { image size, expected size from HF smart_resize }
-    const std::vector<std::pair<clip_image_size, clip_image_size>> resize_cases = {
+    const std::vector<std::pair<mtmd_image_size, mtmd_image_size>> resize_cases = {
         { {  400, 600 }, { 384, 608 } }, // 400 / 32 = 12.5 rounds half to even
         { {  480, 336 }, { 480, 320 } },
         { {   64, 169 }, { 160, 416 } }, // 169 * beta / 32 is exactly 13 in double
@@ -211,7 +212,7 @@ MAKE_TEST(test_image_preprocessor_lfm2) {
     }

     // { image size, expected grid from HF }
-    const std::vector<std::pair<clip_image_size, clip_image_size>> grid_cases = {
+    const std::vector<std::pair<mtmd_image_size, mtmd_image_size>> grid_cases = {
         { {  720, 1600 }, { 2, 4 } }, // 0.45 is an exact tie between 2/4 and 2/5 in double, not in float
         { {  420, 1440 }, { 1, 3 } },
         { {  255, 2160 }, { 1, 8 } },
@@ -231,7 +232,7 @@ MAKE_TEST(test_image_preprocessor_lfm2) {
 }

 MAKE_TEST(test_image_preprocessor_lfm2_torch_resize) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     hparams.set_limit_image_tokens(64, 256);
@@ -243,7 +244,7 @@ MAKE_TEST(test_image_preprocessor_lfm2_torch_resize) {
     hparams.image_pad_rf = PAD_NONE;

     // { resize algo, image size, expected hash of the tiles and the thumbnail from Lfm2VlImageProcessor (torchvision 0.29, native uint8 resize: arm64, or x86 with AVX2/AVX512) }
-    const std::vector<std::tuple<resize_algo, clip_image_size, uint32_t>> cases = {
+    const std::vector<std::tuple<resize_algo, mtmd_image_size, uint32_t>> cases = {
         { RESIZE_ALGO_BILINEAR, {  620,  410 }, 0xf5e9c941 }, // 608x416
         { RESIZE_ALGO_BILINEAR, { 2000, 1500 }, 0x3331f56c }, // 3x2 tiles, 576x416 thumbnail
         { RESIZE_ALGO_BICUBIC,  {  620,  410 }, 0x2b34eac9 },
@@ -338,14 +339,14 @@ MAKE_TEST(test_temporal_merge_grouping) {
 //

 MAKE_TEST(test_video_config) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     const mtmd_image_preprocessor_qwen3vl preproc(hparams);

     // { template, n_frames and fps of the source, expected fps, n_frames and frame size }
     // the 1920x1080 sizes are from Qwen3VLVideoProcessor smart_resize
-    const std::vector<std::tuple<mtmd_video_template, int32_t, float, float, int32_t, clip_image_size>> cases = {
+    const std::vector<std::tuple<mtmd_video_template, int32_t, float, float, int32_t, mtmd_image_size>> cases = {
         { MTMD_VIDEO_TEMPLATE_QWEN3VL,   36000, 30.0f, 2.0f, 768, { 224, 128 } },
         { MTMD_VIDEO_TEMPLATE_LFM3,      36000, 30.0f, 2.0f, 768, { 224, 128 } },
         { MTMD_VIDEO_TEMPLATE_LFM3_OMNI, 36000, 30.0f, 2.0f, 384, { 320, 192 } },
@@ -405,13 +406,13 @@ static std::string video_sequence_to_str(testing & t, mtmd_helper_video_seq & vi
 }

 MAKE_TEST(test_video_sequence_qwen3vl) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     const mtmd_image_preprocessor_qwen3vl preproc(hparams);

     // { source video, expected frame size and sequence from Qwen3VLProcessor (transformers 4.57.1) }
-    const std::vector<std::tuple<mtmd_helper_video_src, clip_image_size, std::string>> cases = {
+    const std::vector<std::tuple<mtmd_helper_video_src, mtmd_image_size, std::string>> cases = {
         { {  720, 358, 24.0f, 240 }, { 704, 352 },
             "<0.3 seconds>[0][13]<1.3 seconds>[25][38]<2.4 seconds>[50][63]<3.4 seconds>[75][88]<4.5 seconds>[101][113]"
             "<5.5 seconds>[126][138]<6.6 seconds>[151][164]<7.6 seconds>[176][189]<8.6 seconds>[201][214]<9.7 seconds>[226][239]" },
@@ -439,7 +440,7 @@ MAKE_TEST(test_video_sequence_qwen3vl) {

 // the soundtrack of each group comes after its frames
 MAKE_TEST(test_video_sequence_lfm3_omni) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.patch_size = 16;
     hparams.n_merge = 2;
     const mtmd_image_preprocessor_lfm3vl preproc(hparams);
@@ -462,7 +463,7 @@ MAKE_TEST(test_video_sequence_lfm3_omni) {
 //

 MAKE_TEST(test_audio_preprocessor_conformer) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.n_mel_bins        = 128;
     hparams.audio_sample_rate = 16000;
     hparams.audio_n_fft       = 512;
@@ -504,7 +505,7 @@ MAKE_TEST(test_audio_preprocessor_conformer) {
         if (!t.assert_true("preprocess", !out.entries.empty() && preproc.compute(out))) {
             return;
         }
-        const clip_image_f32 & mel = out.entries[0];
+        const mtmd_image_f32 & mel = out.entries[0];
         t.assert_equal("n_len", 101, mel.nx());

         for (const auto & [bin, frame, expected] : cases) {
@@ -515,7 +516,7 @@ MAKE_TEST(test_audio_preprocessor_conformer) {
 }

 MAKE_TEST(test_audio_preprocessor_threads) {
-    clip_hparams hparams;
+    mtmd_hparams hparams;
     hparams.n_mel_bins        = 128;
     hparams.audio_sample_rate = 16000;
     hparams.audio_n_fft       = 512;
@@ -539,7 +540,7 @@ MAKE_TEST(test_audio_preprocessor_threads) {
     if (!t.assert_true("preprocess", ref.entries.size() == 1 && ref.needs_compute())) {
         return;
     }
-    const clip_image_size shape = ref.entries[0].get_size();
+    const mtmd_image_size shape = ref.entries[0].get_size();
     if (!t.assert_true("compute", preproc.compute(ref))) {
         return;
     }
@@ -586,7 +587,7 @@ MAKE_TEST(test_audio_log_mel_precision) {
     }

     std::vector<float> out((size_t) n_frames * n_mel);
-    if (!t.assert_true("log mel", clip_audio_log_mel(config, samples.data(), cache.hann_window.data(), n_frames, out.data()))) {
+    if (!t.assert_true("log mel", mtmd_audio_log_mel(config, samples.data(), cache.hann_window.data(), n_frames, out.data()))) {
         return;
     }

@@ -650,8 +651,8 @@ MAKE_TEST(test_audio_log_mel_blocks) {
     std::vector<float> all((size_t) n_frames * n_mel);
     std::vector<float> sub((size_t) n_sub * n_mel);
     const float * window = cache.hann_window.data();
-    if (!t.assert_true("all", clip_audio_log_mel(config, samples.data(), window, n_frames, all.data())) ||
-        !t.assert_true("sub", clip_audio_log_mel(config, samples.data() + (size_t) off * hop, window, n_sub, sub.data()))) {
+    if (!t.assert_true("all", mtmd_audio_log_mel(config, samples.data(), window, n_frames, all.data())) ||
+        !t.assert_true("sub", mtmd_audio_log_mel(config, samples.data() + (size_t) off * hop, window, n_sub, sub.data()))) {
         return;
     }

diff --git a/tools/mtmd/CMakeLists.txt b/tools/mtmd/CMakeLists.txt
index fd8b24efd..c19815d67 100644
--- a/tools/mtmd/CMakeLists.txt
+++ b/tools/mtmd/CMakeLists.txt
@@ -22,11 +22,13 @@ set(MTMD_CORE_SOURCES
     mtmd-helper-gen.cpp
     mtmd-helper-common.h
     mtmd-helper.h
-    clip.cpp
-    clip.h
-    clip-impl.h
-    clip-model.h
-    clip-graph.h
+    mtmd-model.cpp
+    mtmd-subcontext.cpp
+    mtmd-graph.cpp
+    mtmd-subcontext.h
+    mtmd-impl.h
+    mtmd-model.h
+    mtmd-graph.h
 )

 add_library(mtmd
diff --git a/tools/mtmd/README-dev.md b/tools/mtmd/README-dev.md
index 5dad86100..4ca410c67 100644
--- a/tools/mtmd/README-dev.md
+++ b/tools/mtmd/README-dev.md
@@ -7,6 +7,7 @@ Please refer to [multimodal.md](../../docs/multimodal.md) for a broader context.
 In short:
 - `libmtmd` started as a wrapper around `libllava` / `clip.cpp`
 - Various components that used to be in `clip.cpp` are moved progressively to mtmd. For example, preprocessor is now part of mtmd
+- `clip.cpp` has since been split into `mtmd-model` (hparams and weights, one `mtmd_model` per modality), `mtmd-subcontext` (backends and compute, one `mtmd_subcontext` per modality) and `mtmd-graph` (graph builders, see `models/`). Only the GGUF format keeps the `clip.` name (metadata keys and the `clip` architecture), for compatibility with existing mmproj files

 ## Terminologies

@@ -15,6 +16,26 @@ In short:
 - tiles / slices: for llava-uhd-style models, the preprocessor breaks a large input into smaller square images called tiles or slices
 - chunk: a mtmd_input_chunk represents a preprocessed input that can then be passed through `mtmd_encode()`

+## Structure
+
+Overview of the `libmtmd` structure:
+
+- `mtmd_context` (`mtmd.cpp`): the public handle
+    - `mtmd_models`: one mmproj file, loaded by `mtmd_model_load()`
+        - `mtmd_model` `v` / `a` / `gen_a` (`mtmd-model.h`): one per modality, holds hparams, weights (`ctx_data` + `buf`) and requirements
+    - `mtmd_subcontext` `ctx_v` / `ctx_a` / `ctx_gen_a` (`mtmd-subcontext.h`): one per model, holds a const ref to it
+        - backends + `sched`: GPU/CPU backends, compute buffers, resolved flash attn
+        - `mtmd_graph` (`mtmd-graph.h`, `models/`): built for each `encode()` call, temporary
+    - `mtmd_image_preprocessor` / `mtmd_audio_preprocessor` (`mtmd-image.h`, `mtmd-audio.h`): read the hparams of their model
+- `mtmd_gen_state` / `mtmd_audio_stream`: public, one per sequence / stream
+    - `mtmd_subctx_gen_state` / `mtmd_subctx_enc_state`: allocated on the backend of a subcontext, see `gen_state_init()` / `enc_state_init()`
+
+Rules of thumb:
+- `mtmd_model` is read-only once loaded. Anything that depends on the backend (scheduler, compute buffers, flash attn resolution, warmup) belongs to `mtmd_subcontext`
+- A model declares its runtime needs (e.g. `max_nodes`, `flash_attn_auto`) in `load_hparams`, the subcontext applies them
+- Models must outlive their subcontexts, `mtmd_context` declares `models` first for that reason
+- Backends are owned by each subcontext for now; they will move to `mtmd_context` so that subcontexts can share them
+
 ## Pipeline

 A typical pipeline of the core libmtmd is as follows:
@@ -67,21 +88,21 @@ Due to wide variety of audio generation pipelines, the `mtmd_gen_audio` system i
     - Backbone model should be converted to a normal text model (loadable via `libllama`)
         - If model used hard-coded embedding row ID, append them to token embeddings and assign token name for them (see `qwen3tts.py`)
         - If model have a specific output logits head for audio codes (usually semantic code), keep the head as-is and pad the logits at inference time (see `src/models/qwen3vl.cpp`)
-    - Sidecar models (code2wav, bigvgan, etc) must live inside the mmproj GGUF (but can be in different `clip_context` if necessary)
+    - Sidecar models (code2wav, bigvgan, etc) must live inside the mmproj GGUF (but can be in different `mtmd_subcontext` if necessary)
         - Note: it should use `ggml_build_forward_select` to select graphs if multiple graphs living in the same context
-    - Reuse existing GGUF metadata key name and tensor name whenever possible; think twice before adding extensive changes to GGUF writer. For example, Qwen3-TTS hard-code part of the hparams to `clip.cpp` as they won't likely to change.
+    - Reuse existing GGUF metadata key name and tensor name whenever possible; think twice before adding extensive changes to GGUF writer. For example, Qwen3-TTS hard-code part of the hparams in `mtmd-model.cpp` as they won't likely to change.
     - For tensor naming:
         - Prefixed with `a.*` for tensors used by speaker encoder pipeline
         - Prefixed with `a.gen.*` for generation stages (code / mel-spectrogram / PCM generation)
     - For GGUF metadata:
         - Reuse as many existing keys as possible
-        - In most cases, you can hard-code model configs in the model graph class, or in `clip_hparams`
+        - In most cases, you can hard-code model configs in the model graph class, or in `mtmd_hparams`
         - If some values need to be exposed to the `mtmd_helper` layer, hard-code them in `mtmd_helper` and distinguish by pipeline and `mtmd_gen_audio_info::model_variant` if necessary
         - Do NOT add new GGUF metadata or new fields to `mtmd_gen_audio_info` unless you can prove that you absolutely need them
 4. Make sure most of the changes happen inside `mtmd-helper-gen.cpp`. A good PR looks like this:
     - 10-20% changes is to add new backbone (text) model and conversion
     - 60% changes inside `mtmd-helper-gen.cpp`
-    - 10% changes inside `libmtmd` and `clip.cpp` systems
+    - 10% changes inside `libmtmd` core (`mtmd-model`, `mtmd-subcontext`, `models/`)
     - The rest downstream code (CLI, server) should have no changes at all
 5. Update usage documentation in `tools/tts/README.md`

diff --git a/tools/mtmd/README.md b/tools/mtmd/README.md
index 701941947..f0290aae5 100644
--- a/tools/mtmd/README.md
+++ b/tools/mtmd/README.md
@@ -32,7 +32,7 @@ Consequently, running a multimodal model typically requires two GGUF files:

 As outlined in the history, `libmtmd` is the modern library designed to replace the original `llava.cpp` implementation for handling multimodal inputs.

-Built upon `clip.cpp` (similar to `llava.cpp`), `libmtmd` offers several advantages:
+Originally built upon `clip.cpp` (similar to `llava.cpp`), `libmtmd` offers several advantages:
 - **Unified Interface:** Aims to consolidate interaction for various multimodal models.
 - **Improved UX/DX:** Features a more intuitive API, inspired by the `Processor` class in the Hugging Face `transformers` library.
 - **Flexibility:** Designed to support multiple input types (text, audio, images) while respecting the wide variety of chat templates used by different models.
diff --git a/tools/mtmd/clip.h b/tools/mtmd/clip.h
deleted file mode 100644
index f8fd41ef5..000000000
--- a/tools/mtmd/clip.h
+++ /dev/null
@@ -1,200 +0,0 @@
-#pragma once
-
-#include "ggml.h"
-#include "mtmd.h"
-
-#include <stddef.h>
-#include <stdint.h>
-
-#include <map>
-
-// !!! Internal header, to be used by mtmd only !!!
-
-#define MTMD_INTERNAL_HEADER
-
-struct clip_ctx;
-struct clip_enc_state;
-struct clip_gen_state;
-
-struct clip_image_size {
-    int width;
-    int height;
-    bool operator==(const clip_image_size & other) const {
-        return width == other.width && height == other.height;
-    }
-    bool operator!=(const clip_image_size & other) const {
-        return !(*this == other);
-    }
-    int area() const {
-        // avoid overflow when computing area
-        GGML_ASSERT(width  >= 0 && width  <= 46000);
-        GGML_ASSERT(height >= 0 && height <= 46000);
-        return width * height;
-    }
-};
-
-struct clip_image_f32;
-struct clip_image_f32_batch;
-
-enum clip_modality {
-    CLIP_MODALITY_VISION,
-    CLIP_MODALITY_AUDIO,
-    CLIP_MODALITY_GEN_AUDIO,
-};
-
-enum clip_flash_attn_type {
-    CLIP_FLASH_ATTN_TYPE_AUTO     = -1,
-    CLIP_FLASH_ATTN_TYPE_DISABLED = 0,
-    CLIP_FLASH_ATTN_TYPE_ENABLED  = 1,
-};
-
-struct clip_context_params {
-    bool use_gpu;
-    ggml_backend_dev_t device;
-    enum clip_flash_attn_type flash_attn_type;
-    int image_min_tokens;
-    int image_max_tokens;
-    bool warmup;
-    ggml_backend_sched_eval_callback cb_eval;
-    void * cb_eval_user_data;
-    bool no_alloc;
-    mtmd_progress_callback progress_callback;
-    void * progress_callback_user_data;
-    int n_threads;
-};
-
-struct clip_init_result {
-    struct clip_ctx * ctx_v; // vision context
-    struct clip_ctx * ctx_a; // audio context
-    struct clip_ctx * ctx_gen_a; // audio generation context
-};
-
-struct clip_init_result clip_init(const char * fname, struct clip_context_params ctx_params);
-
-// max number of output tokens per image, -1 if not dynamic size
-int clip_get_image_max_tokens(const struct clip_ctx * ctx);
-
-void clip_free(struct clip_ctx * ctx);
-
-// TODO: should be enum, not string
-const char * clip_patch_merge_type(const struct clip_ctx * ctx);
-
-int clip_n_output_tokens(const clip_ctx * ctx, const clip_image_f32 * img);
-
-// for M-RoPE, this will be the number of token positions in X and Y directions
-// for other models, X will be the total number of tokens and Y will be 1
-int clip_n_output_tokens_x(const clip_ctx * ctx, const clip_image_f32 * img);
-int clip_n_output_tokens_y(const clip_ctx * ctx, const clip_image_f32 * img);
-
-// this should be equal to the embedding dimension of the text model
-int clip_n_mmproj_embd(const struct clip_ctx * ctx);
-
-ggml_backend_sched_t clip_get_sched(const struct clip_ctx * ctx);
-
-// compute the log-mel of n_frames frames, the waveform must already be padded and window must hold one whole frame
-// out has the layout [n_frames, n_mel]; like the encoders, must not run at the same time as another pass on config.sched
-struct mtmd_audio_mel_config;
-bool clip_audio_log_mel(const mtmd_audio_mel_config & config, const float * samples, const float * window, int64_t n_frames, float * out);
-
-// TODO: remove clip_image_encode() and always use batched version
-bool clip_image_encode      (struct clip_ctx * ctx, int n_threads, const clip_image_f32 * img, std::vector<float> & out_vec);
-bool clip_image_batch_encode(struct clip_ctx * ctx, int n_threads, const struct clip_image_f32_batch * imgs, std::vector<float> & out_batch_embd);
-
-enum clip_gen_process_type {
-    CLIP_GEN_PROCESS_GEN_UNKNOWN,
-    CLIP_GEN_PROCESS_GEN_CODE, // h_state to codes
-    CLIP_GEN_PROCESS_GEN_WAV,  // codes to raw PCM audio
-    CLIP_GEN_PROCESS_TTS,        // full utterance of codes to raw PCM audio
-    CLIP_GEN_PROCESS_TTS_VOCODE, // internal: mel + source stft to istft input
-    CLIP_GEN_PROCESS_TOKENIZE,   // raw PCM audio to semantic speech tokens
-};
-
-// gen-side voice reference (chatterbox): speech tokens, mel-rate features and the speaker vector
-struct clip_gen_ref {
-    std::vector<int32_t> tokens;
-    std::vector<float>   feat;
-    std::vector<float>   spk;
-};
-
-struct clip_encode_params {
-    int n_threads = 1;
-    const clip_image_f32_batch * imgs = nullptr;
-    std::vector<float> * out_embd = nullptr;
-
-    // for audio gen, imgs has exactly one entry: hidden state from backbone (GEN_CODE) or unused (GEN_WAV)
-    clip_gen_process_type gen_process = CLIP_GEN_PROCESS_GEN_UNKNOWN;
-
-    // GEN_CODE: out_embd receives the embd to feed back to the backbone
-    int32_t code0 = 0; // semantic code sampled by the backbone
-    int32_t top_k = 50;
-    float   top_p = 1.0f;
-    std::vector<int32_t> * out_codes = nullptr; // this frame's 16 sampled codes
-    std::vector<float> * out_feats = nullptr; // continuous counterpart of out_codes
-    float   temp = 0.0f;                      // sampling temperature, noise scale for flow-matching decoders
-    bool * out_is_eos = nullptr;
-
-    // TOKENIZE: speech embedding rows of the produced codes
-    std::vector<float> * out_code_embd = nullptr;
-
-    // GEN_WAV
-    const std::vector<int32_t> * codes = nullptr;     // this frame's 16 RVQ codes
-    const std::vector<float> *   feats = nullptr;     // continuous counterpart of codes
-    std::vector<float> * out_audio = nullptr;         // decoded PCM samples, F32
-    const clip_gen_ref * ref = nullptr; // TTS reference conditioning of the voice
-    // TTS_VOCODE internal stage inputs
-    const std::vector<float> * mel_in   = nullptr;
-    const std::vector<float> * sstft_in = nullptr;
-    int vocode_n_mel  = 0;
-    int vocode_n_stft = 0;
-    std::vector<float> * out_spec = nullptr;
-
-    // audio gen: state of one sequence, clip_encode() updates it in place
-    struct clip_gen_state * gen_state = nullptr;
-
-    // streaming audio input, updated in place by clip_encode()
-    struct clip_enc_state * enc_state = nullptr;
-};
-bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params);
-
-// persistent state of a causal audio encoder, for streaming input (one per stream)
-// it holds the sliding window of past keys/values, so each clip_encode() call only takes the new frames
-// n_frames_max is the maximum number of encoder frames per call
-struct clip_enc_state * clip_enc_state_init(struct clip_ctx * ctx, int32_t n_frames_max);
-void                    clip_enc_state_free(struct clip_enc_state * state);
-
-// per-sequence state of audio generation: RNG and the slots kept between GEN_WAV calls
-// seed UINT32_MAX means random; reset() clears the slots and keeps the RNG stream
-struct clip_gen_state * clip_gen_state_init(struct clip_ctx * ctx, uint32_t seed);
-void                    clip_gen_state_free(struct clip_gen_state * state);
-void                    clip_gen_state_reset(struct clip_gen_state * state);
-
-// read a chatterbox tensor by name as F32, returns the element count (0 if not found), out may be null to query the size
-size_t clip_cbx_read_tensor(struct clip_ctx * ctx, const char * name, float * out, size_t n_max);
-
-// a predefined voice: backbone rows, plus the gen-side reference when the gen stack needs one
-struct clip_gen_voice {
-    std::vector<float> rows;
-    clip_gen_ref       ref;
-};
-
-const std::map<std::string, clip_gen_voice> & clip_gen_get_voices(const struct clip_ctx * ctx);
-
-bool clip_is_llava(const struct clip_ctx * ctx);
-// note for contributor: this clip_is_(model) pattern is deprecated
-//                       do NOT add new functions like this
-
-bool clip_has_vision_encoder(const struct clip_ctx * ctx);
-bool clip_has_audio_encoder(const struct clip_ctx * ctx);
-
-bool clip_support_batch(const struct clip_ctx * ctx);
-
-int clip_model_n_temporal_merge(const struct clip_ctx * ctx); // TODO @ngxson : remove, refactor this
-
-std::map<ggml_backend_dev_t, size_t> clip_get_mem_usage(const struct clip_ctx * ctx);
-
-struct clip_cap {
-    bool has_vision;
-    bool has_audio;
-    bool has_gen_audio;
-};
-struct clip_cap clip_get_cap(const char * fname);
diff --git a/tools/mtmd/debug/mtmd-debug.cpp b/tools/mtmd/debug/mtmd-debug.cpp
index 2719dae9b..894ad7d6f 100644
--- a/tools/mtmd/debug/mtmd-debug.cpp
+++ b/tools/mtmd/debug/mtmd-debug.cpp
@@ -81,7 +81,7 @@ int main(int argc, char ** argv) {
     llama_init = common_init_from_params(params);
     {
         auto * model = llama_init->model();
-        const char * clip_path = params.mmproj.path.c_str();
+        const char * mmproj_path = params.mmproj.path.c_str();
         mtmd_context_params mparams = mtmd_context_params_default();
         mparams.use_gpu          = params.mmproj_use_gpu;
         mparams.device           = params.mmproj_device;
@@ -96,9 +96,9 @@ int main(int argc, char ** argv) {
             mparams.cb_eval_user_data = &cb_data;
             mparams.cb_eval = common_debug_cb_eval;
         }
-        ctx_mtmd.reset(mtmd_init_from_file(clip_path, model, mparams));
+        ctx_mtmd.reset(mtmd_init_from_file(mmproj_path, model, mparams));
         if (!ctx_mtmd.get()) {
-            LOG_ERR("Failed to load vision model from %s\n", clip_path);
+            LOG_ERR("Failed to load vision model from %s\n", mmproj_path);
             exit(1);
         }
     }
diff --git a/tools/mtmd/models/chatterbox-gen.cpp b/tools/mtmd/models/chatterbox-gen.cpp
index 00a9a8d81..017329bf4 100644
--- a/tools/mtmd/models/chatterbox-gen.cpp
+++ b/tools/mtmd/models/chatterbox-gen.cpp
@@ -3,7 +3,7 @@
 // Chatterbox generation graphs: flow encoder, cfm estimator, s3 tokenizer and hift vocoder

 // x [C, T]: y = W x + b with torch Linear weights stored as [in, out]
-ggml_tensor * clip_graph_chatterbox_base::cbx_linear(ggml_tensor * w, ggml_tensor * b, ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_chatterbox_base::cbx_linear(ggml_tensor * w, ggml_tensor * b, ggml_tensor * x) const {
     ggml_tensor * y = ggml_mul_mat(ctx0, w, x);
     if (b) {
         y = ggml_add(ctx0, y, b);
@@ -12,7 +12,7 @@ ggml_tensor * clip_graph_chatterbox_base::cbx_linear(ggml_tensor * w, ggml_tenso
 }

 // conv1d over time with asymmetric zero padding: x [C, T] -> [OC, T_out], kernel [K, IC, OC]
-ggml_tensor * clip_graph_chatterbox_base::cbx_conv1d(ggml_tensor * k, ggml_tensor * b, ggml_tensor * x,
+ggml_tensor * mtmd_graph_chatterbox_base::cbx_conv1d(ggml_tensor * k, ggml_tensor * b, ggml_tensor * x,
                                                      int stride, int pad_l, int pad_r) const {
     ggml_tensor * xt = ggml_cont(ctx0, ggml_transpose(ctx0, x)); // [T, C]
     if (pad_l > 0) {
@@ -34,7 +34,7 @@ ggml_tensor * clip_graph_chatterbox_base::cbx_conv1d(ggml_tensor * k, ggml_tenso
 }

 // x [C, T] -> symmetric-padded dilated conv -> [OC, T]
-ggml_tensor * clip_graph_chatterbox_base::cbx_conv1d_dil(ggml_tensor * k, ggml_tensor * b, ggml_tensor * x,
+ggml_tensor * mtmd_graph_chatterbox_base::cbx_conv1d_dil(ggml_tensor * k, ggml_tensor * b, ggml_tensor * x,
                                                          int pad, int dil) const {
     ggml_tensor * xt = ggml_cont(ctx0, ggml_transpose(ctx0, x));
     ggml_tensor * y = ggml_conv_1d(ctx0, k, xt, 1, pad, dil);
@@ -61,7 +61,7 @@ static ggml_tensor * cbx_rel_shift(ggml_context * ctx0, ggml_tensor * bd, int T)
 }

 // espnet rel-pos self attention block, pre-norm, x [512, T], pos [512, 2T-1]
-ggml_tensor * clip_graph_chatterbox::enc_layer(const clip_chatterbox::enc_layer & l, ggml_tensor * x,
+ggml_tensor * mtmd_graph_chatterbox::enc_layer(const mtmd_chatterbox::enc_layer & l, ggml_tensor * x,
                                                ggml_tensor * pos, int T) {
     const int n_head = 8;
     const int d_head = 64;
@@ -121,14 +121,14 @@ static ggml_tensor * cbx_mish(ggml_context * ctx0, ggml_tensor * x) {
 }

 // causal block: conv k3 left-padded, layer norm over channels, mish; x [C, T]
-ggml_tensor * clip_graph_chatterbox::causal_block(const clip_chatterbox::causal_block & b, ggml_tensor * x) {
+ggml_tensor * mtmd_graph_chatterbox::causal_block(const mtmd_chatterbox::causal_block & b, ggml_tensor * x) {
     x = cbx_conv1d(b.conv_w, b.conv_b, x, 1, (int) b.conv_w->ne[0] - 1, 0);
     x = build_norm(x, b.norm_w, b.norm_b, NORM_TYPE_NORMAL, 1e-5f, -1);
     return cbx_mish(ctx0, x);
 }

 // resnet block with time conditioning; x [C, T], temb [1024]
-ggml_tensor * clip_graph_chatterbox::resnet(const clip_chatterbox::resnet & r, ggml_tensor * x, ggml_tensor * temb) {
+ggml_tensor * mtmd_graph_chatterbox::resnet(const mtmd_chatterbox::resnet & r, ggml_tensor * x, ggml_tensor * temb) {
     ggml_tensor * h = causal_block(r.block1, x);
     ggml_tensor * tproj = cbx_linear(r.mlp_w, r.mlp_b, cbx_mish(ctx0, temb));
     h = ggml_add(ctx0, h, tproj); // broadcast [256, 1] over T
@@ -138,7 +138,7 @@ ggml_tensor * clip_graph_chatterbox::resnet(const clip_chatterbox::resnet & r, g
 }

 // diffusers-style transformer block, full attention; x [256, T]
-ggml_tensor * clip_graph_chatterbox::tfm_block(const clip_chatterbox::tfm_block & b, ggml_tensor * x) {
+ggml_tensor * mtmd_graph_chatterbox::tfm_block(const mtmd_chatterbox::tfm_block & b, ggml_tensor * x) {
     const int n_head = 8;
     const int d_head = 64;
     const int T = (int) x->ne[1];
@@ -157,7 +157,7 @@ ggml_tensor * clip_graph_chatterbox::tfm_block(const clip_chatterbox::tfm_block
 }

 // one estimator evaluation; x_noise, mu, cond [80, T], spks [80], temb [1024]
-ggml_tensor * clip_graph_chatterbox::estimator(ggml_tensor * x_noise, ggml_tensor * mu, ggml_tensor * spks,
+ggml_tensor * mtmd_graph_chatterbox::estimator(ggml_tensor * x_noise, ggml_tensor * mu, ggml_tensor * spks,
                                                ggml_tensor * cond, ggml_tensor * temb, int T) {
     const auto & c = model.cbx;

@@ -199,7 +199,7 @@ ggml_tensor * clip_graph_chatterbox::estimator(ggml_tensor * x_noise, ggml_tenso
 }

 // s3 tokenizer encoder: log-mel [T, 128] -> fsq code [8, T / 4], rounded to base 3 tokens on the host
-ggml_cgraph * clip_graph_chatterbox::build_s3tok(int T) {
+ggml_cgraph * mtmd_graph_chatterbox::build_s3tok(int T) {
     const auto & c = model.cbx;
     const int n_head = 20;
     const int d_head = 64;
@@ -261,14 +261,14 @@ ggml_cgraph * clip_graph_chatterbox::build_s3tok(int T) {
     return gf;
 }

-ggml_cgraph * clip_graph_chatterbox::build() {
-    if (gen_process == CLIP_GEN_PROCESS_TTS_VOCODE) {
+ggml_cgraph * mtmd_graph_chatterbox::build() {
+    if (gen_process == MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE) {
         return build_vocoder(vocode_n_mel, vocode_n_stft);
     }
-    if (gen_process == CLIP_GEN_PROCESS_TOKENIZE) {
+    if (gen_process == MTMD_SUBCTX_GEN_PROCESS_TOKENIZE) {
         return build_s3tok(img.nx());
     }
-    if (gen_process != CLIP_GEN_PROCESS_TTS) {
+    if (gen_process != MTMD_SUBCTX_GEN_PROCESS_TTS) {
         // load-time buffer sizing path
         ggml_tensor * inp = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, 1);
         ggml_set_name(inp, "inp_stub");
@@ -355,7 +355,7 @@ ggml_cgraph * clip_graph_chatterbox::build() {
     const bool meanflow = c.time_embed_mixer_w != nullptr;
     const int n_steps = meanflow ? 2 : 10;

-    // span points, same schedule as the host side sinusoid fill in clip.cpp
+    // span points, same schedule as the host side sinusoid fill in mtmd-subcontext.cpp
     float span[11];
     for (int i = 0; i <= n_steps; i++) {
         const float u = (float) i / n_steps;
@@ -454,7 +454,7 @@ static ggml_tensor * cbx_snake(ggml_context * ctx0, ggml_tensor * x, ggml_tensor
 }

 // hifigan-snake resblock; kernels with dilations 1/3/5 on convs1, 1 on convs2
-ggml_tensor * clip_graph_chatterbox::hift_resblock(const clip_chatterbox::hift_res & r, ggml_tensor * x) {
+ggml_tensor * mtmd_graph_chatterbox::hift_resblock(const mtmd_chatterbox::hift_res & r, ggml_tensor * x) {
     static const int dil[3] = {1, 3, 5};
     for (size_t j = 0; j < r.units.size(); j++) {
         const auto & u = r.units[j];
@@ -471,7 +471,7 @@ ggml_tensor * clip_graph_chatterbox::hift_resblock(const clip_chatterbox::hift_r
 }

 // mel [80, T] + source stft [18, T_stft] -> conv_post output [18, T_stft2]
-ggml_cgraph * clip_graph_chatterbox::build_vocoder(int n_mel, int n_stft) {
+ggml_cgraph * mtmd_graph_chatterbox::build_vocoder(int n_mel, int n_stft) {
     const auto & c = model.cbx;

     ggml_tensor * mel = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, 80, n_mel);
diff --git a/tools/mtmd/models/chatterbox-spkenc.cpp b/tools/mtmd/models/chatterbox-spkenc.cpp
index afd9f0c29..fc73aa805 100644
--- a/tools/mtmd/models/chatterbox-spkenc.cpp
+++ b/tools/mtmd/models/chatterbox-spkenc.cpp
@@ -5,7 +5,7 @@
 static constexpr float bn_eps = 1e-5f;

 // per-channel batchnorm on x [C, T], w/b are null on the affine=False variant
-ggml_tensor * clip_graph_chatterbox_spkenc::bn1d(const clip_chatterbox::bn & n, ggml_tensor * x) {
+ggml_tensor * mtmd_graph_chatterbox_spkenc::bn1d(const mtmd_chatterbox::bn & n, ggml_tensor * x) {
     ggml_tensor * sd = ggml_sqrt(ctx0, ggml_scale_bias(ctx0, n.var, 1.0f, bn_eps));
     if (!n.w) {
         return ggml_div(ctx0, ggml_sub(ctx0, x, n.mean), sd);
@@ -16,7 +16,7 @@ ggml_tensor * clip_graph_chatterbox_spkenc::bn1d(const clip_chatterbox::bn & n,
 }

 // batchnorm on a conv2d activation [W=T, H=F, C, 1], stats on ne2
-static ggml_tensor * cbx_bn2d(ggml_context * ctx0, const clip_chatterbox::bn & n, ggml_tensor * x) {
+static ggml_tensor * cbx_bn2d(ggml_context * ctx0, const mtmd_chatterbox::bn & n, ggml_tensor * x) {
     const int C = (int) x->ne[2];
     ggml_tensor * mean = ggml_reshape_4d(ctx0, n.mean, 1, 1, C, 1);
     ggml_tensor * var  = ggml_reshape_4d(ctx0, n.var,  1, 1, C, 1);
@@ -27,12 +27,12 @@ static ggml_tensor * cbx_bn2d(ggml_context * ctx0, const clip_chatterbox::bn & n
     return ggml_add(ctx0, ggml_mul(ctx0, x, a), shift);
 }

-ggml_tensor * clip_graph_chatterbox_spkenc::bn2d_relu(const clip_chatterbox::bn & n, ggml_tensor * x) {
+ggml_tensor * mtmd_graph_chatterbox_spkenc::bn2d_relu(const mtmd_chatterbox::bn & n, ggml_tensor * x) {
     return ggml_relu(ctx0, cbx_bn2d(ctx0, n, x));
 }

 // fcm residual 2d block, stride on the frequency axis only
-ggml_tensor * clip_graph_chatterbox_spkenc::res2d(const clip_chatterbox::spk_res2d & r, ggml_tensor * x,
+ggml_tensor * mtmd_graph_chatterbox_spkenc::res2d(const mtmd_chatterbox::spk_res2d & r, ggml_tensor * x,
                                                   int stride) {
     ggml_tensor * cur = ggml_conv_2d(ctx0, r.conv1_w, x, 1, stride, 1, 1, 1, 1);
     cur = bn2d_relu(r.bn1, cur);
@@ -48,7 +48,7 @@ ggml_tensor * clip_graph_chatterbox_spkenc::res2d(const clip_chatterbox::spk_res
 }

 // cam dense tdnn layer: bottleneck then context-gated conv; x [C_in, T] -> [growth, T]
-ggml_tensor * clip_graph_chatterbox_spkenc::cam_layer(const clip_chatterbox::spk_cam_layer & l, ggml_tensor * x,
+ggml_tensor * mtmd_graph_chatterbox_spkenc::cam_layer(const mtmd_chatterbox::spk_cam_layer & l, ggml_tensor * x,
                                                       int dil, ggml_tensor * segfix) {
     ggml_tensor * h = ggml_relu(ctx0, bn1d(l.nl1_bn, x));
     h = cbx_conv1d(l.linear1_w, nullptr, h, 1, 0, 0);
@@ -86,7 +86,7 @@ ggml_tensor * clip_graph_chatterbox_spkenc::cam_layer(const clip_chatterbox::spk
     return ggml_mul(ctx0, y, m);
 }

-ggml_cgraph * clip_graph_chatterbox_spkenc::build() {
+ggml_cgraph * mtmd_graph_chatterbox_spkenc::build() {
     const auto & c = model.cbx;

     const int T  = img.nx();
@@ -119,7 +119,7 @@ ggml_cgraph * clip_graph_chatterbox_spkenc::build() {
     cb(x, "spk_tdnn", -1);

     static const int block_dil[3] = {1, 2, 2};
-    const clip_chatterbox::spk_cam_block * blocks[3] = { &c.spk_block1, &c.spk_block2, &c.spk_block3 };
+    const mtmd_chatterbox::spk_cam_block * blocks[3] = { &c.spk_block1, &c.spk_block2, &c.spk_block3 };
     for (int bi = 1; bi <= 3; bi++) {
         const auto & blk = *blocks[bi - 1];
         for (const auto & l : blk.layers) {
diff --git a/tools/mtmd/models/cogvlm.cpp b/tools/mtmd/models/cogvlm.cpp
index 44bc88442..aeaa33e4f 100644
--- a/tools/mtmd/models/cogvlm.cpp
+++ b/tools/mtmd/models/cogvlm.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_cogvlm::build() {
+ggml_cgraph * mtmd_graph_cogvlm::build() {
     GGML_ASSERT(model.class_embedding != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);

diff --git a/tools/mtmd/models/conformer.cpp b/tools/mtmd/models/conformer.cpp
index 9463f9f1c..4cd2bee53 100644
--- a/tools/mtmd/models/conformer.cpp
+++ b/tools/mtmd/models/conformer.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_conformer::build() {
+ggml_cgraph * mtmd_graph_conformer::build() {
     const int n_frames   = img.nx();
     const int n_pos      = n_frames / 2;
     const int n_pos_embd = (((((n_frames + 1) / 2) + 1) / 2 + 1) / 2) * 2 - 1;
diff --git a/tools/mtmd/models/deepseek4v.cpp b/tools/mtmd/models/deepseek4v.cpp
index 06005d52c..e3c0d95ed 100644
--- a/tools/mtmd/models/deepseek4v.cpp
+++ b/tools/mtmd/models/deepseek4v.cpp
@@ -11,14 +11,14 @@
 //
 // each aligner row ends with a NEWLINE, an odd row count is padded with a full row of PADs
 // pairs of adjacent rows are interleaved column-wise ("N-layout")
-// the mapping is precomputed on CPU as the "layout_idx" input (see set_input in clip.cpp)
+// the mapping is precomputed on CPU as the "layout_idx" input (see set_input in mtmd-subcontext.cpp)
 //
 // ref: inference/vision.py and inference/image_processor.py in the HF repo
 //
 // DeepSeek-V4.1-Flash (deepseek41v) shares the encoder and aligner; its block is plain reading order
 //   [START] ([IMAGE]*n_llm_w [NEWLINE])*n_llm_h [END], no lead pads, no pad row, no IMAGE_PAD tensor

-ggml_cgraph * clip_graph_deepseek4v::build() {
+ggml_cgraph * mtmd_graph_deepseek4v::build() {
     const int n_merge = hparams.n_merge;

     // 2D input positions
@@ -27,7 +27,7 @@ ggml_cgraph * clip_graph_deepseek4v::build() {
     ggml_set_input(positions);

     int sections[4] = {d_head/4, d_head/4, 0, 0};
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_multi(ctx0, cur, positions, nullptr,
             d_head/2, sections, GGML_ROPE_TYPE_VISION,
             0, hparams.rope_theta, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
@@ -92,8 +92,8 @@ ggml_cgraph * clip_graph_deepseek4v::build() {
             }
         }

-        const int n_llm_w = CLIP_ALIGN(n_patches_x, n_merge) / n_merge;
-        const int n_llm_h = CLIP_ALIGN(n_patches_y, n_merge) / n_merge;
+        const int n_llm_w = MTMD_ALIGN(n_patches_x, n_merge) / n_merge;
+        const int n_llm_h = MTMD_ALIGN(n_patches_y, n_merge) / n_merge;
         const int n_out   = is_v41 ? dsv41_n_block_tokens(n_llm_w, n_llm_h)
                                    : dsv4_get_block_layout(n_llm_w, n_llm_h, img.lead_pad).n_out;
         GGML_ASSERT(n_grid == n_llm_w * n_llm_h);
diff --git a/tools/mtmd/models/deepseekocr.cpp b/tools/mtmd/models/deepseekocr.cpp
index b784cdad6..af69c0332 100644
--- a/tools/mtmd/models/deepseekocr.cpp
+++ b/tools/mtmd/models/deepseekocr.cpp
@@ -105,7 +105,7 @@ static ggml_tensor * conv_2d_f32(ggml_context * ctx0, ggml_tensor * a, ggml_tens
 }


-ggml_tensor * clip_graph_deepseekocr::build_sam(ggml_tensor * inp_raw) {
+ggml_tensor * mtmd_graph_deepseekocr::build_sam(ggml_tensor * inp_raw) {
     // Building SAM
     const int n_embd  = hparams.sam_n_embd;
     const int n_layer = hparams.sam_n_layer;
@@ -211,7 +211,7 @@ ggml_tensor * clip_graph_deepseekocr::build_sam(ggml_tensor * inp_raw) {
             mask = ggml_add(ctx0, rw, rh); // [B*n_heads, H*W, H, W]
             mask = ggml_reshape_4d(ctx0, mask, W * H, W * H, n_heads, B);
             // casting mask to F16 only required when flash-attn is enabled
-            if (flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
+            if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
                 mask = ggml_cast(ctx0, mask, GGML_TYPE_F16);
             }

@@ -264,7 +264,7 @@ ggml_tensor * clip_graph_deepseekocr::build_sam(ggml_tensor * inp_raw) {
     return cur;
 }

-ggml_cgraph * clip_graph_deepseekocr::build() {
+ggml_cgraph * mtmd_graph_deepseekocr::build() {
     // patch embedding
     ggml_tensor * inp_raw = build_inp_raw();

diff --git a/tools/mtmd/models/deepseekocr2.cpp b/tools/mtmd/models/deepseekocr2.cpp
index 7088bb97b..fb20e9d1d 100644
--- a/tools/mtmd/models/deepseekocr2.cpp
+++ b/tools/mtmd/models/deepseekocr2.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_deepseekocr2::build() {
+ggml_cgraph * mtmd_graph_deepseekocr2::build() {
     GGML_ASSERT(hparams.n_head_kv > 0);
     GGML_ASSERT(n_head % hparams.n_head_kv == 0);

@@ -45,7 +45,7 @@ ggml_cgraph * clip_graph_deepseekocr2::build() {

         ggml_tensor * inp_pos = ggml_cast(ctx0, ggml_arange(ctx0, 0, seq_len, 1), GGML_TYPE_I32);

-        auto add_rope = [&](ggml_tensor * x, const clip_layer &) {
+        auto add_rope = [&](ggml_tensor * x, const mtmd_layer &) {
             return ggml_rope_ext(ctx0, x, inp_pos, nullptr, d_head,
                                  GGML_ROPE_TYPE_NEOX, 131072, 1000000, 1, 0, 1, 0, 0);
         };
diff --git a/tools/mtmd/models/dots3note.cpp b/tools/mtmd/models/dots3note.cpp
index 93c14fc79..c942ea7da 100644
--- a/tools/mtmd/models/dots3note.cpp
+++ b/tools/mtmd/models/dots3note.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_dots3note_a::build() {
+ggml_cgraph * mtmd_graph_dots3note_a::build() {
     // inp_raw: [n_frames, n_mel, 1], one 60s chunk, mel frames not padded
     // the reference impl zero-masks conv inputs beyond the valid length at each stage;
     // running on exactly the valid frames with the convs' zero padding is equivalent
@@ -37,7 +37,7 @@ ggml_cgraph * clip_graph_dots3note_a::build() {
     ggml_set_input(positions);

     // partial rotary: first half of each head, NEOX style
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_ext(ctx0, cur, positions, nullptr, d_head/2,
                              GGML_ROPE_TYPE_NEOX, 0, hparams.rope_theta, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
     };
diff --git a/tools/mtmd/models/dotsocr.cpp b/tools/mtmd/models/dotsocr.cpp
index 92974bb67..dcede11c3 100644
--- a/tools/mtmd/models/dotsocr.cpp
+++ b/tools/mtmd/models/dotsocr.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_dotsocr::build() {
+ggml_cgraph * mtmd_graph_dotsocr::build() {
     const int n_pos            = n_patches;
     const int num_position_ids = n_pos * 4; // m-rope requires 4 dim per position

@@ -11,7 +11,7 @@ ggml_cgraph * clip_graph_dotsocr::build() {
     ggml_set_name(positions, "positions");
     ggml_set_input(positions);

-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_multi(
                     ctx0, cur, positions, nullptr,
                     d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION,
diff --git a/tools/mtmd/models/drama3-gen.cpp b/tools/mtmd/models/drama3-gen.cpp
index de7a4f9e8..e77fcf540 100644
--- a/tools/mtmd/models/drama3-gen.cpp
+++ b/tools/mtmd/models/drama3-gen.cpp
@@ -7,13 +7,13 @@
 static constexpr int   DRAMA3_N_ROT      = 64;
 static constexpr float DRAMA3_ROPE_THETA = 1e7f;

-std::vector<c2w_state_slot> list_drama3_state_slots(const clip_hparams & hparams, const clip_model & model) {
+std::vector<c2w_state_slot> list_drama3_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
     std::vector<c2w_state_slot> slots;
     slots.push_back({"tower_pos", 1, 1, true});
     for (size_t il = 0; il < model.layers.size(); il++) {
         const int64_t n_kv = model.layers[il].k_w->ne[1];
-        slots.push_back({"tower_k_" + std::to_string(il), n_kv, clip_graph_drama3_gen::n_pos_max, true});
-        slots.push_back({"tower_v_" + std::to_string(il), n_kv, clip_graph_drama3_gen::n_pos_max, true});
+        slots.push_back({"tower_k_" + std::to_string(il), n_kv, mtmd_graph_drama3_gen::n_pos_max, true});
+        slots.push_back({"tower_v_" + std::to_string(il), n_kv, mtmd_graph_drama3_gen::n_pos_max, true});
     }
     for (auto & slot : list_c2w_state_slots(hparams, model)) {
         slots.push_back(slot);
@@ -22,7 +22,7 @@ std::vector<c2w_state_slot> list_drama3_state_slots(const clip_hparams & hparams
 }

 // h_out = out_norm(Block(eh_proj([enorm(mask * E[code] + extra); hnorm(h_in)])))
-ggml_tensor * clip_graph_drama3_gen::tower_layer(ggml_tensor * x, int il, ggml_tensor * code, ggml_tensor * mask, ggml_tensor * extra,
+ggml_tensor * mtmd_graph_drama3_gen::tower_layer(ggml_tensor * x, int il, ggml_tensor * code, ggml_tensor * mask, ggml_tensor * extra,
                                                  ggml_tensor * inp_pos, ggml_tensor * k_slot, ggml_tensor * v_slot, int pos) {
     const auto & layer = model.layers[il];
     const auto & tl    = model.drama3_layers[il];
@@ -80,7 +80,7 @@ ggml_tensor * clip_graph_drama3_gen::tower_layer(ggml_tensor * x, int il, ggml_t
 }

 // one row: backbone hidden + next main code -> other codes of that frame + next backbone input
-ggml_cgraph * clip_graph_drama3_gen::build_tower() {
+ggml_cgraph * mtmd_graph_drama3_gen::build_tower() {
     const int     n_layer   = (int) model.layers.size();
     const int     n_heads   = (int) model.gen_code_head_w->ne[2]; // one per residual codebook
     const int     n_shared  = n_layer - n_heads;
@@ -141,7 +141,7 @@ ggml_cgraph * clip_graph_drama3_gen::build_tower() {
     ggml_tensor * extra  = ggml_cont(ctx0, ggml_transpose(ctx0, ggml_get_rows(ctx0, cb_all, inp_extra)));
     extra = ggml_mul(ctx0, ggml_reshape_2d(ctx0, ggml_sum_rows(ctx0, extra), n_embd_cb, 1), extra_on);

-    clip_graph_qwen3tts_gen::code_gen cg(*this, top_k, top_p);
+    mtmd_graph_qwen3tts_gen::code_gen cg(*this, top_k, top_p);
     ggml_tensor * out_codes = cg.cache_set(ggml_new_tensor_2d(ctx0, GGML_TYPE_I32, 1, n_cb), 0, inp_code0);

     ggml_tensor * code = inp_code0;
@@ -183,7 +183,7 @@ ggml_cgraph * clip_graph_drama3_gen::build_tower() {
 }

 // frames -> PCM, qwen3tts code2wav blocks without pre_conv and tfm projections
-ggml_cgraph * clip_graph_drama3_gen::build_codec() {
+ggml_cgraph * mtmd_graph_drama3_gen::build_codec() {
     const auto & c2w = model.c2w;
     const int64_t n_cb = model.gen_code_embd_w->ne[2];

@@ -191,7 +191,7 @@ ggml_cgraph * clip_graph_drama3_gen::build_codec() {
     ggml_set_name(inp_codes, "inp_codes");
     ggml_set_input(inp_codes);

-    clip_graph_qwen3tts_gen::code2wav dec(*this);
+    mtmd_graph_qwen3tts_gen::code2wav dec(*this);
     dec.gelu_erf = true;
     for (const auto & slot : list_c2w_state_slots(hparams, model)) {
         ggml_tensor * t = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, slot.ne0, slot.ne1);
@@ -251,6 +251,6 @@ ggml_cgraph * clip_graph_drama3_gen::build_codec() {
     return gf;
 }

-ggml_cgraph * clip_graph_drama3_gen::build() {
-    return gen_process == CLIP_GEN_PROCESS_GEN_WAV ? build_codec() : build_tower();
+ggml_cgraph * mtmd_graph_drama3_gen::build() {
+    return gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV ? build_codec() : build_tower();
 }
diff --git a/tools/mtmd/models/exaone4_5.cpp b/tools/mtmd/models/exaone4_5.cpp
index d3372a1cd..768620e59 100644
--- a/tools/mtmd/models/exaone4_5.cpp
+++ b/tools/mtmd/models/exaone4_5.cpp
@@ -1,7 +1,7 @@
 // similar to qwen2vl, except for GQA attention
 #include "models.h"

-ggml_cgraph * clip_graph_exaone4_5::build() {
+ggml_cgraph * mtmd_graph_exaone4_5::build() {
     GGML_ASSERT(model.patch_bias == nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);

diff --git a/tools/mtmd/models/gemma4a.cpp b/tools/mtmd/models/gemma4a.cpp
index f98a8b6fc..f1ac867ca 100644
--- a/tools/mtmd/models/gemma4a.cpp
+++ b/tools/mtmd/models/gemma4a.cpp
@@ -1,5 +1,5 @@
 /**
- * Gemma 4 Audio Conformer Encoder (clip_graph_gemma4a)
+ * Gemma 4 Audio Conformer Encoder (mtmd_graph_gemma4a)
  *
  * Architecture: Conformer with dual half-step FFN, full self-attention
  * with sinusoidal RPE, depthwise light conv, and output projection.
@@ -8,7 +8,7 @@
 #include "models.h"
 #include <cmath>

-ggml_cgraph * clip_graph_gemma4a::build() {
+ggml_cgraph * mtmd_graph_gemma4a::build() {
     const float res_weight = 0.5f;
     const float norm_eps   = 1e-6f;

@@ -273,7 +273,7 @@ ggml_cgraph * clip_graph_gemma4a::build() {
     return gf;
 }

-ggml_tensor * clip_graph_gemma4a::build_mm(ggml_tensor * w, ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_gemma4a::build_mm(ggml_tensor * w, ggml_tensor * x) const {
     auto it = model.clamp_info_map.find(w->name);
     if (it == model.clamp_info_map.end()) {
         return ggml_mul_mat(ctx0, w, x);
diff --git a/tools/mtmd/models/gemma4ua.cpp b/tools/mtmd/models/gemma4ua.cpp
index e24bef2ed..1d77b8889 100644
--- a/tools/mtmd/models/gemma4ua.cpp
+++ b/tools/mtmd/models/gemma4ua.cpp
@@ -1,7 +1,7 @@
 #include "models.h"
 #include <cmath>

-ggml_cgraph * clip_graph_gemma4ua::build() {
+ggml_cgraph * mtmd_graph_gemma4ua::build() {
     ggml_tensor * inp = build_inp_raw(1);

     auto cur = ggml_cont(ctx0, ggml_permute(ctx0, inp, 1, 0, 2, 3));
diff --git a/tools/mtmd/models/gemma4uv.cpp b/tools/mtmd/models/gemma4uv.cpp
index 96031141b..04e139a1b 100644
--- a/tools/mtmd/models/gemma4uv.cpp
+++ b/tools/mtmd/models/gemma4uv.cpp
@@ -1,7 +1,7 @@
 #include "models.h"
 #include <cmath>

-ggml_cgraph * clip_graph_gemma4uv::build() {
+ggml_cgraph * mtmd_graph_gemma4uv::build() {
     ggml_tensor * inp_raw = build_inp_raw();

     // Gemma4UnifiedVisionEmbedder uses default pytorch LayerNorm, not RMSNorm
diff --git a/tools/mtmd/models/gemma4v.cpp b/tools/mtmd/models/gemma4v.cpp
index 448438947..46733e0f3 100644
--- a/tools/mtmd/models/gemma4v.cpp
+++ b/tools/mtmd/models/gemma4v.cpp
@@ -1,7 +1,7 @@
 #include "models.h"
 #include <cmath>

-ggml_cgraph * clip_graph_gemma4v::build() {
+ggml_cgraph * mtmd_graph_gemma4v::build() {
     ggml_tensor * inp_raw = build_inp_raw();

     // patches = 2 * (patches - 0.5)
@@ -43,7 +43,7 @@ ggml_cgraph * clip_graph_gemma4v::build() {
     }

     // similar to build_rope_2d, but use neox ordering
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         const int64_t n_dim = cur->ne[0];

         // first half, dims [0, n_dim/2)
@@ -117,7 +117,7 @@ ggml_cgraph * clip_graph_gemma4v::build() {
     return gf;
 }

-ggml_tensor * clip_graph_gemma4v::build_mm(ggml_tensor * w, ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_gemma4v::build_mm(ggml_tensor * w, ggml_tensor * x) const {
     // Gemma4ClippableLinear

     auto it = model.clamp_info_map.find(w->name);
diff --git a/tools/mtmd/models/glm4v.cpp b/tools/mtmd/models/glm4v.cpp
index 1cbcea942..2b9a30192 100644
--- a/tools/mtmd/models/glm4v.cpp
+++ b/tools/mtmd/models/glm4v.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_glm4v::build() {
+ggml_cgraph * mtmd_graph_glm4v::build() {
     GGML_ASSERT(model.patch_bias != nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);

@@ -63,7 +63,7 @@ ggml_cgraph * clip_graph_glm4v::build() {
         cb(learned_pos_embd, "learned_pos_embd", -1);
     }

-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_multi(
                     ctx0, cur, positions, nullptr,
                     d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION,
diff --git a/tools/mtmd/models/granite-speech.cpp b/tools/mtmd/models/granite-speech.cpp
index 9725def82..64fb76c41 100644
--- a/tools/mtmd/models/granite-speech.cpp
+++ b/tools/mtmd/models/granite-speech.cpp
@@ -2,7 +2,7 @@

 #include <algorithm>

-ggml_cgraph * clip_graph_granite_speech::build() {
+ggml_cgraph * mtmd_graph_granite_speech::build() {
     const int n_frames     = img.nx();
     const int context_size = hparams.audio_chunk_size;
     const int ctc_layer    = n_layer / 2;
diff --git a/tools/mtmd/models/granite4-vision.cpp b/tools/mtmd/models/granite4-vision.cpp
index a75f1cee9..390d6edf6 100644
--- a/tools/mtmd/models/granite4-vision.cpp
+++ b/tools/mtmd/models/granite4-vision.cpp
@@ -1,6 +1,6 @@
 #include "models.h"
-#include "../clip-impl.h"
-#include "../clip-model.h"
+#include "../mtmd-impl.h"
+#include "../mtmd-model.h"

 #include <algorithm>
 #include <cmath>
@@ -9,7 +9,7 @@
 #include <vector>

 /*
- * Granite Vision 4.1 clip graph
+ * Granite Vision 4.1 vision graph
  *
  *   Stage 1a: SigLIP vision tower (N layers, post-norm)
  *   Stage 1b: WindowQFormer blocks (deepstack + spatial)
@@ -22,7 +22,7 @@
 // ---------------------------------------------------------------------------

 // split the stacked tiles into the batch axis, then run the usual patch embedding
-ggml_tensor * clip_graph_granite4_vision::build_tile_inp() {
+ggml_tensor * mtmd_graph_granite4_vision::build_tile_inp() {
     ggml_tensor * inp_raw = build_inp_raw();

     if (n_tiles > 1) {
@@ -41,7 +41,7 @@ ggml_tensor * clip_graph_granite4_vision::build_tile_inp() {
     return inp;
 }

-ggml_tensor * clip_graph_granite4_vision::gather(
+ggml_tensor * mtmd_graph_granite4_vision::gather(
         ggml_tensor * src,
         const std::string & name,
         int idx_len) {
@@ -52,7 +52,7 @@ ggml_tensor * clip_graph_granite4_vision::gather(
     return ggml_get_rows(ctx0, src, idx);
 }

-ggml_tensor * clip_graph_granite4_vision::interp_down(
+ggml_tensor * mtmd_graph_granite4_vision::interp_down(
         ggml_tensor * src,
         int side,
         int new_side) {
@@ -72,7 +72,7 @@ ggml_tensor * clip_graph_granite4_vision::interp_down(
 // build_block - WindowQFormer block implementation
 // ---------------------------------------------------------------------------

-ggml_tensor * clip_graph_granite4_vision::build_block(
+ggml_tensor * mtmd_graph_granite4_vision::build_block(
         const qf_block & blk,
         ggml_tensor * h,
         int bid,
@@ -244,7 +244,7 @@ ggml_tensor * clip_graph_granite4_vision::build_block(

 // Build the K-tiled, base-scaled newline row tensor.
 // Shape: (n_mmproj_embd, 1)
-ggml_tensor * clip_graph_granite4_vision::build_newline_row(ggml_context * ctx0) {
+ggml_tensor * mtmd_graph_granite4_vision::build_newline_row(ggml_context * ctx0) {
     const int K = (int) model.qf_proj_blocks.size();
     GGML_ASSERT(K > 0);
     GGML_ASSERT(n_mmproj_embd % K == 0);
@@ -272,7 +272,7 @@ ggml_tensor * clip_graph_granite4_vision::build_newline_row(ggml_context * ctx0)
 // Assemble [overview, tile(0,0), tile(0,1), ...] into one token sequence:
 // the overview tokens first, then the tile grid read in raster order with one newline per row.
 // ref: https://github.com/huggingface/transformers/blob/v5.0.0/src/transformers/models/llava_next/modeling_llava_next.py#L266
-ggml_tensor * clip_graph_granite4_vision::build_anyres_assembly(ggml_tensor * cur, int out_side) {
+ggml_tensor * mtmd_graph_granite4_vision::build_anyres_assembly(ggml_tensor * cur, int out_side) {
     const int n_dim  = cur->ne[0];
     const int grid_x = anyres.grid_x;
     const int grid_y = anyres.grid_y;
@@ -293,7 +293,7 @@ ggml_tensor * clip_graph_granite4_vision::build_anyres_assembly(ggml_tensor * cu

     // drop the tokens that only cover the padding added when resizing to the grid
     int off_x, off_y, w, h;
-    clip_anyres_unpad(cur_w, cur_h, anyres.orig_nx, anyres.orig_ny, off_x, off_y, w, h);
+    mtmd_anyres_unpad(cur_w, cur_h, anyres.orig_nx, anyres.orig_ny, off_x, off_y, w, h);
     if (w != cur_w || h != cur_h) {
         tiles = ggml_cont(ctx0, ggml_view_3d(ctx0, tiles, n_dim, w, h,
                                              tiles->nb[1], tiles->nb[2],
@@ -307,7 +307,7 @@ ggml_tensor * clip_graph_granite4_vision::build_anyres_assembly(ggml_tensor * cu
     return ggml_concat(ctx0, base, tiles, 1);
 }

-ggml_cgraph * clip_graph_granite4_vision::build() {
+ggml_cgraph * mtmd_graph_granite4_vision::build() {
     GGML_ASSERT(model.patch_embeddings_0 != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);
diff --git a/tools/mtmd/models/hunyuanvl.cpp b/tools/mtmd/models/hunyuanvl.cpp
index 2c670979d..b6b3fef58 100644
--- a/tools/mtmd/models/hunyuanvl.cpp
+++ b/tools/mtmd/models/hunyuanvl.cpp
@@ -1,12 +1,12 @@
 #include "models.h"

-ggml_cgraph * clip_graph_hunyuanvl::build() {
+ggml_cgraph * mtmd_graph_hunyuanvl::build() {
     const int merge = hparams.n_merge;
     const int pw    = n_patches_x;
     const int ph    = n_patches_y;

     // position embedding: declared as a graph input, filled on CPU
-    // by clip_image_batch_encode (see PROJECTOR_TYPE_HUNYUANVL branch there).
+    // by mtmd_subcontext::encode() (see PROJECTOR_TYPE_HUNYUANVL branch there).
     ggml_tensor * pos_embd = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_embd, ph * pw);
     ggml_set_name(pos_embd, "hunyuanvl_pos_embd");
     ggml_set_input(pos_embd);
diff --git a/tools/mtmd/models/internvl.cpp b/tools/mtmd/models/internvl.cpp
index af8f12cdc..5e0b679c4 100644
--- a/tools/mtmd/models/internvl.cpp
+++ b/tools/mtmd/models/internvl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_internvl::build() {
+ggml_cgraph * mtmd_graph_internvl::build() {
     GGML_ASSERT(model.class_embedding != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);

diff --git a/tools/mtmd/models/kimik25.cpp b/tools/mtmd/models/kimik25.cpp
index cb345f0fc..13baf86d0 100644
--- a/tools/mtmd/models/kimik25.cpp
+++ b/tools/mtmd/models/kimik25.cpp
@@ -2,10 +2,10 @@
 #include <cstring>
 #include <cmath>

-// note: this is similar to clip_graph::resize_position_embeddings, major difference is having
+// note: this is similar to mtmd_graph::resize_position_embeddings, major difference is having
 // the w/h in ne[1] and ne[2] instead of assuming with sqrt. Could try storing the tensor in 2D instead
 // with a w*h? Also the permute is a bit different at (2, 1, 0, 3) instead of (2, 0, 1, 3).
-ggml_tensor * clip_graph_kimik25::resize_position_embeddings_3d(uint32_t interpolation_mode) {
+ggml_tensor * mtmd_graph_kimik25::resize_position_embeddings_3d(uint32_t interpolation_mode) {
     ggml_tensor * pos_embd = model.position_embeddings;
     const int height       = img.ny() / patch_size;
     const int width        = img.nx() / patch_size;
@@ -31,7 +31,7 @@ ggml_tensor * clip_graph_kimik25::resize_position_embeddings_3d(uint32_t interpo
     return pos_embd;
 }

-ggml_cgraph * clip_graph_kimik25::build() {
+ggml_cgraph * mtmd_graph_kimik25::build() {
     ggml_tensor * pos_h = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_patches);
     ggml_set_name(pos_h, "pos_h");
     ggml_set_input(pos_h);
@@ -44,7 +44,7 @@ ggml_cgraph * clip_graph_kimik25::build() {

     // Kimi-K2.5 uses interleaved 2D RoPE pattern natively, but
     // Q / K are permuted during conversion to use split format.
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         cur = build_rope_2d(ctx0, cur, pos_w, pos_h, hparams.rope_theta, false);
         return cur;
     };
diff --git a/tools/mtmd/models/kimivl.cpp b/tools/mtmd/models/kimivl.cpp
index 0a06f5090..dd8146708 100644
--- a/tools/mtmd/models/kimivl.cpp
+++ b/tools/mtmd/models/kimivl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_kimivl::build() {
+ggml_cgraph * mtmd_graph_kimivl::build() {
     // 2D input positions
     ggml_tensor * pos_h = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_patches);
     ggml_set_name(pos_h, "pos_h");
@@ -13,7 +13,7 @@ ggml_cgraph * clip_graph_kimivl::build() {
     ggml_tensor * learned_pos_embd = resize_position_embeddings();

     // build ViT with 2D position embeddings
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         // first half is X axis and second half is Y axis
         return build_rope_2d(ctx0, cur, pos_w, pos_h, hparams.rope_theta, false);
     };
diff --git a/tools/mtmd/models/lfm3a-gen.cpp b/tools/mtmd/models/lfm3a-gen.cpp
index 998605711..f5aedac6f 100644
--- a/tools/mtmd/models/lfm3a-gen.cpp
+++ b/tools/mtmd/models/lfm3a-gen.cpp
@@ -9,7 +9,7 @@
 // GEN_WAV : a window of frames -> polar STFT frames of the liquid-codec decoder, the ISTFT runs on the host

 // state carried between GEN_WAV calls: rope offset, short-conv taps and attention window of the codec decoder
-std::vector<c2w_state_slot> list_lfm3a_gen_state_slots(const clip_hparams & hparams, const clip_model & model) {
+std::vector<c2w_state_slot> list_lfm3a_gen_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
     std::vector<c2w_state_slot> slots;
     if (model.c2w.tfm_layers.empty()) {
         return slots;
@@ -29,7 +29,7 @@ std::vector<c2w_state_slot> list_lfm3a_gen_state_slots(const clip_hparams & hpar
 }

 // pre-norm LFM block, with the LFM3 sandwich norms when the layer has them
-ggml_tensor * clip_graph_lfm3a_gen::build_lfm_layer(ggml_tensor * inpL, const clip_layer & layer, float eps, int il,
+ggml_tensor * mtmd_graph_lfm3a_gen::build_lfm_layer(ggml_tensor * inpL, const mtmd_layer & layer, float eps, int il,
                                                     const std::function<ggml_tensor *(ggml_tensor *)> & op) {
     ggml_tensor * cur = build_norm(inpL, layer.ln_1_w, nullptr, NORM_TYPE_RMS, eps, il);
     cur = op(cur);
@@ -47,7 +47,7 @@ ggml_tensor * clip_graph_lfm3a_gen::build_lfm_layer(ggml_tensor * inpL, const cl
 }

 // GQA with q/k RMSNorm, NEOX rope, an optional cached K/V prefix and the LFM3 per-head sigmoid gate
-ggml_tensor * clip_graph_lfm3a_gen::build_lfm_attn(ggml_tensor * cur, const clip_layer & layer, int n_head, int n_head_kv,
+ggml_tensor * mtmd_graph_lfm3a_gen::build_lfm_attn(ggml_tensor * cur, const mtmd_layer & layer, int n_head, int n_head_kv,
                                                    float eps, float rope_theta, int il, ggml_tensor * inp_pos,
                                                    ggml_tensor * kq_mask, ggml_tensor * k_cache, ggml_tensor * v_cache,
                                                    ggml_tensor ** k_all, ggml_tensor ** v_all) {
@@ -89,7 +89,7 @@ ggml_tensor * clip_graph_lfm3a_gen::build_lfm_attn(ggml_tensor * cur, const clip
 }

 // short conv: in_proj -> b * x -> causal depthwise conv over [conv_state | x] -> c * conv -> out_proj
-ggml_tensor * clip_graph_lfm3a_gen::build_lfm_conv(ggml_tensor * cur, const clip_layer & layer, int il,
+ggml_tensor * mtmd_graph_lfm3a_gen::build_lfm_conv(ggml_tensor * cur, const mtmd_layer & layer, int il,
                                                    ggml_tensor * conv_state, ggml_tensor ** conv_state_out) {
     const int64_t n_embd_c = cur->ne[0];
     const int64_t n_pos    = cur->ne[1];
@@ -110,7 +110,7 @@ ggml_tensor * clip_graph_lfm3a_gen::build_lfm_conv(ggml_tensor * cur, const clip
     return build_mm(layer.conv_pw2_w, cur);
 }

-ggml_tensor * clip_graph_lfm3a_gen::build_gen_code() {
+ggml_tensor * mtmd_graph_lfm3a_gen::build_gen_code() {
     ggml_tensor * embd_w = model.gen_code_embd_w; // [n_embd, n_vocab, n_codebooks], tied to the output head
     const int64_t n_cb    = embd_w->ne[2];
     const int64_t n_vocab = embd_w->ne[1];
@@ -169,7 +169,7 @@ ggml_tensor * clip_graph_lfm3a_gen::build_gen_code() {
     return fb;
 }

-ggml_tensor * clip_graph_lfm3a_gen::build_gen_wav() {
+ggml_tensor * mtmd_graph_lfm3a_gen::build_gen_wav() {
     const auto & c2w = model.c2w;
     const int64_t n_cb   = model.gen_code_embd_w->ne[2];
     const int64_t n_fs   = hparams.wav_frame_stack;
@@ -201,7 +201,7 @@ ggml_tensor * clip_graph_lfm3a_gen::build_gen_wav() {
     cur = ggml_repeat_4d(ctx0, ggml_reshape_3d(ctx0, cur, n_embd_dec, 1, n_frames), n_embd_dec, n_fs, n_frames, 1);
     cur = ggml_reshape_2d(ctx0, cur, n_embd_dec, n_pos);

-    // positions continue across calls from the "pos" slot, see clip_encode()
+    // positions continue across calls from the "pos" slot, see mtmd_subcontext::encode()
     ggml_tensor * inp_pos = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_pos);
     ggml_set_name(inp_pos, "positions");
     ggml_set_input(inp_pos);
@@ -250,8 +250,8 @@ ggml_tensor * clip_graph_lfm3a_gen::build_gen_wav() {
     return cur;
 }

-ggml_cgraph * clip_graph_lfm3a_gen::build() {
-    ggml_tensor * out = gen_process == CLIP_GEN_PROCESS_GEN_WAV ? build_gen_wav() : build_gen_code();
+ggml_cgraph * mtmd_graph_lfm3a_gen::build() {
+    ggml_tensor * out = gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV ? build_gen_wav() : build_gen_code();
     ggml_build_forward_expand(gf, out);
     return gf;
 }
diff --git a/tools/mtmd/models/lfm3a.cpp b/tools/mtmd/models/lfm3a.cpp
index 4882fc42f..03128a298 100644
--- a/tools/mtmd/models/lfm3a.cpp
+++ b/tools/mtmd/models/lfm3a.cpp
@@ -2,7 +2,7 @@

 // streaming encoder state: base position, conv left context, short-conv taps and attention window
 // a zero state gives the same result as encoding the whole clip
-std::vector<c2w_state_slot> list_lfm3a_state_slots(const clip_hparams & hparams, const clip_model & model) {
+std::vector<c2w_state_slot> list_lfm3a_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
     std::vector<c2w_state_slot> slots;

     const int64_t n_mel  = hparams.n_mel_bins;
@@ -27,7 +27,7 @@ std::vector<c2w_state_slot> list_lfm3a_state_slots(const clip_hparams & hparams,
 }

 // LFM3-Audio encoder: conv downsampler (8x in time) -> causal LFM2 stack -> MLP projector
-ggml_cgraph * clip_graph_lfm3a::build() {
+ggml_cgraph * mtmd_graph_lfm3a::build() {
     const norm_type norm_t = NORM_TYPE_RMS;
     const float rope_theta = hparams.rope_theta;
     const int64_t prefix   = hparams.attn_window_size - 1;
diff --git a/tools/mtmd/models/lfm3vl.cpp b/tools/mtmd/models/lfm3vl.cpp
index e37390976..6ad64ecb1 100644
--- a/tools/mtmd/models/lfm3vl.cpp
+++ b/tools/mtmd/models/lfm3vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_lfm3vl::build() {
+ggml_cgraph * mtmd_graph_lfm3vl::build() {
     GGML_ASSERT(model.patch_embeddings_0 != nullptr);
     GGML_ASSERT(model.lfm3vl_embedding_norm_w != nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);
diff --git a/tools/mtmd/models/ling3vl.cpp b/tools/mtmd/models/ling3vl.cpp
index fb9eb7c74..c3b8ed968 100644
--- a/tools/mtmd/models/ling3vl.cpp
+++ b/tools/mtmd/models/ling3vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_ling3vl::build() {
+ggml_cgraph * mtmd_graph_ling3vl::build() {
     // same vision tower as qwen3vl, but the merger is norm-only (no fc1/fc2) and
     // the projector MLP lives at the top level (mm.0 / mm.2)
     GGML_ASSERT(model.class_embedding == nullptr);
@@ -57,7 +57,7 @@ ggml_cgraph * clip_graph_ling3vl::build() {

     ggml_tensor * inpL = build_vit(
         inp, n_pos, norm_t, hparams.ffn_op, learned_pos_embd,
-        [&](ggml_tensor * c, const clip_layer &) {
+        [&](ggml_tensor * c, const mtmd_layer &) {
             return ggml_rope_multi(
                 ctx0, c, positions, nullptr,
                 d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION, 32768, 10000, 1, 0, 1, 32, 1);
diff --git a/tools/mtmd/models/llama4.cpp b/tools/mtmd/models/llama4.cpp
index 01af54bba..60bfd5e2e 100644
--- a/tools/mtmd/models/llama4.cpp
+++ b/tools/mtmd/models/llama4.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_llama4::build() {
+ggml_cgraph * mtmd_graph_llama4::build() {
     GGML_ASSERT(model.class_embedding != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);

@@ -31,7 +31,7 @@ ggml_cgraph * clip_graph_llama4::build() {
     inp = ggml_concat(ctx0, inp, model.class_embedding, 1);

     // build ViT with 2D position embeddings
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         // first half is X axis and second half is Y axis
         // ref: https://github.com/huggingface/transformers/blob/40a493c7ed4f19f08eadb0639cf26d49bfa5e180/src/transformers/models/llama4/modeling_llama4.py#L1312
         // ref: https://github.com/Blaizzy/mlx-vlm/blob/a57156aa87b33cca6e5ee6cfc14dd4ef8f611be6/mlx_vlm/models/llama4/vision.py#L441
diff --git a/tools/mtmd/models/llava.cpp b/tools/mtmd/models/llava.cpp
index 47efe68bd..f4f31ffdb 100644
--- a/tools/mtmd/models/llava.cpp
+++ b/tools/mtmd/models/llava.cpp
@@ -2,7 +2,7 @@

 // this graph is used by llava, granite and glm
 // due to having embedding_stack (used by granite), we cannot reuse build_vit
-ggml_cgraph * clip_graph_llava::build() {
+ggml_cgraph * mtmd_graph_llava::build() {
     const int batch_size = 1;
     const int n_pos = n_patches + (model.class_embedding ? 1 : 0);

diff --git a/tools/mtmd/models/magevl.cpp b/tools/mtmd/models/magevl.cpp
index f5370ea9f..393f19aaf 100644
--- a/tools/mtmd/models/magevl.cpp
+++ b/tools/mtmd/models/magevl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_magevl::build() {
+ggml_cgraph * mtmd_graph_magevl::build() {
     GGML_ASSERT(hparams.n_merge == 2);

     const int n_pair = d_head / 2;
@@ -14,7 +14,7 @@ ggml_cgraph * clip_graph_magevl::build() {
     ggml_set_input(positions);

     // a pair uses 2 angles: even outputs come from one rope, odd outputs from the other
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         ggml_tensor * freqs_a = ggml_view_1d(ctx0, model.rope_freqs, n_pair, 0);
         ggml_tensor * freqs_b = ggml_view_1d(ctx0, model.rope_freqs, n_pair, ggml_row_size(model.rope_freqs->type, n_pair));

diff --git a/tools/mtmd/models/mimo-audio.cpp b/tools/mtmd/models/mimo-audio.cpp
index 0415bb60d..92be15fc9 100644
--- a/tools/mtmd/models/mimo-audio.cpp
+++ b/tools/mtmd/models/mimo-audio.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_mimo_audio::build() {
+ggml_cgraph * mtmd_graph_mimo_audio::build() {
     ggml_tensor * inp = build_inp_raw(1); // [n_frames, n_mel, 1]

     ggml_tensor * cur = ggml_conv_1d_ph(ctx0, model.conv1d_1_w, inp, 1, 1);
@@ -43,7 +43,7 @@ ggml_cgraph * clip_graph_mimo_audio::build() {
         }
     };

-    auto add_pos = [&](ggml_tensor * x, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * x, const mtmd_layer &) {
         return ggml_rope_ext(ctx0, x, inp_pos, nullptr, d_head,
                              GGML_ROPE_TYPE_NEOX, 0, hparams.rope_theta, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
     };
diff --git a/tools/mtmd/models/mimovl.cpp b/tools/mtmd/models/mimovl.cpp
index 4843698e6..6f2b8934f 100644
--- a/tools/mtmd/models/mimovl.cpp
+++ b/tools/mtmd/models/mimovl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_tensor * clip_graph_mimovl::build_mm(ggml_tensor * w, ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_mimovl::build_mm(ggml_tensor * w, ggml_tensor * x) const {
     ggml_tensor * cur = ggml_mul_mat(ctx0, w, x);
     ggml_prec_set_acc(cur, GGML_PREC_BF16);
     return cur;
@@ -19,7 +19,7 @@ ggml_tensor * clip_graph_mimovl::build_mm(ggml_tensor * w, ggml_tensor * x) cons
 //   4. 1D banded sliding window (|q-k| > window_size -> -inf) as a
 //      single 2D mask broadcast across heads.
 //   5. Per-block MLP biases.
-ggml_cgraph * clip_graph_mimovl::build() {
+ggml_cgraph * mtmd_graph_mimovl::build() {
     GGML_ASSERT(model.patch_embeddings_0 != nullptr);
     GGML_ASSERT(model.patch_embeddings_1 != nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);
diff --git a/tools/mtmd/models/minicpmv.cpp b/tools/mtmd/models/minicpmv.cpp
index 0e0fc0381..08ede0dd1 100644
--- a/tools/mtmd/models/minicpmv.cpp
+++ b/tools/mtmd/models/minicpmv.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_minicpmv::build() {
+ggml_cgraph * mtmd_graph_minicpmv::build() {
     GGML_ASSERT(model.class_embedding == nullptr);
     const int n_pos       = n_patches;
     const int n_embd_proj = n_mmproj_embd;
@@ -113,7 +113,7 @@ ggml_cgraph * clip_graph_minicpmv::build() {
     return gf;
 }

-ggml_cgraph * clip_graph_minicpmv4_6::build() {
+ggml_cgraph * mtmd_graph_minicpmv4_6::build() {
     const bool is_4x = hparams.n_merge == 2;
     const int n_pos  = n_patches;
     const int half_h = n_patches_y / 2;
diff --git a/tools/mtmd/models/minimax-m3.cpp b/tools/mtmd/models/minimax-m3.cpp
index 256e53105..2ac437bf1 100644
--- a/tools/mtmd/models/minimax-m3.cpp
+++ b/tools/mtmd/models/minimax-m3.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_tensor * clip_graph_minimax_m3::apply_rope(
+ggml_tensor * mtmd_graph_minimax_m3::apply_rope(
         ggml_tensor * x, ggml_tensor * pos_h, ggml_tensor * pos_w) {
     const int dh  = (int) x->ne[0];
     const int axd = 2 * ((2 * (dh / 2) / 3) / 2);
@@ -20,7 +20,7 @@ ggml_tensor * clip_graph_minimax_m3::apply_rope(
     return x;
 }

-ggml_cgraph * clip_graph_minimax_m3::build() {
+ggml_cgraph * mtmd_graph_minimax_m3::build() {
     GGML_ASSERT(model.patch_bias     == nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);
     GGML_ASSERT(model.patch_embeddings_0 && model.patch_embeddings_1);
@@ -54,7 +54,7 @@ ggml_cgraph * clip_graph_minimax_m3::build() {

     ggml_tensor * inpL = build_vit(
         inp, n_pos, NORM_TYPE_NORMAL, FFN_GELU_ERF, nullptr,
-        [&](ggml_tensor * c, const clip_layer &) {
+        [&](ggml_tensor * c, const mtmd_layer &) {
             return apply_rope(c, pos_h, pos_w);
         });

diff --git a/tools/mtmd/models/mobilenetv5.cpp b/tools/mtmd/models/mobilenetv5.cpp
index 1c42218d2..e127cb31e 100644
--- a/tools/mtmd/models/mobilenetv5.cpp
+++ b/tools/mtmd/models/mobilenetv5.cpp
@@ -2,7 +2,7 @@

 // Helpers for MobileNetV5 Blocks
 // RMS Norm 2D - normalizes over channels for each spatial position
-ggml_tensor * clip_graph_mobilenetv5::rms_norm_2d(ggml_tensor * inp, ggml_tensor * weight, float eps) {
+ggml_tensor * mtmd_graph_mobilenetv5::rms_norm_2d(ggml_tensor * inp, ggml_tensor * weight, float eps) {
     // inp: [W, H, C, B]

     ggml_tensor * cur = ggml_permute(ctx0, inp, 2, 1, 0, 3);
@@ -20,7 +20,7 @@ ggml_tensor * clip_graph_mobilenetv5::rms_norm_2d(ggml_tensor * inp, ggml_tensor
 }

 // Conv2dSame padding - asymmetric SAME padding like PyTorch/TF
-ggml_tensor* clip_graph_mobilenetv5::pad_same_2d(ggml_tensor* inp, int kernel_h, int kernel_w, int stride_h, int stride_w, int dilation_h, int dilation_w) {
+ggml_tensor* mtmd_graph_mobilenetv5::pad_same_2d(ggml_tensor* inp, int kernel_h, int kernel_w, int stride_h, int stride_w, int dilation_h, int dilation_w) {
     const int64_t ih = inp->ne[1];  // height
     const int64_t iw = inp->ne[0];  // width

@@ -54,7 +54,7 @@ ggml_tensor* clip_graph_mobilenetv5::pad_same_2d(ggml_tensor* inp, int kernel_h,


 // Edge Residual Block (Stage 0)
-ggml_tensor * clip_graph_mobilenetv5::build_edge_residual(ggml_tensor * inp, const mobilenetv5_block & block, int stride) {
+ggml_tensor * mtmd_graph_mobilenetv5::build_edge_residual(ggml_tensor * inp, const mobilenetv5_block & block, int stride) {
     ggml_tensor * cur = inp;

     // 1. Expansion Conv (3x3)
@@ -88,7 +88,7 @@ ggml_tensor * clip_graph_mobilenetv5::build_edge_residual(ggml_tensor * inp, con
 }

 // Universal Inverted Residual Block (Stage 1+)
-ggml_tensor * clip_graph_mobilenetv5::build_inverted_residual(ggml_tensor * inp, const mobilenetv5_block & block, int stride) {
+ggml_tensor * mtmd_graph_mobilenetv5::build_inverted_residual(ggml_tensor * inp, const mobilenetv5_block & block, int stride) {
     ggml_tensor * cur = inp;

     // 1. Depthwise Start (Optional)
@@ -149,7 +149,7 @@ ggml_tensor * clip_graph_mobilenetv5::build_inverted_residual(ggml_tensor * inp,
 }

 // Attention Block (MQA)
-ggml_tensor * clip_graph_mobilenetv5::build_mobilenet_attn(ggml_tensor * inp, const mobilenetv5_block & block) {
+ggml_tensor * mtmd_graph_mobilenetv5::build_mobilenet_attn(ggml_tensor * inp, const mobilenetv5_block & block) {
     ggml_tensor * cur = inp;

     // Norm
@@ -245,7 +245,7 @@ ggml_tensor * clip_graph_mobilenetv5::build_mobilenet_attn(ggml_tensor * inp, co
     return cur;
 }

-ggml_cgraph * clip_graph_mobilenetv5::build() {
+ggml_cgraph * mtmd_graph_mobilenetv5::build() {
     ggml_tensor * inp = build_inp_raw();

     // 1. Stem - Conv2dSame(3, 64, kernel_size=(3, 3), stride=(2, 2))
diff --git a/tools/mtmd/models/models.h b/tools/mtmd/models/models.h
index 8487a0042..17bd57d95 100644
--- a/tools/mtmd/models/models.h
+++ b/tools/mtmd/models/models.h
@@ -1,6 +1,6 @@
 #pragma once

-#include "../clip-graph.h"
+#include "../mtmd-graph.h"

 #include <functional>
 #include <map>
@@ -13,32 +13,32 @@
  * We encourage human contributors to ensure the quality and reliability of the codebase.
  */

-struct clip_graph_siglip : clip_graph {
-    clip_graph_siglip(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_siglip : mtmd_graph {
+    mtmd_graph_siglip(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_gemma4v : clip_graph {
-    clip_graph_gemma4v(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_gemma4v : mtmd_graph {
+    mtmd_graph_gemma4v(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     ggml_tensor * build_mm(ggml_tensor * w, ggml_tensor * x) const override;
     bool support_batch() const override { return true; }
 };

-struct clip_graph_gemma4uv : clip_graph {
-    clip_graph_gemma4uv(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_gemma4uv : mtmd_graph {
+    mtmd_graph_gemma4uv(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_nemotron_transcribe : clip_graph {
-    const clip_enc_state * state; // nullptr during warmup
-    clip_graph_nemotron_transcribe(clip_ctx * ctx, const clip_image_f32 & img, const clip_enc_state * state)
-        : clip_graph(ctx, img), state(state) {}
+struct mtmd_graph_nemotron_transcribe : mtmd_graph {
+    const mtmd_subctx_enc_state * state; // nullptr during warmup
+    mtmd_graph_nemotron_transcribe(mtmd_subcontext * ctx, const mtmd_image_f32 & img, const mtmd_subctx_enc_state * state)
+        : mtmd_graph(ctx, img), state(state) {}
     ggml_cgraph * build() override;
     ggml_tensor * build_spk_encoder(ggml_tensor * cur, ggml_tensor * pos);

     // one recording: encodes its windows in turn, carrying the speaker state between them
-    static bool encode_windows(clip_ctx * ctx, const clip_model & model, clip_encode_params * params);
+    static bool encode_windows(mtmd_subcontext * ctx, const mtmd_model & model, mtmd_subctx_encode_params * params);

     // NeMo ParallelExpertEncoder online defaults, in output frames: 40 s windows with 4 s of context on each side
     static constexpr int window_core  = 500;
@@ -46,54 +46,54 @@ struct clip_graph_nemotron_transcribe : clip_graph {
     static constexpr int window_right = 50;
 };

-struct clip_graph_pixtral : clip_graph {
-    clip_graph_pixtral(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_pixtral : mtmd_graph {
+    mtmd_graph_pixtral(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_deepseek4v : clip_graph {
-    clip_graph_deepseek4v(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_deepseek4v : mtmd_graph {
+    mtmd_graph_deepseek4v(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_qwen2vl : clip_graph {
-    clip_graph_qwen2vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_qwen2vl : mtmd_graph {
+    mtmd_graph_qwen2vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_qwen3vl : clip_graph_qwen2vl {
-    clip_graph_qwen3vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph_qwen2vl(ctx, img) {}
+struct mtmd_graph_qwen3vl : mtmd_graph_qwen2vl {
+    mtmd_graph_qwen3vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph_qwen2vl(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_magevl : clip_graph {
-    clip_graph_magevl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_magevl : mtmd_graph {
+    mtmd_graph_magevl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_sensenova_u1 : clip_graph {
-    clip_graph_sensenova_u1(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_sensenova_u1 : mtmd_graph {
+    mtmd_graph_sensenova_u1(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_lfm3vl : clip_graph {
-    clip_graph_lfm3vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_lfm3vl : mtmd_graph {
+    mtmd_graph_lfm3vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_ling3vl : clip_graph_qwen3vl {
-    clip_graph_ling3vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph_qwen3vl(ctx, img) {}
+struct mtmd_graph_ling3vl : mtmd_graph_qwen3vl {
+    mtmd_graph_ling3vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph_qwen3vl(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_minimax_m3 : clip_graph {
-    clip_graph_minimax_m3(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_minimax_m3 : mtmd_graph {
+    mtmd_graph_minimax_m3(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     ggml_tensor * apply_rope(ggml_tensor * x, ggml_tensor * pos_h, ggml_tensor * pos_w);
 };

-struct clip_graph_mimovl : clip_graph {
-    clip_graph_mimovl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_mimovl : mtmd_graph {
+    mtmd_graph_mimovl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     // Force F32 mat-mul accumulation to avoid F16 overflow in the FFN down-proj
     // when the mmproj is stored in F16 (the source weights are BF16; downcasting
@@ -101,119 +101,119 @@ struct clip_graph_mimovl : clip_graph {
     ggml_tensor * build_mm(ggml_tensor * w, ggml_tensor * x) const override;
 };

-struct clip_graph_step3vl : clip_graph {
-    clip_graph_step3vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_step3vl : mtmd_graph {
+    mtmd_graph_step3vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_youtuvl : clip_graph {
-    clip_graph_youtuvl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_youtuvl : mtmd_graph {
+    mtmd_graph_youtuvl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_yasa2 : clip_graph {
-    clip_graph_yasa2(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_yasa2 : mtmd_graph {
+    mtmd_graph_yasa2(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;

     ggml_tensor * layer_norm_channels(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b, float eps = 1e-6f);
     ggml_tensor * convnext_grn(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b);
 };

-struct clip_graph_minicpmv : clip_graph {
-    clip_graph_minicpmv(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_minicpmv : mtmd_graph {
+    mtmd_graph_minicpmv(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_minicpmv4_6 : clip_graph {
-    clip_graph_minicpmv4_6(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_minicpmv4_6 : mtmd_graph {
+    mtmd_graph_minicpmv4_6(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_internvl : clip_graph {
-    clip_graph_internvl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_internvl : mtmd_graph {
+    mtmd_graph_internvl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     bool support_batch() const override { return true; }
 };

-struct clip_graph_nemotron_v2_vl : clip_graph {
-    clip_graph_nemotron_v2_vl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_nemotron_v2_vl : mtmd_graph {
+    mtmd_graph_nemotron_v2_vl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_nemotron_omni : clip_graph {
-    clip_graph_nemotron_omni(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_nemotron_omni : mtmd_graph {
+    mtmd_graph_nemotron_omni(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     ggml_tensor * build_inp_with_temporal_merge();
 };

-struct clip_graph_llama4 : clip_graph {
-    clip_graph_llama4(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_llama4 : mtmd_graph {
+    mtmd_graph_llama4(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_kimivl : clip_graph {
-    clip_graph_kimivl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_kimivl : mtmd_graph {
+    mtmd_graph_kimivl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_paddleocr : clip_graph {
-    clip_graph_paddleocr(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_paddleocr : mtmd_graph {
+    mtmd_graph_paddleocr(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_dotsocr : clip_graph {
-    clip_graph_dotsocr(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_dotsocr : mtmd_graph {
+    mtmd_graph_dotsocr(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_dots3note_a : clip_graph {
-    clip_graph_dots3note_a(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_dots3note_a : mtmd_graph {
+    mtmd_graph_dots3note_a(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_cogvlm : clip_graph {
-    clip_graph_cogvlm(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_cogvlm : mtmd_graph {
+    mtmd_graph_cogvlm(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_llava : clip_graph {
-    clip_graph_llava(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_llava : mtmd_graph {
+    mtmd_graph_llava(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_whisper_enc : clip_graph {
-    clip_graph_whisper_enc(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_whisper_enc : mtmd_graph {
+    mtmd_graph_whisper_enc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

 // causal whisper-like encoder with a sliding window KV cache (voxtral realtime)
-struct clip_graph_voxtral_rt : clip_graph {
-    clip_enc_state * state; // nullptr during warmup, the cache is then a graph input
-    clip_graph_voxtral_rt(clip_ctx * ctx, const clip_image_f32 & img, clip_enc_state * state) : clip_graph(ctx, img), state(state) {}
+struct mtmd_graph_voxtral_rt : mtmd_graph {
+    mtmd_subctx_enc_state * state; // nullptr during warmup, the cache is then a graph input
+    mtmd_graph_voxtral_rt(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_enc_state * state) : mtmd_graph(ctx, img), state(state) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_deepseekocr : clip_graph {
-    clip_graph_deepseekocr(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_deepseekocr : mtmd_graph {
+    mtmd_graph_deepseekocr(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     ggml_tensor * build_sam(ggml_tensor * inp); // build the SAM model
     bool support_batch() const override { return true; }
 };

-struct clip_graph_deepseekocr2 : clip_graph_deepseekocr {
-    clip_graph_deepseekocr2(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph_deepseekocr(ctx, img) {}
+struct mtmd_graph_deepseekocr2 : mtmd_graph_deepseekocr {
+    mtmd_graph_deepseekocr2(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph_deepseekocr(ctx, img) {}
     ggml_cgraph * build() override; // reuses build_sam() from base
     bool support_batch() const override { return true; }
 };

-struct clip_graph_conformer : clip_graph {
-    clip_graph_conformer(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_conformer : mtmd_graph {
+    mtmd_graph_conformer(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

 // builders shared by the chatterbox gen and spkenc graphs, defined in chatterbox-gen.cpp
-struct clip_graph_chatterbox_base : clip_graph {
-    using clip_graph::clip_graph;
+struct mtmd_graph_chatterbox_base : mtmd_graph {
+    using mtmd_graph::mtmd_graph;

   protected:
     ggml_tensor * cbx_linear(ggml_tensor * w, ggml_tensor * b, ggml_tensor * x) const;
@@ -223,79 +223,79 @@ struct clip_graph_chatterbox_base : clip_graph {
                                  int pad, int dil) const;
 };

-struct clip_graph_chatterbox_spkenc : clip_graph_chatterbox_base {
-    clip_graph_chatterbox_spkenc(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph_chatterbox_base(ctx, img) {}
+struct mtmd_graph_chatterbox_spkenc : mtmd_graph_chatterbox_base {
+    mtmd_graph_chatterbox_spkenc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph_chatterbox_base(ctx, img) {}
     ggml_cgraph * build() override;

   private:
-    ggml_tensor * bn1d(const clip_chatterbox::bn & n, ggml_tensor * x);
-    ggml_tensor * bn2d_relu(const clip_chatterbox::bn & n, ggml_tensor * x);
-    ggml_tensor * res2d(const clip_chatterbox::spk_res2d & r, ggml_tensor * x, int stride);
-    ggml_tensor * cam_layer(const clip_chatterbox::spk_cam_layer & l, ggml_tensor * x, int dil, ggml_tensor * segfix);
+    ggml_tensor * bn1d(const mtmd_chatterbox::bn & n, ggml_tensor * x);
+    ggml_tensor * bn2d_relu(const mtmd_chatterbox::bn & n, ggml_tensor * x);
+    ggml_tensor * res2d(const mtmd_chatterbox::spk_res2d & r, ggml_tensor * x, int stride);
+    ggml_tensor * cam_layer(const mtmd_chatterbox::spk_cam_layer & l, ggml_tensor * x, int dil, ggml_tensor * segfix);
 };

-struct clip_graph_chatterbox : clip_graph_chatterbox_base {
-    clip_gen_process_type gen_process = CLIP_GEN_PROCESS_GEN_CODE;
+struct mtmd_graph_chatterbox : mtmd_graph_chatterbox_base {
+    mtmd_subctx_gen_process gen_process = MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
     int n_tokens = 0;
     int n_prompt_mel  = 0;
     int vocode_n_mel  = 0;
     int vocode_n_stft = 0;
-    clip_graph_chatterbox(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, int n_tokens,
+    mtmd_graph_chatterbox(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, int n_tokens,
                           int n_prompt_mel, int vocode_n_mel, int vocode_n_stft)
-        : clip_graph_chatterbox_base(ctx, img), gen_process(gen_process), n_tokens(n_tokens), n_prompt_mel(n_prompt_mel),
+        : mtmd_graph_chatterbox_base(ctx, img), gen_process(gen_process), n_tokens(n_tokens), n_prompt_mel(n_prompt_mel),
           vocode_n_mel(vocode_n_mel), vocode_n_stft(vocode_n_stft) {}
     ggml_cgraph * build() override;

   private:
-    ggml_tensor * enc_layer(const clip_chatterbox::enc_layer & l, ggml_tensor * x, ggml_tensor * pos, int T);
-    ggml_tensor * causal_block(const clip_chatterbox::causal_block & b, ggml_tensor * x);
-    ggml_tensor * resnet(const clip_chatterbox::resnet & r, ggml_tensor * x, ggml_tensor * temb);
-    ggml_tensor * tfm_block(const clip_chatterbox::tfm_block & b, ggml_tensor * x);
+    ggml_tensor * enc_layer(const mtmd_chatterbox::enc_layer & l, ggml_tensor * x, ggml_tensor * pos, int T);
+    ggml_tensor * causal_block(const mtmd_chatterbox::causal_block & b, ggml_tensor * x);
+    ggml_tensor * resnet(const mtmd_chatterbox::resnet & r, ggml_tensor * x, ggml_tensor * temb);
+    ggml_tensor * tfm_block(const mtmd_chatterbox::tfm_block & b, ggml_tensor * x);
     ggml_tensor * estimator(ggml_tensor * x_noise, ggml_tensor * mu, ggml_tensor * spks,
                             ggml_tensor * cond, ggml_tensor * temb, int T);
-    ggml_tensor * hift_resblock(const clip_chatterbox::hift_res & r, ggml_tensor * x);
+    ggml_tensor * hift_resblock(const mtmd_chatterbox::hift_res & r, ggml_tensor * x);
     ggml_cgraph * build_s3tok(int T);
     ggml_cgraph * build_vocoder(int n_mel, int n_stft);
 };

-struct clip_graph_lfm3a : clip_graph {
-    clip_graph_lfm3a(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_lfm3a : mtmd_graph {
+    mtmd_graph_lfm3a(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_granite_speech : clip_graph {
-    clip_graph_granite_speech(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_granite_speech : mtmd_graph {
+    mtmd_graph_granite_speech(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_muse_spark_audio : clip_graph {
-    clip_graph_muse_spark_audio(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_muse_spark_audio : mtmd_graph {
+    mtmd_graph_muse_spark_audio(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_gemma4a : clip_graph {
-    clip_graph_gemma4a(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_gemma4a : mtmd_graph {
+    mtmd_graph_gemma4a(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
     ggml_tensor * build_mm(ggml_tensor * w, ggml_tensor * x) const override;
 };

-struct clip_graph_gemma4ua : clip_graph {
-    clip_graph_gemma4ua(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_gemma4ua : mtmd_graph {
+    mtmd_graph_gemma4ua(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_glm4v : clip_graph {
-    clip_graph_glm4v(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_glm4v : mtmd_graph {
+    mtmd_graph_glm4v(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_hunyuanvl : clip_graph {
-    clip_graph_hunyuanvl(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_hunyuanvl : mtmd_graph {
+    mtmd_graph_hunyuanvl(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_mobilenetv5 : clip_graph {
-    clip_graph_mobilenetv5(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_mobilenetv5 : mtmd_graph {
+    mtmd_graph_mobilenetv5(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;

     ggml_tensor * rms_norm_2d(
@@ -327,34 +327,34 @@ struct clip_graph_mobilenetv5 : clip_graph {
         const mobilenetv5_block & block);
 };

-struct clip_graph_qwen3a : clip_graph {
-    clip_graph_qwen3a(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_qwen3a : mtmd_graph {
+    mtmd_graph_qwen3a(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_mimo_audio : clip_graph {
-    clip_graph_mimo_audio(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_mimo_audio : mtmd_graph {
+    mtmd_graph_mimo_audio(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_qwen3tts_spkenc : clip_graph {
-    clip_graph_qwen3tts_spkenc(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_qwen3tts_spkenc : mtmd_graph {
+    mtmd_graph_qwen3tts_spkenc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;

     ggml_tensor * conv1d_same(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int dilation) const;
-    ggml_tensor * res2net(ggml_tensor * x, const clip_layer & layer, int dilation, int scale) const;
-    ggml_tensor * se_block(ggml_tensor * x, const clip_layer & layer) const;
-    ggml_tensor * se_res2net_block(ggml_tensor * x, const clip_layer & layer, int dilation, int scale) const;
+    ggml_tensor * res2net(ggml_tensor * x, const mtmd_layer & layer, int dilation, int scale) const;
+    ggml_tensor * se_block(ggml_tensor * x, const mtmd_layer & layer) const;
+    ggml_tensor * se_res2net_block(ggml_tensor * x, const mtmd_layer & layer, int dilation, int scale) const;
     ggml_tensor * attentive_stats_pool(ggml_tensor * x) const;
 };

-struct clip_graph_qwen3tts_gen : clip_graph {
-    clip_graph_qwen3tts_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, int top_k, float top_p)
-        : clip_graph(ctx, img), gen_process(gen_process), top_k(top_k), top_p(top_p) {}
+struct mtmd_graph_qwen3tts_gen : mtmd_graph {
+    mtmd_graph_qwen3tts_gen(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, int top_k, float top_p)
+        : mtmd_graph(ctx, img), gen_process(gen_process), top_k(top_k), top_p(top_p) {}
     ggml_cgraph * build() override;

     // which sub-graph build() constructs, fixed at graph-build time
-    clip_gen_process_type gen_process;
+    mtmd_subctx_gen_process gen_process;

     // sampling params, fixed at graph-build time (GEN_CODE only)
     int   top_k;
@@ -364,9 +364,9 @@ struct clip_graph_qwen3tts_gen : clip_graph {
     // code_gen: backbone hidden state + sampled code0 -> 16 RVQ codes
     // MTP-style code predictor, one token per codebook
     //
-    struct code_gen : clip_graph {
-        code_gen(const clip_graph & parent, int top_k, float top_p)
-            : clip_graph(parent), top_k(top_k), top_p(top_p) {}
+    struct code_gen : mtmd_graph {
+        code_gen(const mtmd_graph & parent, int top_k, float top_p)
+            : mtmd_graph(parent), top_k(top_k), top_p(top_p) {}
         ggml_cgraph * build() override { GGML_ABORT("call prefill()/step() instead"); }

         int   top_k;
@@ -381,7 +381,7 @@ struct clip_graph_qwen3tts_gen : clip_graph {

         ggml_tensor * layer_forward(
                 ggml_tensor * cur,
-                const clip_layer & layer,
+                const mtmd_layer & layer,
                 ggml_tensor * inp_pos,
                 ggml_tensor * kq_mask,
                 ggml_tensor *& k_cache_layer,
@@ -409,8 +409,8 @@ struct clip_graph_qwen3tts_gen : clip_graph {
     //
     // code2wav: RVQ codes -> raw PCM (quantizer + pre_conv + pre_transformer + upsample + DAC).
     //
-    struct code2wav : clip_graph {
-        code2wav(const clip_graph & parent) : clip_graph(parent) {}
+    struct code2wav : mtmd_graph {
+        code2wav(const mtmd_graph & parent) : mtmd_graph(parent) {}
         ggml_cgraph * build() override { GGML_ABORT("call decode() instead"); }

         // state_in: previous call's persisted state, by slot name (see list_c2w_state_slots())
@@ -425,9 +425,9 @@ struct clip_graph_qwen3tts_gen : clip_graph {
         ggml_tensor * snake(ggml_tensor * x, ggml_tensor * alpha, ggml_tensor * beta) const;

         ggml_tensor * quant_decode(ggml_tensor * inp_codes) const;
-        ggml_tensor * tfm_layer_forward(ggml_tensor * cur, const clip_layer & layer, int il) const;
-        ggml_tensor * convnext_block(ggml_tensor * x, const clip_code2wav::upsample_block & blk, const std::string & state_prefix) const;
-        ggml_tensor * dac_res_unit(ggml_tensor * x, const clip_code2wav::dac_res & res, int dilation, const std::string & state_name) const;
+        ggml_tensor * tfm_layer_forward(ggml_tensor * cur, const mtmd_layer & layer, int il) const;
+        ggml_tensor * convnext_block(ggml_tensor * x, const mtmd_code2wav::upsample_block & blk, const std::string & state_prefix) const;
+        ggml_tensor * dac_res_unit(ggml_tensor * x, const mtmd_code2wav::dac_res & res, int dilation, const std::string & state_name) const;

         // drama3: erf GELU in ConvNeXt, not the tanh approximation
         bool gelu_erf = false;
@@ -441,8 +441,8 @@ struct clip_graph_qwen3tts_gen : clip_graph {
 // pocket-tts: SEANet convolution stack, shared by the voice encoder and the mimi decoder.
 // stateless unless state_in is populated: convs then pad instead of carrying left-context.
 //
-struct clip_graph_pockettts_seanet : clip_graph {
-    clip_graph_pockettts_seanet(const clip_graph & parent) : clip_graph(parent) {}
+struct mtmd_graph_pockettts_seanet : mtmd_graph {
+    mtmd_graph_pockettts_seanet(const mtmd_graph & parent) : mtmd_graph(parent) {}
     ggml_cgraph * build() override { GGML_ABORT("call encode()/decode() instead"); }

     // per-call streaming state, keyed by slot name (see list_pockettts_state_slots)
@@ -453,7 +453,7 @@ struct clip_graph_pockettts_seanet : clip_graph {
                          bool pad_replicate = false, const std::string & state_name = "") const;
     ggml_tensor * conv_transpose1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride,
                                    const std::string & state_name = "") const;
-    ggml_tensor * res_unit(ggml_tensor * x, const clip_seanet::stage & stage, int dilation,
+    ggml_tensor * res_unit(ggml_tensor * x, const mtmd_seanet::stage & stage, int dilation,
                            const std::string & state_prefix = "") const;

     // x: [T, C] -> [T / hop, dim]
@@ -463,11 +463,11 @@ struct clip_graph_pockettts_seanet : clip_graph {
 };

 // mimi encoder + speaker_proj: reference waveform -> voice conditioning rows
-struct clip_graph_pockettts_spkenc : clip_graph {
-    clip_graph_pockettts_spkenc(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_pockettts_spkenc : mtmd_graph {
+    mtmd_graph_pockettts_spkenc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;

-    ggml_tensor * tfm_layer_forward(ggml_tensor * cur, const clip_layer & layer, ggml_tensor * inp_pos, ggml_tensor * kq_mask, int il) const;
+    ggml_tensor * tfm_layer_forward(ggml_tensor * cur, const mtmd_layer & layer, ggml_tensor * inp_pos, ggml_tensor * kq_mask, int il) const;
 };

 //
@@ -475,18 +475,18 @@ struct clip_graph_pockettts_spkenc : clip_graph {
 // GEN_CODE = flow-matching decoder + end-of-speech head, one latent per call
 // GEN_WAV  = mimi decoder, a window of latents -> PCM
 //
-struct clip_graph_pockettts_gen : clip_graph {
-    clip_graph_pockettts_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, int n_step, int n_frames)
-        : clip_graph(ctx, img), gen_process(gen_process), n_step(n_step), n_frames(n_frames) {}
+struct mtmd_graph_pockettts_gen : mtmd_graph {
+    mtmd_graph_pockettts_gen(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, int n_step, int n_frames)
+        : mtmd_graph(ctx, img), gen_process(gen_process), n_step(n_step), n_frames(n_frames) {}
     ggml_cgraph * build() override;

-    clip_gen_process_type gen_process;
+    mtmd_subctx_gen_process gen_process;
     int n_step;   // lsd_decode steps, fixed at graph-build time
     int n_frames; // GEN_WAV only: number of latents to decode

     // AdaLN modulation: x * (1 + scale) + shift
     ggml_tensor * modulate(ggml_tensor * x, ggml_tensor * shift, ggml_tensor * scale) const;
-    ggml_tensor * time_embed(const clip_flow_net::time_embd & te, float t) const;
+    ggml_tensor * time_embed(const mtmd_flow_net::time_embd & te, float t) const;
     ggml_tensor * flow_forward(ggml_tensor * cond, ggml_tensor * x, float s, float t) const;
 };

@@ -497,30 +497,30 @@ struct c2w_state_slot {
     int64_t     ne1;
     bool        in_place = false; // graph writes the slot directly, no state_in/state_out copy
 };
-std::vector<c2w_state_slot> list_c2w_state_slots(const clip_hparams & hparams, const clip_model & model);
+std::vector<c2w_state_slot> list_c2w_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);

 // drama3: tower KV slots, then list_c2w_state_slots()
-std::vector<c2w_state_slot> list_drama3_state_slots(const clip_hparams & hparams, const clip_model & model);
+std::vector<c2w_state_slot> list_drama3_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);

 // same, for the streaming mimi decoder (pocket-tts GEN_WAV)
-std::vector<c2w_state_slot> list_pockettts_state_slots(const clip_hparams & hparams, const clip_model & model);
+std::vector<c2w_state_slot> list_pockettts_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);

 //
 // drama3 generation:
 // GEN_CODE = one codebook tower row, each layer attends over the whole sequence (KV in gen state)
 // GEN_WAV  = Fish DAC decoder, frames -> PCM
 //
-struct clip_graph_drama3_gen : clip_graph {
-    clip_graph_drama3_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, clip_gen_state * gen_state,
+struct mtmd_graph_drama3_gen : mtmd_graph {
+    mtmd_graph_drama3_gen(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, mtmd_subctx_gen_state * gen_state,
                           int n_frames, int top_k, float top_p, float temp)
-        : clip_graph(ctx, img), gen_process(gen_process), gen_state(gen_state), n_frames(n_frames), top_k(top_k), top_p(top_p), temp(temp) {}
+        : mtmd_graph(ctx, img), gen_process(gen_process), gen_state(gen_state), n_frames(n_frames), top_k(top_k), top_p(top_p), temp(temp) {}
     ggml_cgraph * build() override;

     // tower KV rows, shared by the prompt and the generated frames
     static constexpr int n_pos_max = 4096;

-    clip_gen_process_type gen_process;
-    clip_gen_state * gen_state; // nullptr on buffer reserve
+    mtmd_subctx_gen_process gen_process;
+    mtmd_subctx_gen_state * gen_state; // nullptr on buffer reserve
     int   n_frames;             // GEN_WAV only
     int   top_k;
     float top_p;
@@ -532,10 +532,10 @@ struct clip_graph_drama3_gen : clip_graph {
                               ggml_tensor * inp_pos, ggml_tensor * k_slot, ggml_tensor * v_slot, int pos);
 };

-struct clip_graph_soprano : clip_graph {
-    clip_graph_soprano(clip_ctx * ctx, const clip_image_f32 & img, int n_frames) : clip_graph(ctx, img), n_frames(n_frames) {}
+struct mtmd_graph_soprano : mtmd_graph {
+    mtmd_graph_soprano(mtmd_subcontext * ctx, const mtmd_image_f32 & img, int n_frames) : mtmd_graph(ctx, img), n_frames(n_frames) {}
     ggml_cgraph * build() override;
-    static void decode_spectrum(const clip_model & model, const std::vector<float> & spectrum, std::vector<float> & pcm);
+    static void decode_spectrum(const mtmd_model & model, const std::vector<float> & spectrum, std::vector<float> & pcm);
     // SopranoDecoder default
     static constexpr int upscale = 4;
     int n_frames;
@@ -546,54 +546,54 @@ struct clip_graph_soprano : clip_graph {
 // GEN_CODE = one MaskGIT pass of the depthformer, or only the feedback embedding once all codes are known
 // GEN_WAV  = liquid-codec decoder, a window of frames -> polar STFT frames
 //
-struct clip_graph_lfm3a_gen : clip_graph {
-    clip_graph_lfm3a_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, bool all_known, int n_frames)
-        : clip_graph(ctx, img), gen_process(gen_process), all_known(all_known), n_frames(n_frames) {}
+struct mtmd_graph_lfm3a_gen : mtmd_graph {
+    mtmd_graph_lfm3a_gen(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, bool all_known, int n_frames)
+        : mtmd_graph(ctx, img), gen_process(gen_process), all_known(all_known), n_frames(n_frames) {}
     ggml_cgraph * build() override;

-    clip_gen_process_type gen_process;
+    mtmd_subctx_gen_process gen_process;
     bool all_known; // GEN_CODE only
     int  n_frames;  // GEN_WAV only

     ggml_tensor * build_gen_code();
     ggml_tensor * build_gen_wav();

-    ggml_tensor * build_lfm_layer(ggml_tensor * inpL, const clip_layer & layer, float eps, int il,
+    ggml_tensor * build_lfm_layer(ggml_tensor * inpL, const mtmd_layer & layer, float eps, int il,
                                   const std::function<ggml_tensor *(ggml_tensor *)> & op);
-    ggml_tensor * build_lfm_attn(ggml_tensor * cur, const clip_layer & layer, int n_head, int n_head_kv,
+    ggml_tensor * build_lfm_attn(ggml_tensor * cur, const mtmd_layer & layer, int n_head, int n_head_kv,
                                  float eps, float rope_theta, int il, ggml_tensor * inp_pos,
                                  ggml_tensor * kq_mask, ggml_tensor * k_cache, ggml_tensor * v_cache,
                                  ggml_tensor ** k_all, ggml_tensor ** v_all);
-    ggml_tensor * build_lfm_conv(ggml_tensor * cur, const clip_layer & layer, int il,
+    ggml_tensor * build_lfm_conv(ggml_tensor * cur, const mtmd_layer & layer, int il,
                                  ggml_tensor * conv_state, ggml_tensor ** conv_state_out);
 };

 // state slots of the streaming codec decoder (lfm3a_gen GEN_WAV)
-std::vector<c2w_state_slot> list_lfm3a_gen_state_slots(const clip_hparams & hparams, const clip_model & model);
+std::vector<c2w_state_slot> list_lfm3a_gen_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);

 // state slots of the streaming LFM3-Audio encoder (lfm3a)
-std::vector<c2w_state_slot> list_lfm3a_state_slots(const clip_hparams & hparams, const clip_model & model);
+std::vector<c2w_state_slot> list_lfm3a_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);

-struct clip_graph_kimik25 : clip_graph {
-    clip_graph_kimik25(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_kimik25 : mtmd_graph {
+    mtmd_graph_kimik25(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;

     ggml_tensor * resize_position_embeddings_3d(uint32_t interpolation_mode);
 };

-struct clip_graph_parakeet : clip_graph {
-    clip_graph_parakeet(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_parakeet : mtmd_graph {
+    mtmd_graph_parakeet(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_exaone4_5 : clip_graph {
-    clip_graph_exaone4_5(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_exaone4_5 : mtmd_graph {
+    mtmd_graph_exaone4_5(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };

-struct clip_graph_granite4_vision : clip_graph {
-    clip_graph_granite4_vision(clip_ctx * ctx, const clip_image_f32 & img)
-        : clip_graph(ctx, img),
+struct mtmd_graph_granite4_vision : mtmd_graph {
+    mtmd_graph_granite4_vision(mtmd_subcontext * ctx, const mtmd_image_f32 & img)
+        : mtmd_graph(ctx, img),
           anyres(img.anyres),
           n_tiles(img.ny() / img.nx()),
           tile_side(img.nx() / patch_size) {}
@@ -602,7 +602,7 @@ struct clip_graph_granite4_vision : clip_graph {

 private:
     // the input image is a stack of tiles on the Y axis: [overview, tile(0,0), tile(0,1), ...]
-    const clip_image_f32::anyres_info anyres;
+    const mtmd_image_f32::anyres_info anyres;
     const int n_tiles;
     const int tile_side; // patches per tile side

@@ -617,7 +617,7 @@ private:
     ggml_tensor * build_anyres_assembly(ggml_tensor * cur, int out_side);
 };

-struct clip_graph_muse_glimmer : clip_graph {
-    clip_graph_muse_glimmer(clip_ctx * ctx, const clip_image_f32 & img) : clip_graph(ctx, img) {}
+struct mtmd_graph_muse_glimmer : mtmd_graph {
+    mtmd_graph_muse_glimmer(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
 };
diff --git a/tools/mtmd/models/muse-glimmer.cpp b/tools/mtmd/models/muse-glimmer.cpp
index a397c1b22..869954f99 100644
--- a/tools/mtmd/models/muse-glimmer.cpp
+++ b/tools/mtmd/models/muse-glimmer.cpp
@@ -5,13 +5,13 @@
 // adapter MLP + LLM's vision_projection.
 //
 // Several quantities are precomputed on host and fed as named graph inputs (filled in
-// clip.cpp set_input, PROJECTOR_TYPE_MUSE_GLIMMER branch):
+// mtmd-subcontext.cpp set_input, PROJECTOR_TYPE_MUSE_GLIMMER branch):
 //   muse_glimmer_pos_w/_h [n_tok] i32         : 1-indexed RoPE positions (sparse-permuted order)
 //   muse_glimmer_sp_perm  [n_tok] i32         : window grouping permutation (applied after ln_pre)
 //   muse_glimmer_inv_perm [n_tok] i32         : inverse of sp_perm (applied after blocks)
 //   muse_glimmer_ds_perm  [n_tok] i32         : pixel-shuffle gather (original order)
 //   attn_mask             [n_tok, n_tok] f32 (f16 with flash attn) : block-diagonal window mask (sparse layers)
-ggml_cgraph * clip_graph_muse_glimmer::build() {
+ggml_cgraph * mtmd_graph_muse_glimmer::build() {
     const int ds = hparams.n_merge;              // downsample factor (2)
     const int sf = hparams.muse_glimmer_sparse_factor;   // 4
     const int n_tok     = n_patches;
@@ -50,7 +50,7 @@ ggml_cgraph * clip_graph_muse_glimmer::build() {
     }

     // 2D RoPE: first half of head_dim uses width pos, second half uses height pos
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return build_rope_2d(ctx0, cur, pos_w, pos_h, rope_base, false);
     };

diff --git a/tools/mtmd/models/muse-spark-audio.cpp b/tools/mtmd/models/muse-spark-audio.cpp
index d5340d5f9..20e770ef8 100644
--- a/tools/mtmd/models/muse-spark-audio.cpp
+++ b/tools/mtmd/models/muse-spark-audio.cpp
@@ -2,7 +2,7 @@

 // Muse Spark audio tower: a causal conv pyramid over the raw waveform, no attention and no spectrogram
 // each stage folds `patch` samples into the channel dim, projects, then runs `depth` residual blocks (depthwise causal conv, RMS norm, GELU MLP)
-ggml_cgraph * clip_graph_muse_spark_audio::build() {
+ggml_cgraph * mtmd_graph_muse_spark_audio::build() {
     const int kernel = hparams.audio_conv_kernel_size;
     const int n_stage = (int) hparams.audio_patch_sizes.size();

diff --git a/tools/mtmd/models/nemotron-omni.cpp b/tools/mtmd/models/nemotron-omni.cpp
index c4b1bff40..fc0f2b2ce 100644
--- a/tools/mtmd/models/nemotron-omni.cpp
+++ b/tools/mtmd/models/nemotron-omni.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_tensor * clip_graph_nemotron_omni::build_inp_with_temporal_merge() {
+ggml_tensor * mtmd_graph_nemotron_omni::build_inp_with_temporal_merge() {
     if (n_batch == 1 && !img_batch->is_video) {
         return build_inp();
     }
@@ -22,7 +22,7 @@ ggml_tensor * clip_graph_nemotron_omni::build_inp_with_temporal_merge() {
     return ggml_cont(ctx0, ggml_transpose(ctx0, inp));
 }

-ggml_cgraph * clip_graph_nemotron_omni::build() {
+ggml_cgraph * mtmd_graph_nemotron_omni::build() {
     GGML_ASSERT(model.class_embedding != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);

diff --git a/tools/mtmd/models/nemotron-transcribe.cpp b/tools/mtmd/models/nemotron-transcribe.cpp
index 2ce20c81f..b8fe37749 100644
--- a/tools/mtmd/models/nemotron-transcribe.cpp
+++ b/tools/mtmd/models/nemotron-transcribe.cpp
@@ -27,7 +27,7 @@ static constexpr float SPK_WEAK_BOOST   = 2.0f; // _boost_topk_scores, scale_fac
 static constexpr float SPK_SPEECH_THR   = 0.5f; // _disable_low_scores: a speaker's frames at or below it are not cached

 // the speaker expert has its own layers, width and head count, which build_vit does not take; full rotary
-ggml_tensor * clip_graph_nemotron_transcribe::build_spk_encoder(ggml_tensor * cur, ggml_tensor * pos) {
+ggml_tensor * mtmd_graph_nemotron_transcribe::build_spk_encoder(ggml_tensor * cur, ggml_tensor * pos) {
     const int     n_head = hparams.spk_n_head;
     const int64_t n_embd = cur->ne[0];
     const int64_t n_pos  = cur->ne[1];
@@ -57,7 +57,7 @@ ggml_tensor * clip_graph_nemotron_transcribe::build_spk_encoder(ggml_tensor * cu
     return build_norm(cur, model.spk_post_ln_w, model.spk_post_ln_b, NORM_TYPE_NORMAL, eps, -1);
 }

-ggml_cgraph * clip_graph_nemotron_transcribe::build() {
+ggml_cgraph * mtmd_graph_nemotron_transcribe::build() {
     const int     sub          = hparams.subsampling_factor;
     const int64_t n_mel        = hparams.n_mel_bins;
     const int64_t n_mel_frames = img.nx();
@@ -90,7 +90,7 @@ ggml_cgraph * clip_graph_nemotron_transcribe::build() {
     }

     ggml_tensor * asr_pos = ggml_view_1d(ctx0, pos, n_pos, 0);
-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_ext(ctx0, cur, asr_pos, nullptr, hparams.audio_rope_dims, GGML_ROPE_TYPE_NEOX, 0, 10000.0f, 1.0f,
                              0.0f, 1.0f, 0.0f, 0.0f);
     };
@@ -231,10 +231,10 @@ struct spk_stream {
 };

 // every update has a full window, which overflows the FIFO and the cache (NeMo's update period of 300 never applies)
-static_assert(clip_graph_nemotron_transcribe::window_core - SPK_FIFO_LEN > SPK_CACHE_LEN);
+static_assert(mtmd_graph_nemotron_transcribe::window_core - SPK_FIFO_LEN > SPK_CACHE_LEN);

 // NeMo SortformerModules.streaming_update: core rows join the FIFO, its overflow moves to the cache, which is compressed
-static void update_spk_state(spk_stream & ss, const clip_enc_state & st, const clip_model & model, int64_t core_begin,
+static void update_spk_state(spk_stream & ss, const mtmd_subctx_enc_state & st, const mtmd_model & model, int64_t core_begin,
                              int64_t n_core) {
     const int64_t d       = model.hparams.spk_n_embd;
     const int64_t n_spk   = model.spk_head_fc2_w->ne[1];
@@ -257,13 +257,13 @@ static void update_spk_state(spk_stream & ss, const clip_enc_state & st, const c
 }

 // NeMo ParallelExpertEncoder._forward_online: windows with left/right context, one graph each, the speaker state carried
-bool clip_graph_nemotron_transcribe::encode_windows(clip_ctx * ctx, const clip_model & model, clip_encode_params * params) {
+bool mtmd_graph_nemotron_transcribe::encode_windows(mtmd_subcontext * ctx, const mtmd_model & model, mtmd_subctx_encode_params * params) {
     GGML_ASSERT(params->imgs->entries.size() == 1 && params->out_embd != nullptr);
-    const clip_image_f32 & rec = params->imgs->entries[0];
+    const mtmd_image_f32 & rec = params->imgs->entries[0];
     const auto &  buf      = rec.get_ro_buf();
     const int64_t n_frames = rec.nx();
     const int64_t n_rows   = rec.ny();
-    const int64_t n_embd   = clip_n_mmproj_embd(ctx);
+    const int64_t n_embd   = ctx->model.n_mmproj_embd();
     const int     sub      = model.hparams.subsampling_factor;
     const int64_t core     = (int64_t) window_core  * sub;
     const int64_t left     = (int64_t) window_left  * sub;
@@ -272,7 +272,7 @@ bool clip_graph_nemotron_transcribe::encode_windows(clip_ctx * ctx, const clip_m
     GGML_ASSERT((int64_t) buf.size() == n_frames * n_rows);

     auto & out = *params->out_embd;
-    GGML_ASSERT(out.size() == (size_t) n_embd * clip_n_output_tokens(ctx, &rec));
+    GGML_ASSERT(out.size() == (size_t) n_embd * ctx->model.n_output_tokens(&rec));

     // the ASR expert's input: per-feature normalization over the whole recording (NeMo normalize_batch, per_feature)
     std::vector<double> mean(n_rows), denom(n_rows);
@@ -292,7 +292,7 @@ bool clip_graph_nemotron_transcribe::encode_windows(clip_ctx * ctx, const clip_m
     }

     spk_stream     ss;
-    clip_enc_state st;
+    mtmd_subctx_enc_state st;
     size_t n_out = 0;
     for (int64_t w = 0; w < n_win; w++) {
         const int64_t core_begin = w * core;
@@ -310,18 +310,18 @@ bool clip_graph_nemotron_transcribe::encode_windows(clip_ctx * ctx, const clip_m
                 st.mel_norm[r * win_len + t] = (float) ((src[t] - mean[r]) / denom[r]);
             }
         }
-        clip_image_f32_batch batch;
+        mtmd_image_f32_batch batch;
         batch.is_audio = true;
         batch.entries.emplace_back();
         batch.entries[0].set_size({(int) win_len, (int) n_rows}, false, true);
         batch.entries[0].cpy_buf(data);

-        std::vector<float> embd((size_t) n_embd * clip_n_output_tokens(ctx, &batch.entries[0]));
-        clip_encode_params wp = *params;
+        std::vector<float> embd((size_t) n_embd * ctx->model.n_output_tokens(&batch.entries[0]));
+        mtmd_subctx_encode_params wp = *params;
         wp.imgs      = &batch;
         wp.out_embd  = &embd;
         wp.enc_state = &st;
-        if (!clip_encode(ctx, &wp)) {
+        if (!ctx->encode(&wp)) {
             return false;
         }

diff --git a/tools/mtmd/models/nemotron-v2-vl.cpp b/tools/mtmd/models/nemotron-v2-vl.cpp
index 03094be1b..6e644ea97 100644
--- a/tools/mtmd/models/nemotron-v2-vl.cpp
+++ b/tools/mtmd/models/nemotron-v2-vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_nemotron_v2_vl::build() {
+ggml_cgraph * mtmd_graph_nemotron_v2_vl::build() {
     GGML_ASSERT(model.class_embedding != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);

diff --git a/tools/mtmd/models/paddleocr.cpp b/tools/mtmd/models/paddleocr.cpp
index 0e6b3f4b6..1ad186935 100644
--- a/tools/mtmd/models/paddleocr.cpp
+++ b/tools/mtmd/models/paddleocr.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_paddleocr::build() {
+ggml_cgraph * mtmd_graph_paddleocr::build() {
     GGML_ASSERT(img.nx() % (patch_size * model.hparams.n_merge) == 0);
     GGML_ASSERT(img.ny() % (patch_size * model.hparams.n_merge) == 0);

@@ -13,7 +13,7 @@ ggml_cgraph * clip_graph_paddleocr::build() {
     ggml_set_name(positions, "positions");
     ggml_set_input(positions);

-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return ggml_rope_multi(
                     ctx0, cur, positions, nullptr,
                     d_head/2, mrope_sections, GGML_ROPE_TYPE_VISION,
diff --git a/tools/mtmd/models/parakeet.cpp b/tools/mtmd/models/parakeet.cpp
index a6d7f0739..4a2377b4c 100644
--- a/tools/mtmd/models/parakeet.cpp
+++ b/tools/mtmd/models/parakeet.cpp
@@ -4,7 +4,7 @@ static constexpr int PARAKEET_LOCAL_ATTN_THRESHOLD = 8192;
 static constexpr int PARAKEET_LOCAL_ATTN_WINDOW    = 128;

 // conv subsampling + conformer encoder
-ggml_cgraph * clip_graph_parakeet::build() {
+ggml_cgraph * mtmd_graph_parakeet::build() {

     // Conv subsampling
     ggml_tensor * inp = build_inp_raw(1);
diff --git a/tools/mtmd/models/pixtral.cpp b/tools/mtmd/models/pixtral.cpp
index edfae0825..6d3f01de0 100644
--- a/tools/mtmd/models/pixtral.cpp
+++ b/tools/mtmd/models/pixtral.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_pixtral::build() {
+ggml_cgraph * mtmd_graph_pixtral::build() {
     const int n_merge = hparams.n_merge;

     // 2D input positions
@@ -12,7 +12,7 @@ ggml_cgraph * clip_graph_pixtral::build() {
     ggml_set_name(pos_w, "pos_w");
     ggml_set_input(pos_w);

-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return build_rope_2d(ctx0, cur, pos_h, pos_w, hparams.rope_theta, true);
     };

diff --git a/tools/mtmd/models/pockettts-gen.cpp b/tools/mtmd/models/pockettts-gen.cpp
index 662feff7f..c5f340656 100644
--- a/tools/mtmd/models/pockettts-gen.cpp
+++ b/tools/mtmd/models/pockettts-gen.cpp
@@ -9,13 +9,13 @@
 //
 // there is no codebook anywhere, "codes" in the mtmd API are continuous features here

-ggml_tensor * clip_graph_pockettts_gen::modulate(ggml_tensor * x, ggml_tensor * shift, ggml_tensor * scale) const {
+ggml_tensor * mtmd_graph_pockettts_gen::modulate(ggml_tensor * x, ggml_tensor * shift, ggml_tensor * scale) const {
     ggml_tensor * cur = ggml_mul(ctx0, x, ggml_scale_bias(ctx0, scale, 1.0f, 1.0f));
     return ggml_add(ctx0, cur, shift);
 }

 // see TimestepEmbedder in the reference
-ggml_tensor * clip_graph_pockettts_gen::time_embed(const clip_flow_net::time_embd & te, float t) const {
+ggml_tensor * mtmd_graph_pockettts_gen::time_embed(const mtmd_flow_net::time_embd & te, float t) const {
     // t is a graph-build constant, so the cos/sin table can be folded into a scaled copy
     ggml_tensor * args = ggml_scale(ctx0, te.freqs, t);
     ggml_tensor * emb  = ggml_concat(ctx0, ggml_cos(ctx0, args), ggml_sin(ctx0, args), 0);
@@ -42,7 +42,7 @@ ggml_tensor * clip_graph_pockettts_gen::time_embed(const clip_flow_net::time_emb
 }

 // one velocity evaluation: v(cond, s, t, x)
-ggml_tensor * clip_graph_pockettts_gen::flow_forward(ggml_tensor * cond, ggml_tensor * x, float s, float t) const {
+ggml_tensor * mtmd_graph_pockettts_gen::flow_forward(ggml_tensor * cond, ggml_tensor * x, float s, float t) const {
     const auto & flow = model.flow;

     ggml_tensor * cur = build_mm(flow.input_proj_w, x);
@@ -99,7 +99,7 @@ ggml_tensor * clip_graph_pockettts_gen::flow_forward(ggml_tensor * cond, ggml_te

 // state carried between GEN_WAV calls: rope offset, per-layer KV window, conv left context
 // and the transposed-conv overlap tails
-std::vector<c2w_state_slot> list_pockettts_state_slots(const clip_hparams & hparams, const clip_model & model) {
+std::vector<c2w_state_slot> list_pockettts_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
     std::vector<c2w_state_slot> slots;
     if (model.gen_upsample_w == nullptr) {
         return slots; // not a pocket-tts decoder
@@ -136,8 +136,8 @@ std::vector<c2w_state_slot> list_pockettts_state_slots(const clip_hparams & hpar
     return slots;
 }

-ggml_cgraph * clip_graph_pockettts_gen::build() {
-    if (gen_process == CLIP_GEN_PROCESS_GEN_CODE) {
+ggml_cgraph * mtmd_graph_pockettts_gen::build() {
+    if (gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_CODE) {
         // the backbone hidden state arrives as the single batch entry
         ggml_tensor * h_state = build_inp_raw(1);
         h_state = ggml_reshape_2d(ctx0, h_state, n_mmproj_embd, 1);
@@ -188,7 +188,7 @@ ggml_cgraph * clip_graph_pockettts_gen::build() {
     cur = build_mm(model.gen_quant_out_w, cur);
     cb(cur, "quant_out", -1);

-    clip_graph_pockettts_seanet seanet(*this);
+    mtmd_graph_pockettts_seanet seanet(*this);
     for (const auto & slot : list_pockettts_state_slots(hparams, model)) {
         ggml_tensor * t = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, slot.ne0, slot.ne1);
         ggml_set_name(t, ("state_in_" + slot.name).c_str());
@@ -225,7 +225,7 @@ ggml_cgraph * clip_graph_pockettts_gen::build() {
     keep = ggml_mul(ctx0, keep,
         ggml_step(ctx0, ggml_scale_bias(ctx0, ggml_add(ctx0, pos_k, base), 1.0f, 0.5f - (float) prefix)));
     ggml_tensor * kq_mask = ggml_reshape_4d(ctx0, ggml_log(ctx0, keep), n_kv, n_pos, 1, 1);
-    if (flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
         kq_mask = ggml_cast(ctx0, kq_mask, GGML_TYPE_F16);
     }

diff --git a/tools/mtmd/models/pockettts-seanet.cpp b/tools/mtmd/models/pockettts-seanet.cpp
index c47207f56..4480aac9d 100644
--- a/tools/mtmd/models/pockettts-seanet.cpp
+++ b/tools/mtmd/models/pockettts-seanet.cpp
@@ -11,7 +11,7 @@ static int64_t div_ceil(int64_t a, int64_t b) {

 // x: [T, IC], w: [K, IC, OC] -> [T / stride, OC]
 // the convs are causal, so the whole K - stride padding goes on the left
-ggml_tensor * clip_graph_pockettts_seanet::conv1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride, int dilation,
+ggml_tensor * mtmd_graph_pockettts_seanet::conv1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride, int dilation,
                                                   bool pad_replicate, const std::string & state_name) const {
     const int64_t k_size  = (w->ne[0] - 1) * dilation + 1;
     const int64_t p_total = k_size - stride;
@@ -48,7 +48,7 @@ ggml_tensor * clip_graph_pockettts_seanet::conv1d(ggml_tensor * x, ggml_tensor *

 // x: [T, IC], w: [K, OC/groups, IC] -> [T * stride, OC]
 // the K - stride overlap tail belongs to the next call: added to its head when streaming, else dropped
-ggml_tensor * clip_graph_pockettts_seanet::conv_transpose1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride,
+ggml_tensor * mtmd_graph_pockettts_seanet::conv_transpose1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride,
                                                             const std::string & state_name) const {
     const int64_t K         = w->ne[0];
     const int64_t T         = x->ne[0];
@@ -99,7 +99,7 @@ ggml_tensor * clip_graph_pockettts_seanet::conv_transpose1d(ggml_tensor * x, ggm
     return out;
 }

-ggml_tensor * clip_graph_pockettts_seanet::res_unit(ggml_tensor * x, const clip_seanet::stage & stage, int dilation,
+ggml_tensor * mtmd_graph_pockettts_seanet::res_unit(ggml_tensor * x, const mtmd_seanet::stage & stage, int dilation,
                                                     const std::string & state_prefix) const {
     ggml_tensor * h = ggml_elu(ctx0, x);
     h = conv1d(h, stage.res_conv1_w, stage.res_conv1_b, 1, dilation, false, state_prefix);
@@ -109,7 +109,7 @@ ggml_tensor * clip_graph_pockettts_seanet::res_unit(ggml_tensor * x, const clip_
     return ggml_add(ctx0, x, h);
 }

-ggml_tensor * clip_graph_pockettts_seanet::encode(ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_pockettts_seanet::encode(ggml_tensor * x) const {
     const auto & seanet = model.seanet;

     ggml_tensor * cur = conv1d(x, seanet.conv_in_w, seanet.conv_in_b, 1, 1);
@@ -132,7 +132,7 @@ ggml_tensor * clip_graph_pockettts_seanet::encode(ggml_tensor * x) const {
     return cur;
 }

-ggml_tensor * clip_graph_pockettts_seanet::decode(ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_pockettts_seanet::decode(ggml_tensor * x) const {
     const auto & seanet = model.seanet;
     const bool   stream = !state_in.empty();

diff --git a/tools/mtmd/models/pockettts-spkenc.cpp b/tools/mtmd/models/pockettts-spkenc.cpp
index 280f2a97c..f01727ad5 100644
--- a/tools/mtmd/models/pockettts-spkenc.cpp
+++ b/tools/mtmd/models/pockettts-spkenc.cpp
@@ -4,7 +4,7 @@
 // mimi encoder (SEANet + transformer + downsample), then flow_lm.speaker_proj_weight

 // pre-norm block with layer scale on both residual paths, see mimi_transformer.py
-ggml_tensor * clip_graph_pockettts_spkenc::tfm_layer_forward(ggml_tensor * cur, const clip_layer & layer, ggml_tensor * inp_pos, ggml_tensor * kq_mask, int il) const {
+ggml_tensor * mtmd_graph_pockettts_spkenc::tfm_layer_forward(ggml_tensor * cur, const mtmd_layer & layer, ggml_tensor * inp_pos, ggml_tensor * kq_mask, int il) const {
     ggml_tensor * inp = cur;

     cur = build_norm(cur, layer.ln_1_w, layer.ln_1_b, NORM_TYPE_NORMAL, eps, il);
@@ -36,12 +36,12 @@ ggml_tensor * clip_graph_pockettts_spkenc::tfm_layer_forward(ggml_tensor * cur,
     return cur;
 }

-ggml_cgraph * clip_graph_pockettts_spkenc::build() {
+ggml_cgraph * mtmd_graph_pockettts_spkenc::build() {
     // the preprocessor hands over the waveform as a single-row "mel", already [n_samples, 1]
     ggml_tensor * inp_raw = build_inp_raw(1);
     ggml_tensor * cur = ggml_reshape_2d(ctx0, inp_raw, inp_raw->ne[0], inp_raw->ne[1]);

-    clip_graph_pockettts_seanet seanet(*this);
+    mtmd_graph_pockettts_seanet seanet(*this);
     cur = seanet.encode(cur);
     cb(cur, "mimi_enc", -1);

diff --git a/tools/mtmd/models/qwen2vl.cpp b/tools/mtmd/models/qwen2vl.cpp
index 7516aa23a..8ecd59036 100644
--- a/tools/mtmd/models/qwen2vl.cpp
+++ b/tools/mtmd/models/qwen2vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_qwen2vl::build() {
+ggml_cgraph * mtmd_graph_qwen2vl::build() {
     GGML_ASSERT(model.patch_bias == nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);

diff --git a/tools/mtmd/models/qwen3a.cpp b/tools/mtmd/models/qwen3a.cpp
index 4de96955d..0ed6b9a8f 100644
--- a/tools/mtmd/models/qwen3a.cpp
+++ b/tools/mtmd/models/qwen3a.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_qwen3a::build() {
+ggml_cgraph * mtmd_graph_qwen3a::build() {
     // Ref implementation: https://github.com/QwenLM/Qwen3-ASR/blob/main/qwen_asr/core/transformers_backend/modeling_qwen3_asr.py

     // inp_raw: [n_frames, n_mel, 1]  (nx=n_frames, ny=n_mel)
diff --git a/tools/mtmd/models/qwen3tts-gen.cpp b/tools/mtmd/models/qwen3tts-gen.cpp
index ab619b237..59958fc4c 100644
--- a/tools/mtmd/models/qwen3tts-gen.cpp
+++ b/tools/mtmd/models/qwen3tts-gen.cpp
@@ -3,7 +3,7 @@
 #include <string>

 // on-device sampling: top-k, top-p, then a random draw
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::do_sampling(ggml_tensor * logits, ggml_tensor * inp_rand) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::do_sampling(ggml_tensor * logits, ggml_tensor * inp_rand) const {
     logits = ggml_reshape_1d(ctx0, logits, ggml_nelements(logits));
     const int64_t n_vocab = logits->ne[0];

@@ -68,7 +68,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::do_sampling(ggml_tensor * logit
 }

 // returns a new cache with row row_idx set to value
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::cache_set(ggml_tensor * cache, int row_idx, ggml_tensor * value) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::cache_set(ggml_tensor * cache, int row_idx, ggml_tensor * value) const {
     const int64_t n_embd  = cache->ne[0];
     const int64_t n_cache = cache->ne[1];
     GGML_ASSERT(row_idx >= 0 && row_idx < n_cache);
@@ -95,7 +95,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::cache_set(ggml_tensor * cache,
 }

 // builds a const i32 with no host upload: view a tensor, zero it via scale, add value, cast to i32
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::const_i32(ggml_tensor * anchor, float value) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::const_i32(ggml_tensor * anchor, float value) const {
     ggml_tensor * v = ggml_view_1d(ctx0, anchor, 1, 0);
     if (v->type != GGML_TYPE_F32) {
         v = ggml_cast(ctx0, v, GGML_TYPE_F32);
@@ -104,20 +104,20 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::const_i32(ggml_tensor * anchor,
 }

 // causal keep-mask row for a query at position pos, window size n_kv_pad
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::causal_mask_row(int64_t n_kv_pad, int pos) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::causal_mask_row(int64_t n_kv_pad, int pos) const {
     ggml_tensor * ones = ggml_fill(ctx0, ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_kv_pad, n_kv_pad), 1.0f);
     ggml_tensor * keep = ggml_tri(ctx0, ones, GGML_TRI_TYPE_LOWER_DIAG);
     ggml_tensor * row  = ggml_view_1d(ctx0, keep, n_kv_pad, (size_t) pos * keep->nb[1]);
     ggml_tensor * mask = ggml_log(ctx0, row); // 0 = keep, -inf = masked
     mask = ggml_reshape_4d(ctx0, mask, n_kv_pad, 1, 1, 1);
-    if (flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
         mask = ggml_cast(ctx0, mask, GGML_TYPE_F16);
     }
     return mask;
 }

 // talker hidden size -> predictor hidden size (small_to_mtp_projection)
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::project_in(ggml_tensor * cur) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::project_in(ggml_tensor * cur) const {
     if (!model.gen_code_proj_in_w) {
         return cur;
     }
@@ -129,9 +129,9 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::project_in(ggml_tensor * cur) c
 }

 // one transformer layer at position pos; writes k/v into k_cache_layer/v_cache_layer at row pos
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::layer_forward(
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::layer_forward(
         ggml_tensor * cur,
-        const clip_layer & layer,
+        const mtmd_layer & layer,
         ggml_tensor * inp_pos,
         ggml_tensor * kq_mask,
         ggml_tensor *& k_cache_layer,
@@ -192,7 +192,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::layer_forward(

 // position 0: hidden bridge, seeds the k/v cache, no sampling
 // position 1: embed(code0), sample with lm_head[0], write out_code_cache[1]
-void clip_graph_qwen3tts_gen::code_gen::prefill(
+void mtmd_graph_qwen3tts_gen::code_gen::prefill(
         std::vector<ggml_tensor *> & k_cache,
         std::vector<ggml_tensor *> & v_cache,
         ggml_tensor *& out_code_cache,
@@ -237,7 +237,7 @@ void clip_graph_qwen3tts_gen::code_gen::prefill(
 // - write new kv at cache row g+1, sample with lm_head[g]
 // - write result to out_code_cache[g+1]
 // step_idx must be in [1, n_acoustic - 1]
-ggml_tensor * clip_graph_qwen3tts_gen::code_gen::step(
+ggml_tensor * mtmd_graph_qwen3tts_gen::code_gen::step(
         std::vector<ggml_tensor *> & k_cache,
         std::vector<ggml_tensor *> & v_cache,
         ggml_tensor * out_code_cache,
@@ -290,7 +290,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code_gen::step(

 // causal conv1d, stride 1: prepend persisted left-context instead of zero-padding, then a plain conv
 // x: [T, IC] (T-first). w: [K, IC, OC]. state_name empty means K == 1 (no left-context). returns [T, OC]
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int dilation, const std::string & state_name) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::causal_conv1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int dilation, const std::string & state_name) const {
     const int K   = (int) w->ne[0];
     const int pad = (K - 1) * dilation;

@@ -314,7 +314,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv1d(ggml_tensor * x,

 // causal depthwise conv1d, stride 1, dilation 1, kernel from w's shape.
 // x: [T, C]. w: [K, 1, C]. returns [T, C]. see causal_conv1d for the state contract.
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv1d_dw(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, const std::string & state_name) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::causal_conv1d_dw(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, const std::string & state_name) const {
     const int K   = (int) w->ne[0];
     const int pad = K - 1;

@@ -338,7 +338,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv1d_dw(ggml_tensor *

 // causal ConvTranspose1d, the (kernel - stride) overlap tail is kept as state for the next call
 // x: [T, IC], w: [K, OC, IC]. state_name empty means K == stride (no overlap). returns [T * stride, OC]
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv_transpose1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride, const std::string & state_name) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::causal_conv_transpose1d(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int stride, const std::string & state_name) const {
     const int     K        = (int) w->ne[0];
     const int     OC       = (int) w->ne[1];
     const int     trim     = K - stride;
@@ -372,7 +372,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::causal_conv_transpose1d(ggml_te

 // SnakeBeta activation: y = x + sin(alpha*x)^2 * inv_beta (alpha/inv_beta folded via exp/reciprocal at conversion time)
 // x: [T, C]. alpha/beta: [C], broadcasts over T
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::snake(ggml_tensor * x, ggml_tensor * alpha, ggml_tensor * beta) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::snake(ggml_tensor * x, ggml_tensor * alpha, ggml_tensor * beta) const {
     ggml_tensor * a = ggml_reshape_2d(ctx0, alpha, 1, alpha->ne[0]);
     ggml_tensor * b = ggml_reshape_2d(ctx0, beta,  1, beta->ne[0]);

@@ -388,7 +388,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::snake(ggml_tensor * x, ggml_ten

 // RVQ codebook decode: T frames of 16 codes -> 512-dim hidden (C-first, [512, T])
 // codebook 0 (semantic) and 1..15 (acoustic) sum within their group, project separately, then add
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::quant_decode(ggml_tensor * inp_codes) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::quant_decode(ggml_tensor * inp_codes) const {
     const auto & c2w = model.c2w;
     const int64_t T = inp_codes->ne[0];

@@ -418,7 +418,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::quant_decode(ggml_tensor * inp_
 // one pre_transformer layer over a batch of N = sliding_window new frames
 // attention runs over [(W-1)-frame prefix from the last batch] + [N new frames]
 // RoPE positions come from a persisted counter, so phases line up across batches
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::tfm_layer_forward(ggml_tensor * cur, const clip_layer & layer, int il) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::tfm_layer_forward(ggml_tensor * cur, const mtmd_layer & layer, int il) const {
     const int     n_head    = hparams.wav_tfm_n_head;
     const int     n_head_kv = hparams.wav_tfm_n_head_kv;
     const int64_t d_head    = layer.q_w->ne[1] / n_head;
@@ -485,7 +485,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::tfm_layer_forward(ggml_tensor *
     keep = ggml_mul(ctx0, keep, warm);

     ggml_tensor * mask = ggml_reshape_4d(ctx0, ggml_log(ctx0, keep), total_kv, N, 1, 1); // 0 = keep, -inf = masked
-    if (flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
         mask = ggml_cast(ctx0, mask, GGML_TYPE_F16);
     }

@@ -515,7 +515,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::tfm_layer_forward(ggml_tensor *

 // dwconv -> LayerNorm -> pwconv1 -> GELU -> pwconv2 -> layer scale -> residual
 // x: [T, C] T-first; LayerNorm/pwconv need C on ne0, so this transposes in and back out
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::convnext_block(ggml_tensor * x, const clip_code2wav::upsample_block & blk, const std::string & state_prefix) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::convnext_block(ggml_tensor * x, const mtmd_code2wav::upsample_block & blk, const std::string & state_prefix) const {
     ggml_tensor * residual = x;

     ggml_tensor * h = causal_conv1d_dw(x, blk.dwconv_w, blk.dwconv_b, state_prefix + "_dwconv"); // [T, C]
@@ -538,7 +538,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::convnext_block(ggml_tensor * x,

 // SnakeBeta -> dilated causal conv (k=7) -> SnakeBeta -> pointwise causal conv (k=1) -> residual.
 // x: [T, C]. returns [T, C].
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::dac_res_unit(ggml_tensor * x, const clip_code2wav::dac_res & res, int dilation, const std::string & state_name) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::dac_res_unit(ggml_tensor * x, const mtmd_code2wav::dac_res & res, int dilation, const std::string & state_name) const {
     ggml_tensor * residual = x;
     ggml_tensor * h = snake(x, res.act1_alpha, res.act1_beta);
     h = causal_conv1d(h, res.conv1_w, res.conv1_b, dilation, state_name);
@@ -548,7 +548,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::dac_res_unit(ggml_tensor * x, c
 }

 // RVQ codes -> raw PCM for a batch of N = sliding_window frames
-ggml_tensor * clip_graph_qwen3tts_gen::code2wav::decode(ggml_tensor * inp_codes) const {
+ggml_tensor * mtmd_graph_qwen3tts_gen::code2wav::decode(ggml_tensor * inp_codes) const {
     const auto & c2w = model.c2w;

     // 1. quantizer decode: N frames of 16 codes -> [512, N] (C-first)
@@ -613,7 +613,7 @@ ggml_tensor * clip_graph_qwen3tts_gen::code2wav::decode(ggml_tensor * inp_codes)

 // code2wav's persisted state buffers: RoPE position counter, K/V per pre_transformer layer,
 // left-context/tail per stateful conv. shape lookup only, no graph needed
-std::vector<c2w_state_slot> list_c2w_state_slots(const clip_hparams & hparams, const clip_model & model) {
+std::vector<c2w_state_slot> list_c2w_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
     const auto & c2w = model.c2w;
     std::vector<c2w_state_slot> slots;

@@ -663,20 +663,20 @@ std::vector<c2w_state_slot> list_c2w_state_slots(const clip_hparams & hparams, c

 // both sub-graphs are always built, so the topology stays constant
 // ggml_build_forward_select() then picks the one that actually runs
-ggml_cgraph * clip_graph_qwen3tts_gen::build() {
+ggml_cgraph * mtmd_graph_qwen3tts_gen::build() {
     GGML_ASSERT(n_batch == 1); // this module only ever processes one frame at a time

     int idx;
     switch (gen_process) {
-        case CLIP_GEN_PROCESS_GEN_CODE: idx = 0; break;
-        case CLIP_GEN_PROCESS_GEN_WAV:  idx = 1; break;
+        case MTMD_SUBCTX_GEN_PROCESS_GEN_CODE: idx = 0; break;
+        case MTMD_SUBCTX_GEN_PROCESS_GEN_WAV:  idx = 1; break;
         default: GGML_ABORT("unknown gen_process");
     }

-    // ---- CLIP_GEN_PROCESS_GEN_CODE: backbone hidden state -> 16 RVQ codes + next-step embd ----
+    // ---- MTMD_SUBCTX_GEN_PROCESS_GEN_CODE: backbone hidden state -> 16 RVQ codes + next-step embd ----
     // not build_inp_raw(), a GEN_WAV call's `img` has no hidden-state data
     ggml_tensor * h_state = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, n_mmproj_embd);
-    ggml_set_name(h_state, "inp_raw"); // must keep this exact name, clip_encode() sets it by name
+    ggml_set_name(h_state, "inp_raw"); // must keep this exact name, mtmd_subcontext::encode() sets it by name
     ggml_set_input(h_state);

     ggml_tensor * code0 = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, 1);
@@ -740,7 +740,7 @@ ggml_cgraph * clip_graph_qwen3tts_gen::build() {
     out_embd = ggml_reshape_2d(ctx0, out_embd, out_embd->ne[0], 1);
     cb(out_embd, "gen_audio_out", -1);

-    // ---- CLIP_GEN_PROCESS_GEN_WAV: 16 RVQ codes -> raw PCM ----
+    // ---- MTMD_SUBCTX_GEN_PROCESS_GEN_WAV: 16 RVQ codes -> raw PCM ----
     const int n_frames = hparams.wav_tfm_swa; // frames per batch, == the attention window

     ggml_tensor * inp_codes = ggml_new_tensor_2d(ctx0, GGML_TYPE_I32, n_frames, n_codes);
@@ -764,7 +764,7 @@ ggml_cgraph * clip_graph_qwen3tts_gen::build() {
         ggml_set_output(slot.second);
     }

-    // out_embd goes last, clip_encode() reads it back via ggml_graph_node(gf, -1)
+    // out_embd goes last, mtmd_subcontext::encode() reads it back via ggml_graph_node(gf, -1)
     ggml_tensor * outs[2];
     outs[0] = out_codes; outs[1] = out_audio;
     ggml_build_forward_select(gf, outs, 2, idx);
diff --git a/tools/mtmd/models/qwen3tts-spkenc.cpp b/tools/mtmd/models/qwen3tts-spkenc.cpp
index ac6cafd9a..1717eac6e 100644
--- a/tools/mtmd/models/qwen3tts-spkenc.cpp
+++ b/tools/mtmd/models/qwen3tts-spkenc.cpp
@@ -5,7 +5,7 @@ static constexpr int SPK_DILATIONS[3]  = { 2, 3, 4 }; // enc_dilations[1..3]

 // conv1d, kernel K, padding "same" (reflect), dilation d
 // x: [C, T] (ne[0]=C, ne[1]=T) -> [out_c, T]
-ggml_tensor * clip_graph_qwen3tts_spkenc::conv1d_same(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int dilation) const {
+ggml_tensor * mtmd_graph_qwen3tts_spkenc::conv1d_same(ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, int dilation) const {
     const int K   = (int) w->ne[0];
     const int IC  = (int) w->ne[1];
     const int OC  = (int) w->ne[2];
@@ -36,7 +36,7 @@ ggml_tensor * clip_graph_qwen3tts_spkenc::conv1d_same(ggml_tensor * x, ggml_tens

 // Res2Net: split channel axis into `scale` chunks, chain dilated conv1d branches
 // x: [C, T] -> [C, T]
-ggml_tensor * clip_graph_qwen3tts_spkenc::res2net(ggml_tensor * x, const clip_layer & layer, int dilation, int scale) const {
+ggml_tensor * mtmd_graph_qwen3tts_spkenc::res2net(ggml_tensor * x, const mtmd_layer & layer, int dilation, int scale) const {
     const int64_t C  = x->ne[0];
     const int64_t T  = x->ne[1];
     const int64_t Cs = C / scale;
@@ -70,7 +70,7 @@ ggml_tensor * clip_graph_qwen3tts_spkenc::res2net(ggml_tensor * x, const clip_la
 }

 // squeeze-and-excitation gate. x: [C, T] -> [C, T]
-ggml_tensor * clip_graph_qwen3tts_spkenc::se_block(ggml_tensor * x, const clip_layer & layer) const {
+ggml_tensor * mtmd_graph_qwen3tts_spkenc::se_block(ggml_tensor * x, const mtmd_layer & layer) const {
     // temporal mean, keepdim: transpose so T is on ne[0], reduce, transpose back
     ggml_tensor * x_t  = ggml_cont(ctx0, ggml_transpose(ctx0, x));    // [T, C]
     ggml_tensor * mean = ggml_mean(ctx0, x_t);                        // [1, C]
@@ -85,7 +85,7 @@ ggml_tensor * clip_graph_qwen3tts_spkenc::se_block(ggml_tensor * x, const clip_l
 }

 // tdnn1 -> res2net -> tdnn2 -> se, plus residual. x: [C, T] -> [C, T]
-ggml_tensor * clip_graph_qwen3tts_spkenc::se_res2net_block(ggml_tensor * x, const clip_layer & layer, int dilation, int scale) const {
+ggml_tensor * mtmd_graph_qwen3tts_spkenc::se_res2net_block(ggml_tensor * x, const mtmd_layer & layer, int dilation, int scale) const {
     ggml_tensor * residual = x;
     ggml_tensor * h  = conv1d_same(x, layer.conv_pw1_w, layer.conv_pw1_b, 1); // tdnn1
     h = ggml_relu(ctx0, h);
@@ -97,7 +97,7 @@ ggml_tensor * clip_graph_qwen3tts_spkenc::se_res2net_block(ggml_tensor * x, cons
 }

 // attentive statistics pooling. x: [C, T] -> [2*C, 1]
-ggml_tensor * clip_graph_qwen3tts_spkenc::attentive_stats_pool(ggml_tensor * x) const {
+ggml_tensor * mtmd_graph_qwen3tts_spkenc::attentive_stats_pool(ggml_tensor * x) const {
     const int64_t T = x->ne[1];

     // mean over T: [C, 1]
@@ -151,7 +151,7 @@ ggml_tensor * clip_graph_qwen3tts_spkenc::attentive_stats_pool(ggml_tensor * x)
     return ggml_concat(ctx0, w_mean, w_std, 0); // [2C, 1]
 }

-ggml_cgraph * clip_graph_qwen3tts_spkenc::build() {
+ggml_cgraph * mtmd_graph_qwen3tts_spkenc::build() {
     // inp_raw: [T, n_mel, 1, 1], from mtmd_audio_preprocessor_qwen3tts_spk
     ggml_tensor * inp = build_inp_raw(1);
     inp = ggml_reshape_2d(ctx0, inp, inp->ne[0], inp->ne[1]);
diff --git a/tools/mtmd/models/qwen3vl.cpp b/tools/mtmd/models/qwen3vl.cpp
index 2e49dcd01..ada7a129d 100644
--- a/tools/mtmd/models/qwen3vl.cpp
+++ b/tools/mtmd/models/qwen3vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_qwen3vl::build() {
+ggml_cgraph * mtmd_graph_qwen3vl::build() {
     GGML_ASSERT(model.patch_bias != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);
     GGML_ASSERT(model.class_embedding == nullptr);
diff --git a/tools/mtmd/models/sensenova-u1.cpp b/tools/mtmd/models/sensenova-u1.cpp
index 57875c6ed..8e685d74c 100644
--- a/tools/mtmd/models/sensenova-u1.cpp
+++ b/tools/mtmd/models/sensenova-u1.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_sensenova_u1::build() {
+ggml_cgraph * mtmd_graph_sensenova_u1::build() {
     GGML_ASSERT(model.patch_embeddings_0 != nullptr);
     GGML_ASSERT(model.patch_bias != nullptr);
     GGML_ASSERT(model.mm_0_w != nullptr);
diff --git a/tools/mtmd/models/siglip.cpp b/tools/mtmd/models/siglip.cpp
index 8cc901a28..84387f86d 100644
--- a/tools/mtmd/models/siglip.cpp
+++ b/tools/mtmd/models/siglip.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_siglip::build() {
+ggml_cgraph * mtmd_graph_siglip::build() {
     ggml_tensor * inp = build_inp();

     ggml_tensor * learned_pos_embd = model.position_embeddings;
diff --git a/tools/mtmd/models/soprano.cpp b/tools/mtmd/models/soprano.cpp
index bd16c6912..43f34f86f 100644
--- a/tools/mtmd/models/soprano.cpp
+++ b/tools/mtmd/models/soprano.cpp
@@ -2,7 +2,7 @@
 #include "../mtmd-audio.h"

 // Soprano Vocos: normalized Qwen3 hidden states -> STFT coefficients.
-ggml_cgraph * clip_graph_soprano::build() {
+ggml_cgraph * mtmd_graph_soprano::build() {
     const auto & v = model.vocos;
     const int n_inp = hparams.projection_dim;
     const int n_up = upscale * (n_frames - 1) + 1;
@@ -59,7 +59,7 @@ ggml_cgraph * clip_graph_soprano::build() {
     return gf;
 }

-void clip_graph_soprano::decode_spectrum(const clip_model & model, const std::vector<float> & spectrum, std::vector<float> & pcm) {
+void mtmd_graph_soprano::decode_spectrum(const mtmd_model & model, const std::vector<float> & spectrum, std::vector<float> & pcm) {
     const int n_frame_vals = model.vocos.output_w->ne[1];
     const int n_fft = n_frame_vals - 2;
     const int hop_length = model.hparams.audio_hop_len;
diff --git a/tools/mtmd/models/step3vl.cpp b/tools/mtmd/models/step3vl.cpp
index 5142b0bba..db0245a89 100644
--- a/tools/mtmd/models/step3vl.cpp
+++ b/tools/mtmd/models/step3vl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_step3vl::build() {
+ggml_cgraph * mtmd_graph_step3vl::build() {
     GGML_ASSERT(model.class_embedding == nullptr);
     GGML_ASSERT(model.patch_embeddings_0 != nullptr);
     GGML_ASSERT(model.position_embeddings != nullptr);
@@ -18,7 +18,7 @@ ggml_cgraph * clip_graph_step3vl::build() {
     ggml_tensor * inp = build_inp();
     ggml_tensor * learned_pos_embd = resize_position_embeddings();

-    auto add_pos = [&](ggml_tensor * cur, const clip_layer &) {
+    auto add_pos = [&](ggml_tensor * cur, const mtmd_layer &) {
         return build_rope_2d(ctx0, cur, pos_w, pos_h, hparams.rope_theta, false);
     };

diff --git a/tools/mtmd/models/voxtral-rt.cpp b/tools/mtmd/models/voxtral-rt.cpp
index 4a01512d2..3f768d6a2 100644
--- a/tools/mtmd/models/voxtral-rt.cpp
+++ b/tools/mtmd/models/voxtral-rt.cpp
@@ -2,7 +2,7 @@

 // the input holds 2 context mel frames followed by the new frames, so the causal conv stem
 // sees the same left context as on the full spectrogram
-ggml_cgraph * clip_graph_voxtral_rt::build() {
+ggml_cgraph * mtmd_graph_voxtral_rt::build() {
     const int n_frames_in = img.nx();
     const int n_new       = (n_frames_in - 2) / 2; // encoder frames, the conv stem has stride 2
     const int n_window    = hparams.attn_window_size;
diff --git a/tools/mtmd/models/whisper-enc.cpp b/tools/mtmd/models/whisper-enc.cpp
index 49d5dd5ad..6225b707c 100644
--- a/tools/mtmd/models/whisper-enc.cpp
+++ b/tools/mtmd/models/whisper-enc.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_whisper_enc::build() {
+ggml_cgraph * mtmd_graph_whisper_enc::build() {
     const int n_frames = img.nx();
     const int n_pos    = n_frames / 2;
     GGML_ASSERT(model.position_embeddings->ne[1] >= n_pos);
diff --git a/tools/mtmd/models/yasa2.cpp b/tools/mtmd/models/yasa2.cpp
index e8cd3dacb..07fa84e06 100644
--- a/tools/mtmd/models/yasa2.cpp
+++ b/tools/mtmd/models/yasa2.cpp
@@ -25,7 +25,7 @@ static ggml_tensor * mul_channel_weight(
     return ggml_mul(ctx0, x_whcb, w4);
 }

-ggml_tensor * clip_graph_yasa2::layer_norm_channels(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b, float eps) {
+ggml_tensor * mtmd_graph_yasa2::layer_norm_channels(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b, float eps) {
     // Match HF ConvNextLayerNorm(channels_first):
     // u = mean_c(x), s = mean_c((x-u)^2), x = (x-u)/sqrt(s+eps)
     // cast back to input dtype before affine.
@@ -48,7 +48,7 @@ ggml_tensor * clip_graph_yasa2::layer_norm_channels(ggml_tensor * inp, ggml_tens
     return xhat;
 }

-ggml_tensor * clip_graph_yasa2::convnext_grn(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b) {
+ggml_tensor * mtmd_graph_yasa2::convnext_grn(ggml_tensor * inp, ggml_tensor * w, ggml_tensor * b) {
     // Exact ConvNeXtV2 GRN:
     // Gx = ||x||_2 over spatial dims (W,H), Nx = Gx / (mean_c(Gx) + eps)
     // y  = w * (x * Nx) + b + x
@@ -78,7 +78,7 @@ ggml_tensor * clip_graph_yasa2::convnext_grn(ggml_tensor * inp, ggml_tensor * w,
     return ggml_add(ctx0, inp, xnx);
 }

-ggml_cgraph * clip_graph_yasa2::build() {
+ggml_cgraph * mtmd_graph_yasa2::build() {
     ggml_tensor * cur = build_inp_raw();

     // Patch embedding Conv2d(kernel=4, stride=4)
diff --git a/tools/mtmd/models/youtuvl.cpp b/tools/mtmd/models/youtuvl.cpp
index cda456c66..8cc714358 100644
--- a/tools/mtmd/models/youtuvl.cpp
+++ b/tools/mtmd/models/youtuvl.cpp
@@ -1,6 +1,6 @@
 #include "models.h"

-ggml_cgraph * clip_graph_youtuvl::build() {
+ggml_cgraph * mtmd_graph_youtuvl::build() {
     GGML_ASSERT(model.class_embedding == nullptr);
     const int batch_size       = 1;
     const bool use_window_attn = !hparams.wa_layer_indexes.empty();
diff --git a/tools/mtmd/mtmd-audio.cpp b/tools/mtmd/mtmd-audio.cpp
index 60087b16d..fcf94445a 100644
--- a/tools/mtmd/mtmd-audio.cpp
+++ b/tools/mtmd/mtmd-audio.cpp
@@ -1,4 +1,5 @@
 #include "mtmd-audio.h"
+#include "mtmd-subcontext.h"

 #include <cfloat>
 #include <cmath>
@@ -269,12 +270,12 @@ static void ifft(const mtmd_audio_cache & cache, float * in, int N, float * out)
     fft_impl<true, false>(cache, in, N, out);
 }

-static void mtmd_audio_fill_entries(std::vector<mtmd_audio_mel> & mels, bool shape_only, std::vector<clip_image_f32> & entries) {
+static void mtmd_audio_fill_entries(std::vector<mtmd_audio_mel> & mels, bool shape_only, std::vector<mtmd_image_f32> & entries) {
     entries.clear();
     for (auto & mel : mels) {
         GGML_ASSERT(mel.n_len <= INT32_MAX && mel.n_len >= 0);
         GGML_ASSERT(mel.n_mel <= INT32_MAX && mel.n_mel >= 0);
-        clip_image_f32 entry;
+        mtmd_image_f32 entry;
         entry.set_size({(int) mel.n_len, (int) mel.n_mel}, shape_only || mel.data.empty(), /* is_audio */ true);
         if (!entry.is_placeholder()) {
             entry.cpy_buf(mel.data);
@@ -430,7 +431,7 @@ static bool log_mel_spectrogram(
     }
     out.data.resize(total_size);

-    if (!shape_only && !clip_audio_log_mel(params, samples_padded.data(), hann, out.n_len, out.data.data())) {
+    if (!shape_only && !mtmd_audio_log_mel(params, samples_padded.data(), hann, out.n_len, out.data.data())) {
         return false;
     }

@@ -547,7 +548,7 @@ bool mtmd_audio_preprocessor_whisper::process(const float *                 samp
         return false;
     }

-    // because the cgraph in clip.cpp only accepts 3000 frames each, we need to split the mel
+    // because the cgraph only accepts 3000 frames each, we need to split the mel
     // we always expect the mel to have 3000 silent frames at the end
     if (DEBUG) {
         printf("output: n_mel = %d, n_len = %d\n", (int) out_full.n_mel, (int) out_full.n_len);
@@ -2002,10 +2003,10 @@ bool mtmd_audio_preprocessor_pockettts::process(const float *                 sa
     }

     // the mimi transformer mask is dense, so cost is quadratic in the reference length
-    const int64_t max_samples = (int64_t) clip_hparams::pockettts_max_spk_seconds * hparams.audio_sample_rate;
+    const int64_t max_samples = (int64_t) mtmd_hparams::pockettts_max_spk_seconds * hparams.audio_sample_rate;
     if ((int64_t) n_samples > max_samples) {
         LOG_WRN("%s: speaker reference is %.1f s, truncating to the first %d s\n", __func__,
-                (double) n_samples / hparams.audio_sample_rate, clip_hparams::pockettts_max_spk_seconds);
+                (double) n_samples / hparams.audio_sample_rate, mtmd_hparams::pockettts_max_spk_seconds);
         n_samples = (size_t) max_samples;
     }

diff --git a/tools/mtmd/mtmd-audio.h b/tools/mtmd/mtmd-audio.h
index c0751b5e6..ba71913e0 100644
--- a/tools/mtmd/mtmd-audio.h
+++ b/tools/mtmd/mtmd-audio.h
@@ -2,8 +2,7 @@

 #include "ggml.h"
 #include "ggml-backend.h"
-#include "clip-model.h"
-#include "clip-impl.h"
+#include "mtmd-model.h"

 #include <cstdint>
 #include <vector>
@@ -77,7 +76,7 @@ struct mtmd_audio_mel_config {
 };

 struct mtmd_audio_preproc_out {
-    std::vector<clip_image_f32> entries; // one per chunk, empty on failure
+    std::vector<mtmd_image_f32> entries; // one per chunk, empty on failure
     std::vector<float> samples;          // input of compute(), empty if entries already hold the data

     bool needs_compute() const {
@@ -86,13 +85,12 @@ struct mtmd_audio_preproc_out {
 };

 struct mtmd_audio_preprocessor {
-    const clip_hparams & hparams;
+    const mtmd_hparams & hparams;
     mtmd_audio_mel_config mel_config; // filled by initialize() of the preprocessors that use the log-mel graph

-    mtmd_audio_preprocessor(const clip_ctx * ctx): hparams(*clip_get_hparams(ctx)) {
-        mel_config.sched = clip_get_sched(ctx);
-    }
-    mtmd_audio_preprocessor(const clip_hparams & hparams, ggml_backend_sched_t sched): hparams(hparams) {
+    // mel_config.sched must be set before the first compute(), see mtmd_context::init_audio()
+    mtmd_audio_preprocessor(const mtmd_model * model): hparams(model->hparams) {}
+    mtmd_audio_preprocessor(const mtmd_hparams & hparams, ggml_backend_sched_t sched): hparams(hparams) {
         mel_config.sched = sched;
     }

@@ -115,7 +113,7 @@ struct mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_whisper : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_whisper(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_whisper(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -124,14 +122,14 @@ struct mtmd_audio_preprocessor_whisper : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_muse_spark_audio : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_muse_spark_audio(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_muse_spark_audio(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override {}
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
 };

 struct mtmd_audio_preprocessor_conformer : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_conformer(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
-    mtmd_audio_preprocessor_conformer(const clip_hparams & hparams, ggml_backend_sched_t sched) : mtmd_audio_preprocessor(hparams, sched) {}
+    mtmd_audio_preprocessor_conformer(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
+    mtmd_audio_preprocessor_conformer(const mtmd_hparams & hparams, ggml_backend_sched_t sched) : mtmd_audio_preprocessor(hparams, sched) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -146,7 +144,7 @@ struct mtmd_audio_preprocessor_d1omni : mtmd_audio_preprocessor_conformer {
 };

 struct mtmd_audio_preprocessor_lfm3a : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_lfm3a(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_lfm3a(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -155,7 +153,7 @@ struct mtmd_audio_preprocessor_lfm3a : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_granite_speech : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_granite_speech(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_granite_speech(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -164,7 +162,7 @@ struct mtmd_audio_preprocessor_granite_speech : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_gemma4a : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_gemma4a(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_gemma4a(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -173,13 +171,13 @@ struct mtmd_audio_preprocessor_gemma4a : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_gemma4ua : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_gemma4ua(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_gemma4ua(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
 };

 struct mtmd_audio_preprocessor_qwen3a : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_qwen3a(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_qwen3a(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -188,7 +186,7 @@ struct mtmd_audio_preprocessor_qwen3a : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_dots3note : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_dots3note(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_dots3note(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -197,7 +195,7 @@ struct mtmd_audio_preprocessor_dots3note : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_mimo_audio : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_mimo_audio(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_mimo_audio(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -206,7 +204,7 @@ struct mtmd_audio_preprocessor_mimo_audio : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_qwen3tts_spk : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_qwen3tts_spk(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_qwen3tts_spk(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -216,7 +214,7 @@ struct mtmd_audio_preprocessor_qwen3tts_spk : mtmd_audio_preprocessor {

 // mimi convolves the waveform directly, so this only pads it to a whole number of frames
 struct mtmd_audio_preprocessor_pockettts : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_pockettts(const clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) {}
+    mtmd_audio_preprocessor_pockettts(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
     void initialize() override {}
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
 };
@@ -229,7 +227,7 @@ struct mtmd_audio_preprocessor_pockettts : mtmd_audio_preprocessor {
 //   4: voice encoder power mel         [n_frames x 40]  (frame major, trimmed, partial grid)
 // turbo loudness-normalizes the clip to -27 LUFS and caps the t3 clip at 15 s instead of 6 s
 struct mtmd_audio_preprocessor_chatterbox_ref : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_chatterbox_ref(const clip_ctx * ctx, bool is_mtl) : mtmd_audio_preprocessor(ctx), is_mtl(is_mtl) {}
+    mtmd_audio_preprocessor_chatterbox_ref(const mtmd_model * model, bool is_mtl) : mtmd_audio_preprocessor(model), is_mtl(is_mtl) {}
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -242,7 +240,7 @@ struct mtmd_audio_preprocessor_chatterbox_ref : mtmd_audio_preprocessor {
 };

 struct mtmd_audio_preprocessor_parakeet : mtmd_audio_preprocessor {
-    mtmd_audio_preprocessor_parakeet(clip_ctx * ctx) : mtmd_audio_preprocessor(ctx) { }
+    mtmd_audio_preprocessor_parakeet(const mtmd_model * model) : mtmd_audio_preprocessor(model) { }
     void initialize() override;
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;

@@ -255,7 +253,7 @@ struct mtmd_audio_preprocessor_parakeet : mtmd_audio_preprocessor {

 // one chunk per recording: the raw log-mel of its valid frames
 struct mtmd_audio_preprocessor_nemotron_transcribe : mtmd_audio_preprocessor_parakeet {
-    mtmd_audio_preprocessor_nemotron_transcribe(clip_ctx * ctx) : mtmd_audio_preprocessor_parakeet(ctx) { }
+    mtmd_audio_preprocessor_nemotron_transcribe(const mtmd_model * model) : mtmd_audio_preprocessor_parakeet(model) { }
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
 };

diff --git a/tools/mtmd/mtmd-cli.cpp b/tools/mtmd/mtmd-cli.cpp
index ba156f1c5..4534527fc 100644
--- a/tools/mtmd/mtmd-cli.cpp
+++ b/tools/mtmd/mtmd-cli.cpp
@@ -153,7 +153,7 @@ struct mtmd_cli_context {
     }

     void init_vision_context(common_params & params) {
-        const char * clip_path = params.mmproj.path.c_str();
+        const char * mmproj_path = params.mmproj.path.c_str();
         mtmd_context_params mparams = mtmd_context_params_default();
         mparams.use_gpu          = params.mmproj_use_gpu;
         mparams.device           = params.mmproj_device;
@@ -166,7 +166,7 @@ struct mtmd_cli_context {
         {
             // non-causal models need the whole image in one ubatch
             const int n_ubatch = llama_n_ubatch(lctx);
-            auto mem = mtmd_get_memory_usage(clip_path, mparams);
+            auto mem = mtmd_get_memory_usage(mmproj_path, mparams);
             if (mem.use_non_causal && mem.image_max_tokens > n_ubatch) {
                 LOG_WRN("%s: cap image_max_tokens (original=%d) to n_ubatch (%d) because model needs non-causal attention on image\n", __func__, mem.image_max_tokens, n_ubatch);
                 LOG_WRN("%s: increase n_ubatch (-ub) to increase vision token budget\n", __func__);
@@ -178,9 +178,9 @@ struct mtmd_cli_context {
             mparams.cb_eval_user_data = &cb_data;
             mparams.cb_eval = common_debug_cb_eval;
         }
-        ctx_vision.reset(mtmd_init_from_file(clip_path, model, mparams));
+        ctx_vision.reset(mtmd_init_from_file(mmproj_path, model, mparams));
         if (!ctx_vision.get()) {
-            LOG_ERR("Failed to load vision model from %s\n", clip_path);
+            LOG_ERR("Failed to load vision model from %s\n", mmproj_path);
             exit(1);
         }

diff --git a/tools/mtmd/mtmd-graph.cpp b/tools/mtmd/mtmd-graph.cpp
new file mode 100644
index 000000000..3242bddbb
--- /dev/null
+++ b/tools/mtmd/mtmd-graph.cpp
@@ -0,0 +1,1064 @@
+#include "mtmd-graph.h"
+#include "models/models.h"
+
+#include "ggml.h"
+#include "ggml-cpp.h"
+#include "ggml-backend.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <stdexcept>
+#include <vector>
+
+//
+// mtmd_graph
+//
+
+mtmd_graph::mtmd_graph(mtmd_subcontext * ctx, const mtmd_image_f32 & img) :
+        model(ctx->model),
+        hparams(model.hparams),
+        proj_type(ctx->model.proj_type),
+        img(img),
+        patch_size(hparams.patch_size),
+        n_patches_x(img.nx() / patch_size),
+        n_patches_y(img.ny() / patch_size),
+        n_patches(n_patches_x * n_patches_y),
+        n_embd(hparams.n_embd),
+        n_head(hparams.n_head),
+        n_head_kv(hparams.n_head_kv),
+        d_head(hparams.n_embd_head > 0 ? hparams.n_embd_head : (n_head > 0 ? n_embd / n_head : 0)),
+        n_layer(hparams.n_layer),
+        n_mmproj_embd(ctx->model.n_mmproj_embd()),
+        eps(hparams.eps),
+        kq_scale(d_head > 0 ? 1.0f / sqrtf((float)d_head) : 0.0f),
+        flash_attn_type(ctx->flash_attn_type) {
+    struct ggml_init_params params = {
+        /*.mem_size   =*/ ctx->buf_compute_meta.size(),
+        /*.mem_buffer =*/ ctx->buf_compute_meta.data(),
+        /*.no_alloc   =*/ true,
+    };
+    ctx0_ptr.reset(ggml_init(params));
+    ctx0 = ctx0_ptr.get();
+    gf = ggml_new_graph_custom(ctx0, ctx->max_nodes, false);
+}
+
+mtmd_graph::mtmd_graph(const mtmd_graph & parent) :
+        model(parent.model),
+        hparams(parent.hparams),
+        proj_type(parent.proj_type),
+        img(parent.img),
+        patch_size(parent.patch_size),
+        n_patches_x(parent.n_patches_x),
+        n_patches_y(parent.n_patches_y),
+        n_patches(parent.n_patches),
+        n_embd(parent.n_embd),
+        n_head(parent.n_head),
+        n_head_kv(parent.n_head_kv),
+        d_head(parent.d_head),
+        n_layer(parent.n_layer),
+        n_mmproj_embd(parent.n_mmproj_embd),
+        eps(parent.eps),
+        kq_scale(parent.kq_scale),
+        flash_attn_type(parent.flash_attn_type) {
+    // reuse from parent
+    ctx0 = parent.ctx0;
+    gf   = parent.gf;
+}
+
+ggml_tensor * mtmd_graph::build_mm(ggml_tensor * w, ggml_tensor * x) const {
+    return ggml_mul_mat(ctx0, w, x);
+}
+
+void mtmd_graph::cb(ggml_tensor * cur, const char * name, int il) const {
+    if (il >= 0) {
+        ggml_format_name(cur, "%s-%d", name, il);
+    } else {
+        ggml_set_name(cur, name);
+    }
+}
+
+// siglip2 naflex
+ggml_tensor * mtmd_graph::resize_position_embeddings(uint32_t interpolation_mode) {
+    ggml_tensor * pos_embd = model.position_embeddings;
+    const int height       = img.ny() / patch_size;
+    const int width        = img.nx() / patch_size;
+    const uint32_t mode    = interpolation_mode;
+    const int n_per_side   = (int)std::sqrt(pos_embd->ne[1]);
+
+    GGML_ASSERT(pos_embd);
+
+    if (height == n_per_side && width == n_per_side) {
+        return pos_embd;
+    }
+
+    pos_embd = ggml_reshape_3d(ctx0, pos_embd, n_embd, n_per_side, n_per_side);  // -> (n_embd, n_per_side, n_per_side)
+    pos_embd = ggml_permute(ctx0, pos_embd, 2, 0, 1, 3);                         // -> (n_per_side, n_per_side, n_embd)
+    pos_embd = ggml_interpolate(ctx0, pos_embd, width, height, n_embd, 1, mode); // -> (width, height, n_embd)
+    pos_embd = ggml_permute(ctx0, pos_embd, 1, 2, 0, 3);                         // -> (n_embd, width, height)
+    pos_embd = ggml_cont_2d(ctx0, pos_embd, n_embd, width * height);             // -> (n_embd, width * height)
+
+    return pos_embd;
+}
+
+// build vision transformer (ViT) cgraph
+// this function should cover most of the models
+// if your model has specific features, you should probably duplicate this function
+ggml_tensor * mtmd_graph::build_vit(
+            ggml_tensor * inp,
+            int64_t n_pos,
+            norm_type norm_t,
+            ffn_op_type ffn_t,
+            ggml_tensor * learned_pos_embd,
+            std::function<ggml_tensor *(ggml_tensor *, const mtmd_layer &)> add_pos,
+            const build_vit_opts & opts
+        ) {
+    // batch dim: inp is [n_embd, n_pos, B]
+    const int64_t B = inp->ne[2];
+
+    if (learned_pos_embd) {
+        inp = ggml_add(ctx0, inp, learned_pos_embd);
+        cb(inp, "pos_embed", -1);
+    }
+
+    // flatten batch; unflatten again in attention
+    inp = ggml_reshape_2d(ctx0, inp, n_embd, n_pos * B);
+
+    ggml_tensor * inpL = inp;
+
+    // pre-layernorm
+    if (model.pre_ln_w) {
+        inpL = build_norm(inpL, model.pre_ln_w, model.pre_ln_b, norm_t, eps, -1);
+        cb(inpL, "pre_ln", -1);
+    }
+
+    // loop over layers
+    for (int il = 0; il < n_layer; il++) {
+        auto & layer = model.layers[il];
+        ggml_tensor * cur = inpL; // inpL = residual, cur = hidden_states
+
+        ggml_tensor * attn_mask = opts.attn_mask;
+        if (opts.attn_mask_layers.size() > (size_t) il) {
+            attn_mask = opts.attn_mask_layers[il];
+        }
+
+        // layernorm1
+        cur = build_norm(cur, layer.ln_1_w, layer.ln_1_b, norm_t, eps, il);
+        cb(cur, "layer_inp_normed", il);
+
+        // self-attention
+        {
+            ggml_tensor * Qcur = nullptr;
+            ggml_tensor * Kcur = nullptr;
+            ggml_tensor * Vcur = nullptr;
+            if (layer.qkv_w != nullptr) {
+                // fused qkv
+                cur = build_mm(layer.qkv_w, cur);
+                if (layer.qkv_b != nullptr) {
+                    cur = ggml_add(ctx0, cur, layer.qkv_b);
+                }
+
+                // Q/K/V as [d_head, n_head, n_pos, B], the batch stride is cur->nb[1]*n_pos.
+                Qcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
+                /* nb1    */ ggml_row_size(cur->type, d_head),
+                /* nb2    */ cur->nb[1],
+                /* nb3    */ cur->nb[1] * n_pos,
+                /* offset */ 0);
+
+                Kcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
+                /* nb1    */ ggml_row_size(cur->type, d_head),
+                /* nb2    */ cur->nb[1],
+                /* nb3    */ cur->nb[1] * n_pos,
+                /* offset */ ggml_row_size(cur->type, n_head * d_head));
+
+                Vcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
+                /* nb1    */ ggml_row_size(cur->type, d_head),
+                /* nb2    */ cur->nb[1],
+                /* nb3    */ cur->nb[1] * n_pos,
+                /* offset */ ggml_row_size(cur->type, 2 * n_head * d_head));
+
+                if (layer.q_norm) {
+                    GGML_ASSERT(layer.q_norm->ne[0] == Qcur->ne[0]);
+                    Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
+                    cb(Qcur, "Qcur_norm", il);
+                }
+
+                if (layer.k_norm) {
+                    GGML_ASSERT(layer.k_norm->ne[0] == Kcur->ne[0]);
+                    Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
+                    cb(Kcur, "Kcur_norm", il);
+                }
+
+            } else {
+                // separate q, k, v
+                Qcur = build_mm(layer.q_w, cur);
+                if (layer.q_b) {
+                    Qcur = ggml_add(ctx0, Qcur, layer.q_b);
+                }
+
+                Kcur = build_mm(layer.k_w, cur);
+                if (layer.k_b) {
+                    Kcur = ggml_add(ctx0, Kcur, layer.k_b);
+                }
+
+                Vcur = build_mm(layer.v_w, cur);
+                if (layer.v_b) {
+                    Vcur = ggml_add(ctx0, Vcur, layer.v_b);
+                }
+
+                // if true, norm must be applied after reshaping to (d_head, n_head, n_pos)
+                bool norm_per_head = layer.q_norm && layer.q_norm->ne[0] == d_head;
+
+                if (!norm_per_head) {
+                    if (layer.q_norm) {
+                        Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
+                        cb(Qcur, "Qcur_norm", il);
+                    }
+                    if (layer.k_norm) {
+                        Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
+                        cb(Kcur, "Kcur_norm", il);
+                    }
+                }
+
+                Qcur = ggml_reshape_4d(ctx0, Qcur, d_head, n_head,    n_pos, B);
+                Kcur = ggml_reshape_4d(ctx0, Kcur, d_head, n_head_kv, n_pos, B);
+                Vcur = ggml_reshape_4d(ctx0, Vcur, d_head, n_head_kv, n_pos, B);
+
+                if (norm_per_head) {
+                    if (layer.q_norm) {
+                        Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
+                        cb(Qcur, "Qcur_norm_per_head", il);
+                    }
+                    if (layer.k_norm) {
+                        Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
+                        cb(Kcur, "Kcur_norm_per_head", il);
+                    }
+                }
+            }
+
+            cb(Qcur, "Qcur", il);
+            cb(Kcur, "Kcur", il);
+            cb(Vcur, "Vcur", il);
+
+            if (add_pos) {
+                Qcur = add_pos(Qcur, layer);
+                Kcur = add_pos(Kcur, layer);
+                cb(Qcur, "Qcur_pos", il);
+                cb(Kcur, "Kcur_pos", il);
+            }
+
+            if (proj_type == PROJECTOR_TYPE_GEMMA4V) {
+                Vcur = ggml_rms_norm(ctx0, Vcur, eps);
+                cb(Vcur, "Vcur_normed", il);
+            }
+
+            // build_attn returns a flat 2D [n_embd, n_pos*B]
+            cur = build_attn(layer.o_w, layer.o_b,
+                Qcur, Kcur, Vcur, attn_mask, kq_scale, il);
+            cb(cur, "attn_out", il);
+        }
+
+        if (layer.ls_1_w) {
+            cur = ggml_mul(ctx0, cur, layer.ls_1_w);
+            cb(cur, "attn_out_scaled", il);
+        }
+
+        if (layer.attn_post_norm_w) {
+            cur = build_norm(cur, layer.attn_post_norm_w, nullptr, norm_t, eps, il);
+            cb(cur, "attn_post_normed", il);
+        }
+
+        // re-add the layer input, e.g., residual
+        cur = ggml_add(ctx0, cur, inpL);
+
+        inpL = cur; // inpL = residual, cur = hidden_states
+
+        if (opts.callback_layer_out) {
+            opts.callback_layer_out(cur, il);
+        }
+
+        cb(cur, "ffn_inp", il);
+
+        // layernorm2 (pre-ffn norm)
+        cur = build_norm(cur, layer.ln_2_w, layer.ln_2_b, norm_t, eps, il);
+        cb(cur, "ffn_inp_normed", il);
+
+        // ffn
+        cur = layer.ff_gate_exps_w
+            ? build_moe_ffn(cur, layer, ffn_t, il)
+            : build_ffn(cur,
+                layer.ff_up_w, layer.ff_up_b,
+                layer.ff_gate_w, layer.ff_gate_b,
+                layer.ff_down_w, layer.ff_down_b,
+                ffn_t, il);
+
+        cb(cur, "ffn_out", il);
+
+        if (layer.ff_post_norm_w) {
+            cur = build_norm(cur, layer.ff_post_norm_w, nullptr, norm_t, eps, il);
+            cb(cur, "ffn_post_normed", il);
+        }
+
+        if (layer.ls_2_w) {
+            cur = ggml_mul(ctx0, cur, layer.ls_2_w);
+            cb(cur, "ffn_out_scaled", il);
+        }
+
+        // residual 2
+        cur = ggml_add(ctx0, inpL, cur);
+        cb(cur, "layer_out", il);
+
+        if (layer.ls_out_w) {
+            cur = ggml_mul(ctx0, cur, layer.ls_out_w);
+            cb(cur, "layer_out_scaled", il);
+        }
+
+        inpL = cur;
+    }
+
+    if (model.audio_has_avgpool()) {
+        ggml_tensor * cur = inpL;
+        cur = ggml_transpose(ctx0, cur);
+        cur = ggml_cont(ctx0, cur);
+        cur = ggml_pool_1d(ctx0, cur, GGML_OP_POOL_AVG, 2, 2, 0);
+        cur = ggml_transpose(ctx0, cur);
+        cur = ggml_cont(ctx0, cur);
+        inpL = cur;
+    }
+
+    // post-layernorm
+    if (model.post_ln_w && !opts.skip_post_ln) {
+        inpL = build_norm(inpL, model.post_ln_w, model.post_ln_b, norm_t, eps, -1);
+    }
+
+    // restore the batch dim
+    GGML_ASSERT(inpL->ne[1] % B == 0);
+    inpL = ggml_reshape_3d(ctx0, inpL, n_embd, inpL->ne[1] / B, B);
+    return inpL;
+}
+
+// build the input after conv2d (inp_raw --> patches)
+// returns tensor with shape [n_embd, n_patches]
+ggml_tensor * mtmd_graph::build_inp() {
+    ggml_tensor * inp_raw = build_inp_raw();
+    ggml_tensor * inp = ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_raw, patch_size, patch_size, 0, 0, 1, 1);
+    inp = ggml_reshape_3d(ctx0, inp, n_patches, n_embd, n_batch);
+    inp = ggml_cont(ctx0, ggml_transpose(ctx0, inp));
+    if (model.patch_bias) {
+        inp = ggml_add(ctx0, inp, model.patch_bias);
+        cb(inp, "patch_bias", -1);
+    }
+    return inp;
+}
+
+ggml_tensor * mtmd_graph::build_inp_raw(int channels) {
+    ggml_tensor * inp_raw = ggml_new_tensor_4d(ctx0, GGML_TYPE_F32, img.nx(), img.ny(), channels, n_batch);
+    ggml_set_name(inp_raw, "inp_raw");
+    ggml_set_input(inp_raw);
+    return inp_raw;
+}
+
+// qwen-vl style temporal merge: Conv3D(kt=2) split into 2 Conv2D
+ggml_tensor * mtmd_graph::build_inp_temporal_merge() {
+    ggml_tensor * inp_raw = build_inp_raw();
+
+    GGML_ASSERT(img.nx() % (patch_size * 2) == 0);
+    GGML_ASSERT(img.ny() % (patch_size * 2) == 0);
+
+    const size_t nb1 = ggml_row_size(inp_raw->type, img.nx());
+    const size_t nb2 = ggml_row_size(inp_raw->type, img.nx() * img.ny());
+
+    if (n_batch == 1) {
+        // still image input
+        return ggml_add(ctx0,
+            ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_raw, patch_size, patch_size, 0, 0, 1, 1),
+            ggml_conv_2d(ctx0, model.patch_embeddings_1, inp_raw, patch_size, patch_size, 0, 0, 1, 1));
+    } else if (n_batch == 2) {
+        // 2 frames input (video input)
+        ggml_tensor * inp_0 = ggml_view_3d(ctx0, inp_raw,
+                                    img.nx(), img.ny(), 3, nb1, nb2, 0);
+        ggml_tensor * inp_1 = ggml_view_3d(ctx0, inp_raw,
+                                    img.nx(), img.ny(), 3, nb1, nb2,
+                                    nb2 * 3); // move to the second frame
+        return ggml_add(ctx0,
+            ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_0, patch_size, patch_size, 0, 0, 1, 1),
+            ggml_conv_2d(ctx0, model.patch_embeddings_1, inp_1, patch_size, patch_size, 0, 0, 1, 1));
+    } else {
+        GGML_ASSERT(false && "n_batch > 2 is not supported");
+    }
+}
+
+ggml_tensor * mtmd_graph::build_inp_attn_mask(int64_t n_kv, int64_t n_q, int idx) {
+    const ggml_type type = flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED ? GGML_TYPE_F16 : GGML_TYPE_F32;
+    ggml_tensor * mask = ggml_new_tensor_2d(ctx0, type, n_kv, n_q);
+    ggml_set_name(mask, get_attn_mask_name(idx).c_str());
+    ggml_set_input(mask);
+    return mask;
+}
+
+ggml_tensor * mtmd_graph::build_norm(
+        ggml_tensor * cur,
+        ggml_tensor * mw,
+        ggml_tensor * mb,
+        norm_type type,
+        float norm_eps,
+        int il) const {
+
+    cur = type == NORM_TYPE_RMS
+        ? ggml_rms_norm(ctx0, cur, norm_eps)
+        : ggml_norm(ctx0, cur, norm_eps);
+
+    if (mw) {
+        cur = ggml_mul(ctx0, cur, mw);
+        cb(cur, "norm_w", il);
+    }
+
+    if (mb) {
+        cur = ggml_add(ctx0, cur, mb);
+        cb(cur, "norm_b", il);
+    }
+
+    return cur;
+}
+
+ggml_tensor * mtmd_graph::build_ffn(
+        ggml_tensor * cur,
+        ggml_tensor * up,
+        ggml_tensor * up_b,
+        ggml_tensor * gate,
+        ggml_tensor * gate_b,
+        ggml_tensor * down,
+        ggml_tensor * down_b,
+        ffn_op_type type_op,
+        int il) const {
+
+    ggml_tensor * tmp = up ? build_mm(up, cur) : cur;
+    cb(tmp, "ffn_up", il);
+
+    if (up_b) {
+        tmp = ggml_add(ctx0, tmp, up_b);
+        cb(tmp, "ffn_up_b", il);
+    }
+
+    if (gate) {
+        cur = build_mm(gate, cur);
+        cb(cur, "ffn_gate", il);
+
+        if (gate_b) {
+            cur = ggml_add(ctx0, cur, gate_b);
+            cb(cur, "ffn_gate_b", il);
+        }
+    } else {
+        cur = tmp;
+    }
+
+    // we only support parallel ffn for now
+    switch (type_op) {
+        case FFN_SILU:
+            if (gate) {
+                cur = hparams.swiglu_clamp > 0.0f
+                    ? ggml_swiglu_clamp(ctx0, cur, tmp, hparams.swiglu_clamp)
+                    : ggml_swiglu_split(ctx0, cur, tmp);
+                cb(cur, "ffn_swiglu", il);
+            } else {
+                cur = ggml_silu(ctx0, cur);
+                cb(cur, "ffn_silu", il);
+            } break;
+        case FFN_GELU:
+            if (gate) {
+                cur = ggml_geglu_split(ctx0, cur, tmp);
+                cb(cur, "ffn_geglu", il);
+            } else {
+                cur = ggml_gelu(ctx0, cur);
+                cb(cur, "ffn_gelu", il);
+            } break;
+        case FFN_GELU_ERF:
+            if (gate) {
+                cur = ggml_geglu_erf_split(ctx0, cur, tmp);
+                cb(cur, "ffn_geglu_erf", il);
+            } else {
+                cur = ggml_gelu_erf(ctx0, cur);
+                cb(cur, "ffn_gelu_erf", il);
+            } break;
+        case FFN_GELU_QUICK:
+            if (gate) {
+                cur = ggml_geglu_quick_split(ctx0, cur, tmp);
+                cb(cur, "ffn_geglu_quick", il);
+            } else {
+                cur = ggml_gelu_quick(ctx0, cur);
+                cb(cur, "ffn_gelu_quick", il);
+            } break;
+        case FFN_RELU_SQR:
+            {
+                cur = ggml_relu(ctx0, cur);
+                cur = ggml_sqr(ctx0, cur);
+                cb(cur, "ffn_relu_sqr", il);
+            } break;
+    }
+
+    if (down) {
+        cur = build_mm(down, cur);
+    }
+
+    if (down_b) {
+        cb(cur, "ffn_down", il);
+    }
+
+    if (down_b) {
+        cur = ggml_add(ctx0, cur, down_b);
+    }
+
+    return cur;
+}
+
+// MoE FFN with sigmoid router and normalized top-k weights (dots3note vision)
+// the router runs in fp32; exp_probs_b only affects expert selection, not the weights
+ggml_tensor * mtmd_graph::build_moe_ffn(ggml_tensor * cur, const mtmd_layer & layer, ffn_op_type type_op, int il) const {
+    const int64_t n_tokens      = cur->ne[1];
+    const int64_t n_expert      = layer.ff_gate_exps_w->ne[2];
+    const int64_t n_expert_used = std::min((int64_t) hparams.n_expert_used, n_expert);
+    GGML_ASSERT(n_expert_used > 0);
+    GGML_ASSERT(type_op == FFN_SILU);
+
+    ggml_tensor * probs = ggml_sigmoid(ctx0, build_mm(layer.ff_gate_inp_w, cur)); // [n_expert, n_tokens]
+    cb(probs, "ffn_moe_probs", il);
+
+    ggml_tensor * sel = layer.ff_exp_probs_b
+        ? ggml_add(ctx0, probs, layer.ff_exp_probs_b)
+        : probs;
+    ggml_tensor * selected = ggml_top_k(ctx0, sel, n_expert_used); // [n_expert_used, n_tokens]
+
+    ggml_tensor * weights = ggml_get_rows(ctx0,
+        ggml_reshape_3d(ctx0, probs, 1, n_expert, n_tokens), selected);
+    weights = ggml_reshape_2d(ctx0, weights, n_expert_used, n_tokens);
+    weights = ggml_div(ctx0, weights, ggml_sum_rows(ctx0, weights));
+    weights = ggml_reshape_3d(ctx0, weights, 1, n_expert_used, n_tokens);
+    cb(weights, "ffn_moe_weights", il);
+
+    cur = ggml_reshape_3d(ctx0, cur, cur->ne[0], 1, n_tokens);
+    ggml_tensor * gate = ggml_mul_mat_id(ctx0, layer.ff_gate_exps_w, cur, selected); // [n_ff, n_expert_used, n_tokens]
+    ggml_tensor * up   = ggml_mul_mat_id(ctx0, layer.ff_up_exps_w,   cur, selected);
+    cur = ggml_mul(ctx0, ggml_silu(ctx0, gate), up);
+    cur = ggml_mul_mat_id(ctx0, layer.ff_down_exps_w, cur, selected); // [n_embd, n_expert_used, n_tokens]
+    cur = ggml_mul(ctx0, cur, weights);
+
+    // sum over the selected experts
+    ggml_tensor * out = nullptr;
+    for (int64_t i = 0; i < n_expert_used; i++) {
+        ggml_tensor * v = ggml_view_2d(ctx0, cur, cur->ne[0], n_tokens, cur->nb[2], i * cur->nb[1]);
+        out = out ? ggml_add(ctx0, out, v) : v;
+    }
+    if (n_expert_used == 1) {
+        out = ggml_cont(ctx0, out);
+    }
+    cb(out, "ffn_moe_out", il);
+    return out;
+}
+
+ggml_tensor * mtmd_graph::build_attn(
+        ggml_tensor * wo,
+        ggml_tensor * wo_b,
+        ggml_tensor * q_cur,
+        ggml_tensor * k_cur,
+        ggml_tensor * v_cur,
+        ggml_tensor * kq_mask,
+        float kq_scale,
+        int il,
+        ggml_tensor * sinks) const {
+    // these nodes are added to the graph together so that they are not reordered
+    // by doing so, the number of splits in the graph is reduced
+    // the order is fixed without the compute flag, so an unselected branch stays out of the compute set
+    ggml_build_forward_order(gf, q_cur);
+    ggml_build_forward_order(gf, k_cur);
+    ggml_build_forward_order(gf, v_cur);
+
+    ggml_tensor * q = ggml_permute(ctx0, q_cur, 0, 2, 1, 3);
+    //cb(q, "q", il);
+
+    ggml_tensor * k = ggml_permute(ctx0, k_cur, 0, 2, 1, 3);
+    //cb(k, "k", il);
+
+    ggml_tensor * cur;
+
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
+        ggml_tensor * v = ggml_permute(ctx0, v_cur, 0, 2, 1, 3);
+
+        k = ggml_cast(ctx0, k, GGML_TYPE_F16);
+        v = ggml_cast(ctx0, v, GGML_TYPE_F16);
+        // mask must be f16 here, use build_inp_attn_mask()
+        GGML_ASSERT(!kq_mask || kq_mask->type == GGML_TYPE_F16);
+
+        cur = ggml_flash_attn_ext(ctx0, q, k, v, kq_mask, kq_scale, 0.0f, 0.0f);
+        ggml_prec_set_acc(cur, GGML_PREC_F32);
+        if (sinks != nullptr) {
+            ggml_flash_attn_ext_add_sinks(cur, sinks);
+        }
+
+        cur = ggml_reshape_2d(ctx0, cur, cur->ne[0]*cur->ne[1], cur->ne[2]*cur->ne[3]);
+
+    } else {
+        ggml_tensor * v = ggml_permute(ctx0, v_cur, 1, 2, 0, 3);
+        v = ggml_cont(ctx0, v);
+
+        ggml_tensor * kq = ggml_mul_mat(ctx0, k, q);
+        // TODO: this most likely requires GGML_PREC_BF16, but will enable it later to avoid scope-creep
+        // ggml_prec_set_acc(kq, GGML_PREC_BF16);
+
+        kq = ggml_soft_max_ext(ctx0, kq, kq_mask, kq_scale, 0.0f);
+        if (sinks != nullptr) {
+            ggml_soft_max_add_sinks(kq, sinks);
+        }
+
+        ggml_tensor * kqv = ggml_mul_mat(ctx0, v, kq);
+        cur = ggml_permute(ctx0, kqv, 0, 2, 1, 3);
+        cur = ggml_cont_2d(ctx0, cur, cur->ne[0] * cur->ne[1], cur->ne[2] * cur->ne[3]);
+    }
+
+    cb(cur, "kqv_out", il);
+
+    if (wo) {
+        cur = build_mm(wo, cur);
+    }
+
+    if (wo_b) {
+        cur = ggml_add(ctx0, cur, wo_b);
+    }
+
+    return cur;
+}
+
+// implementation of the 2D RoPE without adding a new op in ggml
+ggml_tensor * mtmd_graph::build_rope_2d(
+    ggml_context * ctx0,
+    ggml_tensor * cur,
+    ggml_tensor * pos_a, // first half
+    ggml_tensor * pos_b, // second half
+    const float freq_base,
+    const bool interleave_freq
+) {
+    const int64_t n_dim = cur->ne[0];
+
+    // for example, if we have cur tensor of shape (n_dim=8, n_head, n_pos)
+    // we will have a list of 4 inv_freq: 1e-0, 1e-1, 1e-2, 1e-3
+    // first half of cur will use 1e-0, 1e-2 (even)
+    // second half of cur will use 1e-1, 1e-3 (odd)
+    // the trick here is to rotate just half of n_dim, so inv_freq will automatically be even
+    //  ^ don't ask me why, it's math! -2(2i) / n_dim == -2i / (n_dim/2)
+    // then for the second half, we use freq_scale to shift the inv_freq
+    //  ^ why? replace (2i) with (2i+1) in the above equation
+    const float freq_scale_odd = interleave_freq
+                                ? std::pow(freq_base, (float)-2/n_dim)
+                                : 1.0;
+
+    // first half, dims [0, n_dim/2)
+    cur = ggml_rope_ext(
+        ctx0,
+        cur,
+        pos_a,      // positions
+        nullptr,    // freq factors
+        n_dim/2,    // n_dims
+        0, 0, freq_base,
+        1.0f, 0.0f, 1.0f, 0.0f, 0.0f
+    );
+
+    // second half, dims [n_dim/2, n_dim)
+    cur = ggml_rope_ext(
+        ctx0,
+        cur,
+        pos_b,      // positions
+        nullptr,    // freq factors
+        n_dim/2,    // n_dims
+        0, 0, freq_base,
+        freq_scale_odd,
+        0.0f, 1.0f, 0.0f, 0.0f
+    );
+    cur = ggml_rope_set_offset(cur, n_dim/2);
+
+    return cur;
+}
+
+// Generic function to stack frames for audio processing
+// Abstracts out the StackAudioFrames logic used by ultravox
+ggml_tensor * mtmd_graph::build_stack(ggml_tensor * cur, int32_t stack_factor, int32_t n_embed) {
+    if (stack_factor <= 1) {
+        return cur;
+    }
+
+    int64_t total_elements = ggml_nelements(cur);
+    int64_t stride = n_embed * stack_factor;
+
+    // Calculate padded length
+    int64_t padded_len = GGML_PAD(total_elements, stride);
+    int64_t pad = padded_len - total_elements;
+
+    if (pad > 0) {
+        // Pad the tensor to make it divisible by stride
+        cur = ggml_view_1d(ctx0, cur, total_elements, 0);
+        cur = ggml_pad(ctx0, cur, pad, 0, 0, 0);
+    }
+
+    // Reshape to [stride, padded_len / stride]
+    cur = ggml_view_2d(ctx0, cur, stride, padded_len / stride,
+                        ggml_row_size(cur->type, stride), 0);
+    return cur;
+}
+
+// aka pixel_shuffle / pixel_unshuffle / patch_merger (Kimi-VL)
+// support dynamic resolution
+ggml_tensor * mtmd_graph::build_suffix(ggml_tensor * cur) {
+    for (int idx : mtmd_suffix_rows(img.suffix_type)) {
+        GGML_ASSERT(model.tok_embd_sep && idx < model.tok_embd_sep->ne[1]);
+        ggml_tensor * row = ggml_view_2d(ctx0, model.tok_embd_sep, model.tok_embd_sep->ne[0], 1,
+                                         model.tok_embd_sep->nb[1], idx * model.tok_embd_sep->nb[1]);
+        cur = ggml_concat(ctx0, cur, ggml_cast(ctx0, row, cur->type), 1);
+    }
+    return cur;
+}
+
+ggml_tensor * mtmd_graph::build_patch_merge_permute(ggml_tensor * cur, int scale_factor) {
+    GGML_ASSERT(scale_factor > 1);
+
+    const int n_embd = cur->ne[0];
+    int width  = img.nx() / patch_size;
+    int height = img.ny() / patch_size;
+
+    // pad width and height to factor
+    const int64_t pad_width  = MTMD_ALIGN(width,  scale_factor) - width;
+    const int64_t pad_height = MTMD_ALIGN(height, scale_factor) - height;
+    cur = ggml_reshape_3d(ctx0, cur, n_embd, width, height);
+    if (pad_width || pad_height) {
+        cur     = ggml_pad(ctx0, cur, 0, pad_width, pad_height, 0);
+        width  += pad_width;
+        height += pad_height;
+    }
+
+    // unshuffle h
+    cur = ggml_reshape_3d(ctx0, cur, n_embd * scale_factor, width / scale_factor, height);
+    cur = ggml_permute(ctx0, cur, 0, 2, 1, 3);
+
+    // unshuffle w
+    cur = ggml_cont_3d(ctx0, cur, n_embd * scale_factor * scale_factor, height / scale_factor, width / scale_factor);
+    cur = ggml_permute(ctx0, cur, 0, 2, 1, 3);
+
+    cur = ggml_cont_2d(ctx0, cur, cur->ne[0], cur->ne[1] * cur->ne[2]);
+    cb(cur, "pixel_shuffle", -1);
+
+    return cur;
+}
+
+std::unique_ptr<mtmd_graph> mtmd_get_graph_builder(mtmd_subcontext * ctx, const mtmd_image_f32_batch & imgs,
+                                                   const mtmd_subctx_encode_params * params) {
+    const mtmd_image_f32 & img = imgs.entries[0];
+    std::unique_ptr<mtmd_graph> builder;
+
+    switch (ctx->model.proj_type) {
+        case PROJECTOR_TYPE_GEMMA3:
+        case PROJECTOR_TYPE_IDEFICS3:
+        case PROJECTOR_TYPE_COHERE2V:
+        case PROJECTOR_TYPE_LFM2:
+        case PROJECTOR_TYPE_D1OMNI_V:
+        case PROJECTOR_TYPE_JANUS_PRO:
+        case PROJECTOR_TYPE_PHI4:
+            {
+                builder = std::make_unique<mtmd_graph_siglip>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GEMMA3NV:
+            {
+                builder = std::make_unique<mtmd_graph_mobilenetv5>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4V:
+            {
+                builder = std::make_unique<mtmd_graph_gemma4v>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4UV:
+            {
+                builder = std::make_unique<mtmd_graph_gemma4uv>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_PIXTRAL:
+        case PROJECTOR_TYPE_LIGHTONOCR:
+            {
+                builder = std::make_unique<mtmd_graph_pixtral>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_DOTS_OCR:
+        case PROJECTOR_TYPE_DOTS3NOTE_V: // same ViT + merger; pyramid MoE is handled by build_vit
+            {
+                builder = std::make_unique<mtmd_graph_dotsocr>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_DOTS3NOTE_A:
+            {
+                builder = std::make_unique<mtmd_graph_dots3note_a>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_QWEN2VL:
+        case PROJECTOR_TYPE_QWEN25VL:
+            {
+                builder = std::make_unique<mtmd_graph_qwen2vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_QWEN3VL:
+            {
+                builder = std::make_unique<mtmd_graph_qwen3vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MAGEVL:
+            {
+                builder = std::make_unique<mtmd_graph_magevl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_SENSENOVA_U1:
+            {
+                builder = std::make_unique<mtmd_graph_sensenova_u1>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_LFM3VL:
+            {
+                builder = std::make_unique<mtmd_graph_lfm3vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_LING3VL:
+            {
+                builder = std::make_unique<mtmd_graph_ling3vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_EXAONE4_5:
+            {
+                builder = std::make_unique<mtmd_graph_exaone4_5>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MIMOVL:
+            {
+                builder = std::make_unique<mtmd_graph_mimovl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MINIMAX_M3:
+            {
+                builder = std::make_unique<mtmd_graph_minimax_m3>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MUSE_GLIMMER:
+            {
+                builder = std::make_unique<mtmd_graph_muse_glimmer>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_STEP3VL:
+            {
+                builder = std::make_unique<mtmd_graph_step3vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MINICPMV:
+            {
+                builder = std::make_unique<mtmd_graph_minicpmv>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MINICPMV4_6:
+        case PROJECTOR_TYPE_MINICPMV4_7:
+        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
+            {
+                builder = std::make_unique<mtmd_graph_minicpmv4_6>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_INTERNVL:
+            {
+                builder = std::make_unique<mtmd_graph_internvl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
+            {
+                builder = std::make_unique<mtmd_graph_nemotron_v2_vl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_NEMOTRON_OMNI:
+            {
+                builder = std::make_unique<mtmd_graph_nemotron_omni>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_LLAMA4:
+            {
+                builder = std::make_unique<mtmd_graph_llama4>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_VOXTRAL_RT:
+            {
+                builder = std::make_unique<mtmd_graph_voxtral_rt>(ctx, img, params ? params->enc_state : nullptr);
+            } break;
+        case PROJECTOR_TYPE_ULTRAVOX:
+        case PROJECTOR_TYPE_VOXTRAL:
+        case PROJECTOR_TYPE_QWEN2A:
+        case PROJECTOR_TYPE_GLMA:
+        case PROJECTOR_TYPE_MERALION:
+        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
+            {
+                builder = std::make_unique<mtmd_graph_whisper_enc>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
+            {
+                builder = std::make_unique<mtmd_graph_muse_spark_audio>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_KIMIVL:
+            {
+                builder = std::make_unique<mtmd_graph_kimivl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_PADDLEOCR:
+            {
+                builder = std::make_unique<mtmd_graph_paddleocr>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_KIMIK25:
+            {
+                builder = std::make_unique<mtmd_graph_kimik25>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEK4V:
+        case PROJECTOR_TYPE_DEEPSEEK41V:
+            {
+                builder = std::make_unique<mtmd_graph_deepseek4v>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_COGVLM:
+            {
+                builder = std::make_unique<mtmd_graph_cogvlm>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_HUNYUANVL:
+            {
+                builder = std::make_unique<mtmd_graph_hunyuanvl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MLP:
+        case PROJECTOR_TYPE_MLP_NORM:
+        case PROJECTOR_TYPE_LDP:
+        case PROJECTOR_TYPE_LDPV2:
+        case PROJECTOR_TYPE_GLM_EDGE:
+            {
+                builder = std::make_unique<mtmd_graph_llava>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEKOCR:
+            {
+                builder = std::make_unique<mtmd_graph_deepseekocr>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEKOCR2:
+             {
+                builder = std::make_unique<mtmd_graph_deepseekocr2>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_LFM2A:
+        case PROJECTOR_TYPE_D1OMNI_A:
+            {
+                builder = std::make_unique<mtmd_graph_conformer>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_LFM3A:
+            {
+                builder = std::make_unique<mtmd_graph_lfm3a>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4A:
+            {
+                builder = std::make_unique<mtmd_graph_gemma4a>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4UA:
+            {
+                builder = std::make_unique<mtmd_graph_gemma4ua>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GRANITE_SPEECH:
+            {
+                builder = std::make_unique<mtmd_graph_granite_speech>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_GLM4V:
+        case PROJECTOR_TYPE_GLM5V:
+            {
+                builder = std::make_unique<mtmd_graph_glm4v>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_QWEN3A:
+            {
+                builder = std::make_unique<mtmd_graph_qwen3a>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_MIMO_AUDIO:
+            {
+                builder = std::make_unique<mtmd_graph_mimo_audio>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
+            {
+                builder = std::make_unique<mtmd_graph_qwen3tts_spkenc>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
+            {
+                builder = std::make_unique<mtmd_graph_pockettts_spkenc>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_POCKETTTS_GEN:
+            {
+                const auto gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int  n_step = ctx->model.hparams.flow_n_step;
+                const int64_t n_latent = ctx->model.gen_input_lin_w->ne[0];
+                GGML_ASSERT(n_step > 0);
+                GGML_ASSERT(n_latent > 0);
+                // "inp_feats" takes the caller's buffer as-is, the graph must consume all of it
+                if (params && params->feats) {
+                    GGML_ASSERT(params->feats->size() % (size_t) n_latent == 0);
+                    GGML_ASSERT(params->feats->size() >= (size_t) n_latent);
+                }
+                const int  n_frames = params && params->feats ? (int) (params->feats->size() / n_latent) : 1;
+                builder = std::make_unique<mtmd_graph_pockettts_gen>(ctx, img, gen_process, n_step, n_frames);
+            } break;
+        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
+            {
+                builder = std::make_unique<mtmd_graph_chatterbox_spkenc>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_CHATTERBOX:
+            {
+                const auto gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                int n_tokens = params && params->codes ? (int) params->codes->size() : 0;
+                int n_prompt_mel = 0;
+                if (n_tokens > 0) {
+                    GGML_ASSERT(params->ref);
+                    n_tokens += (int) params->ref->tokens.size();
+                    n_prompt_mel = (int) (params->ref->feat.size() / 80);
+                }
+                const int vnm = params ? params->vocode_n_mel  : 0;
+                const int vns = params ? params->vocode_n_stft : 0;
+                builder = std::make_unique<mtmd_graph_chatterbox>(ctx, img, gen_process, n_tokens, n_prompt_mel, vnm, vns);
+            } break;
+        case PROJECTOR_TYPE_LFM3A_GEN:
+            {
+                const auto    gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int64_t n_cb        = ctx->model.gen_code_embd_w->ne[2];
+                bool all_known = false;
+                int  n_frames  = 1;
+                if (params && params->codes) {
+                    GGML_ASSERT(params->codes->size() % (size_t) n_cb == 0);
+                    if (gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                        n_frames = (int) (params->codes->size() / n_cb);
+                        GGML_ASSERT(n_frames > 0);
+                    } else {
+                        // -1 marks a masked codebook, none masked means only the feedback embedding is needed
+                        all_known = std::all_of(params->codes->begin(), params->codes->end(), [](int32_t c) { return c >= 0; });
+                    }
+                }
+                builder = std::make_unique<mtmd_graph_lfm3a_gen>(ctx, img, gen_process, all_known, n_frames);
+            } break;
+        case PROJECTOR_TYPE_DRAMA3_GEN:
+            {
+                const auto gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int64_t n_cb = ctx->model.gen_code_embd_w->ne[2];
+                const int n_frames = params && params->codes ? (int) (params->codes->size() / n_cb) : 1;
+                builder = std::make_unique<mtmd_graph_drama3_gen>(ctx, img, gen_process, params ? params->gen_state : nullptr, n_frames,
+                                                                  params ? params->top_k : 30, params ? params->top_p : 0.9f,
+                                                                  params ? params->temp : 1.0f);
+            } break;
+        case PROJECTOR_TYPE_QWEN3TTS_GEN:
+            {
+                const auto  gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int   top_k = params ? params->top_k : 50;
+                const float top_p = params ? params->top_p : 1.0f;
+                builder = std::make_unique<mtmd_graph_qwen3tts_gen>(ctx, img, gen_process, top_k, top_p);
+            } break;
+        case PROJECTOR_TYPE_SOPRANO:
+            {
+                const int n_frames = params && params->feats ? (int) (params->feats->size() / ctx->model.hparams.projection_dim) : 2;
+                builder = std::make_unique<mtmd_graph_soprano>(ctx, img, n_frames);
+            } break;
+        case PROJECTOR_TYPE_YOUTUVL:
+            {
+                builder = std::make_unique<mtmd_graph_youtuvl>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_YASA2:
+            {
+                builder = std::make_unique<mtmd_graph_yasa2>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_PARAKEET:
+            {
+                builder = std::make_unique<mtmd_graph_parakeet>(ctx, img);
+            } break;
+        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+            {
+                builder = std::make_unique<mtmd_graph_nemotron_transcribe>(ctx, img, params ? params->enc_state : nullptr);
+            } break;
+        case PROJECTOR_TYPE_GRANITE4_VISION:
+            {
+                builder = std::make_unique<mtmd_graph_granite4_vision>(ctx, img);
+            } break;
+        default:
+            GGML_ABORT("missing cgraph builder");
+    }
+
+    builder->img_batch = &imgs;
+
+    // TODO [QWEN_VIDEO]: improve this in the future
+    builder->n_batch = imgs.entries.size();
+
+    return builder;
+}
diff --git a/tools/mtmd/clip-graph.h b/tools/mtmd/mtmd-graph.h
similarity index 84%
rename from tools/mtmd/clip-graph.h
rename to tools/mtmd/mtmd-graph.h
index a0669d1a0..41765d03f 100644
--- a/tools/mtmd/clip-graph.h
+++ b/tools/mtmd/mtmd-graph.h
@@ -2,9 +2,9 @@

 #include "ggml.h"
 #include "ggml-cpp.h"
-#include "clip.h"
-#include "clip-impl.h"
-#include "clip-model.h"
+#include "mtmd-subcontext.h"
+#include "mtmd-impl.h"
+#include "mtmd-model.h"

 #include <vector>
 #include <functional>
@@ -23,13 +23,13 @@ struct build_vit_opts {
     bool skip_post_ln = false;
 };

-struct clip_graph {
-    const clip_model & model;
-    const clip_hparams & hparams;
+struct mtmd_graph {
+    const mtmd_model & model;
+    const mtmd_hparams & hparams;
     projector_type proj_type;

-    const clip_image_f32 & img; // for backward compat
-    const clip_image_f32_batch * img_batch = nullptr;
+    const mtmd_image_f32 & img; // for backward compat
+    const mtmd_image_f32_batch * img_batch = nullptr;

     const int patch_size;
     const int n_patches_x;
@@ -43,7 +43,7 @@ struct clip_graph {
     const int n_mmproj_embd;
     const float eps;
     float kq_scale; // TODO: maybe move this to hparams
-    const clip_flash_attn_type flash_attn_type;
+    const mtmd_flash_attn_type flash_attn_type;

     // TODO [QWEN_VIDEO]: improve this in the future
     int n_batch = 1;
@@ -52,12 +52,12 @@ struct clip_graph {
     ggml_context * ctx0;
     ggml_cgraph * gf;

-    clip_graph(clip_ctx * ctx, const clip_image_f32 & img);
+    mtmd_graph(mtmd_subcontext * ctx, const mtmd_image_f32 & img);

     // build sub-graph, reuse buf from parent
-    clip_graph(const clip_graph & parent);
+    mtmd_graph(const mtmd_graph & parent);

-    virtual ~clip_graph() = default;
+    virtual ~mtmd_graph() = default;
     virtual ggml_cgraph * build() = 0;

     // wrapper around ggml_mul_mat, allow hooking (e.g. LoRA, clamping) depending on the model
@@ -74,7 +74,7 @@ struct clip_graph {
     //
     void cb(ggml_tensor * cur0, const char * name, int il) const;

-    const clip_image_f32 & get_img(size_t idx) const {
+    const mtmd_image_f32 & get_img(size_t idx) const {
         GGML_ASSERT(img_batch);
         GGML_ASSERT(idx < img_batch->entries.size());
         return img_batch->entries[idx];
@@ -92,7 +92,7 @@ struct clip_graph {
                 norm_type norm_t,
                 ffn_op_type ffn_t,
                 ggml_tensor * learned_pos_embd,
-                std::function<ggml_tensor *(ggml_tensor *, const clip_layer &)> add_pos,
+                std::function<ggml_tensor *(ggml_tensor *, const mtmd_layer &)> add_pos,
                 const build_vit_opts & opts = {});

     // build the input after conv2d (inp_raw --> patches)
@@ -130,7 +130,7 @@ struct clip_graph {

     ggml_tensor * build_moe_ffn(
             ggml_tensor * cur,
-            const clip_layer & layer,
+            const mtmd_layer & layer,
             ffn_op_type type_op,
             int il) const;

@@ -174,3 +174,11 @@ struct clip_graph {
     // append the separators of img.suffix_type after the image tokens
     ggml_tensor * build_suffix(ggml_tensor * cur);
 };
+
+static inline std::string get_attn_mask_name(int idx) {
+    return idx == 0 ? "attn_mask" : "attn_mask_" + std::to_string(idx);
+}
+
+// create the graph builder of the subcontext's projector, params is only needed by encode()
+std::unique_ptr<mtmd_graph> mtmd_get_graph_builder(mtmd_subcontext * ctx, const mtmd_image_f32_batch & imgs,
+                                                   const mtmd_subctx_encode_params * params = nullptr);
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
index d776ed1e8..acdee5d12 100644
--- a/tools/mtmd/mtmd-helper-gen.cpp
+++ b/tools/mtmd/mtmd-helper-gen.cpp
@@ -494,7 +494,7 @@ private:
     llama_token tts_eos    = LLAMA_TOKEN_NULL;
     std::vector<float> tok_embd; // whole token embedding matrix, n_vocab * n_embd

-    // must match hparams.wav_tfm_swa hardcoded in clip.cpp
+    // must match hparams.wav_tfm_swa hardcoded in mtmd-model.cpp
     size_t window_frames = 72;

     // per-generation state, cleared by reset()
@@ -2291,7 +2291,7 @@ private:
     // repetition-aware sampling: if a semantic draw repeats one of the last ras_window main tokens, draw again with fixed settings
     static constexpr size_t ras_window = 16;
     static constexpr size_t n_cb       = 10;
-    // codebook tower KV rows (clip_graph_drama3_gen::n_pos_max): one per prompt row but the last, one per frame
+    // codebook tower KV rows (mtmd_graph_drama3_gen::n_pos_max): one per prompt row but the last, one per frame
     static constexpr size_t n_tower_rows = 4096;
     // frames per codec call when streaming (0.33 s)
     static constexpr size_t stream_frames = 7;
diff --git a/tools/mtmd/mtmd-image.cpp b/tools/mtmd/mtmd-image.cpp
index 8c0f433e3..b0543aa43 100644
--- a/tools/mtmd/mtmd-image.cpp
+++ b/tools/mtmd/mtmd-image.cpp
@@ -4,8 +4,8 @@
 #include <cmath>
 #include <vector>

-void mtmd_image_preproc_out::append(const clip_hparams & hparams, const clip_image_u8 & img, bool normalized) {
-    clip_image_f32 dst;
+void mtmd_image_preproc_out::append(const mtmd_hparams & hparams, const mtmd_image_u8 & img, bool normalized) {
+    mtmd_image_f32 dst;
     dst.from_u8(img);
     if (normalized) {
         dst.normalize(hparams.image_mean, hparams.image_std);
@@ -13,20 +13,20 @@ void mtmd_image_preproc_out::append(const clip_hparams & hparams, const clip_ima
     entries.push_back(std::move(dst));
 }

-void mtmd_image_preproc_out::append(const clip_hparams & hparams, const std::vector<clip_image_u8> & imgs, bool normalized) {
+void mtmd_image_preproc_out::append(const mtmd_hparams & hparams, const std::vector<mtmd_image_u8> & imgs, bool normalized) {
     for (const auto & img : imgs) {
         append(hparams, img, normalized);
     }
 }

-void mtmd_image_preproc_out::append(const clip_hparams & hparams, clip_image_f32 & img, bool normalized) {
+void mtmd_image_preproc_out::append(const mtmd_hparams & hparams, mtmd_image_f32 & img, bool normalized) {
     if (normalized) {
         img.normalize(hparams.image_mean, hparams.image_std);
     }
     entries.push_back(std::move(img));
 }

-void mtmd_image_preproc_out::append_overview(const clip_hparams & hparams, const clip_image_u8 & img, bool normalized) {
+void mtmd_image_preproc_out::append_overview(const mtmd_hparams & hparams, const mtmd_image_u8 & img, bool normalized) {
     overview.from_u8(img);
     if (normalized) {
         overview.normalize(hparams.image_mean, hparams.image_std);
@@ -37,9 +37,9 @@ void mtmd_image_preproc_out::append_overview(const clip_hparams & hparams, const
 // in the future, we can have HW acceleration by allowing this struct to access 3rd party lib like imagick or opencv
 struct img_tool {
     static void resize(
-            const clip_image_u8 & src,
-            clip_image_u8 & dst,
-            const clip_image_size & target_resolution,
+            const mtmd_image_u8 & src,
+            mtmd_image_u8 & dst,
+            const mtmd_image_size & target_resolution,
             resize_algo algo,
             pad_style padding = PAD_CEIL,
             std::array<uint8_t, 3> pad_color = {0, 0, 0}) {
@@ -61,7 +61,7 @@ struct img_tool {
             resize_pillow(src, dst, target_resolution.width, target_resolution.height, algo);
         } else {
             // resize with padding
-            clip_image_u8 resized_image;
+            mtmd_image_u8 resized_image;
             float scale_w = static_cast<float>(target_resolution.width) / src.get_size().width;
             float scale_h = static_cast<float>(target_resolution.height) / src.get_size().height;
             float scale = std::min(scale_w, scale_h);
@@ -92,7 +92,7 @@ struct img_tool {
         }
     }

-    static void crop(const clip_image_u8 & image, clip_image_u8 & dst, int x, int y, int w, int h) {
+    static void crop(const mtmd_image_u8 & image, mtmd_image_u8 & dst, int x, int y, int w, int h) {
         GGML_ASSERT(x >= 0 && y >= 0 && w > 0 && h > 0);
         GGML_ASSERT(x + w <= image.get_size().width && y + h <= image.get_size().height);
         dst.set_size({w, h}, image.is_placeholder());
@@ -120,7 +120,7 @@ struct img_tool {

     // calculate the size of the **resized** image, while preserving the aspect ratio and
     // aligning to the nearest multiple of align_size ("smart_resize" in transformers code)
-    static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const calc_size_opt & opts) {
+    static mtmd_image_size calc_size_preserved_ratio(const mtmd_image_size & inp_size, const calc_size_opt & opts) {
         GGML_ASSERT(opts.align_size > 0);
         const int width  = inp_size.width;
         const int height = inp_size.height;
@@ -161,7 +161,7 @@ struct img_tool {
     }

     // draw src image into dst image at offset (offset_x, offset_y)
-    static void composite(clip_image_u8 & dst, const clip_image_u8 & src, int offset_x, int offset_y) {
+    static void composite(mtmd_image_u8 & dst, const mtmd_image_u8 & src, int offset_x, int offset_y) {
         if (src.is_placeholder()) {
             // no-op for placeholder image
             return;
@@ -183,7 +183,7 @@ struct img_tool {
     }

     // fill the image with a solid color
-    static void fill(clip_image_u8 & img, const std::array<uint8_t, 3> & color) {
+    static void fill(mtmd_image_u8 & img, const std::array<uint8_t, 3> & color) {
         if (img.is_placeholder()) {
             // no-op for placeholder image
             return;
@@ -208,8 +208,8 @@ private:
     //
     // RESIZE_ALGO_FLAG_TORCH is the same, except for the int16 weights of torchvision (aten/src/ATen/native/cpu/UpSampleKernel.cpp)
     static bool resize_pillow(
-            const clip_image_u8 & img,
-            clip_image_u8 & dst,
+            const mtmd_image_u8 & img,
+            mtmd_image_u8 & dst,
             int target_width,
             int target_height,
             resize_algo algo) {
@@ -516,8 +516,8 @@ private:
 // mtmd_image_preprocessor_llava_uhd
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_llava_uhd::preprocess(const clip_image_u8 & img) const {
-    const clip_image_size original_size = img.get_size();
+mtmd_image_preproc_out mtmd_image_preprocessor_llava_uhd::preprocess(const mtmd_image_u8 & img) const {
+    const mtmd_image_size original_size = img.get_size();
     auto const inst = get_slice_instructions(original_size);
     auto sliced = slice_image(img, inst);

@@ -530,7 +530,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_llava_uhd::preprocess(const clip_
     return output;
 }

-mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_llava_uhd::get_slice_instructions(const clip_image_size & original_size) const {
+mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_llava_uhd::get_slice_instructions(const mtmd_image_size & original_size) const {
     mtmd_image_preprocessor_llava_uhd::slice_instructions res;
     const int patch_size      = get_slice_align();
     const int slice_size      = hparams.image_size;
@@ -542,9 +542,9 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_ll

     if (!has_slices) {
         // skip slicing logic
-        res.overview_size = clip_image_size{slice_size, slice_size};
-        res.refined_size  = clip_image_size{0, 0};
-        res.grid_size     = clip_image_size{0, 0};
+        res.overview_size = mtmd_image_size{slice_size, slice_size};
+        res.refined_size  = mtmd_image_size{0, 0};
+        res.grid_size     = mtmd_image_size{0, 0};

         return res;
     }
@@ -554,9 +554,9 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_ll
         auto refine_size = select_best_resolution(
             original_size,
             hparams.image_res_candidates);
-        res.overview_size         = clip_image_size{slice_size, slice_size};
+        res.overview_size         = mtmd_image_size{slice_size, slice_size};
         res.refined_size          = refine_size;
-        res.grid_size             = clip_image_size{0, 0};
+        res.grid_size             = mtmd_image_size{0, 0};

         LOG_DBG("%s: using pinpoints for slicing\n", __func__);
         LOG_DBG("%s: original size: %d x %d, overview size: %d x %d, refined size: %d x %d\n",
@@ -633,7 +633,7 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_ll
     return res;
 }

-mtmd_image_preprocessor_llava_uhd::slice_output mtmd_image_preprocessor_llava_uhd::slice_image(const clip_image_u8 & img, const mtmd_image_preprocessor_llava_uhd::slice_instructions & inst) const {
+mtmd_image_preprocessor_llava_uhd::slice_output mtmd_image_preprocessor_llava_uhd::slice_image(const mtmd_image_u8 & img, const mtmd_image_preprocessor_llava_uhd::slice_instructions & inst) const {
     slice_output output;

     // resize to overview size
@@ -646,7 +646,7 @@ mtmd_image_preprocessor_llava_uhd::slice_output mtmd_image_preprocessor_llava_uh
     }

     // resize to refined size
-    clip_image_u8 refined_img;
+    mtmd_image_u8 refined_img;
     img_tool::resize(img, refined_img, inst.refined_size, hparams.image_resize_algo_rf,
                         hparams.image_pad_rf, hparams.image_pad_color_rf);

@@ -657,7 +657,7 @@ mtmd_image_preprocessor_llava_uhd::slice_output mtmd_image_preprocessor_llava_uh
         int w = slice.size.width;
         int h = slice.size.height;

-        clip_image_u8 img_slice;
+        mtmd_image_u8 img_slice;
         img_tool::crop(refined_img, img_slice, x, y, w, h);
         output.slices.push_back(std::move(img_slice));
     }
@@ -665,7 +665,7 @@ mtmd_image_preprocessor_llava_uhd::slice_output mtmd_image_preprocessor_llava_uh
     return output;
 }

-clip_image_size mtmd_image_preprocessor_llava_uhd::get_best_resize(const clip_image_size & original_size, int scale_resolution, int patch_size, bool allow_upscale) const {
+mtmd_image_size mtmd_image_preprocessor_llava_uhd::get_best_resize(const mtmd_image_size & original_size, int scale_resolution, int patch_size, bool allow_upscale) const {
     int width  = original_size.width;
     int height = original_size.height;
     if ((width * height > scale_resolution * scale_resolution) || allow_upscale) {
@@ -673,28 +673,28 @@ clip_image_size mtmd_image_preprocessor_llava_uhd::get_best_resize(const clip_im
         height  = static_cast<int>(scale_resolution / std::sqrt(r));
         width   = static_cast<int>(height * r);
     }
-    clip_image_size res;
+    mtmd_image_size res;
     res.width  = ensure_divide(width,  patch_size);
     res.height = ensure_divide(height, patch_size);
     return res;
 }

-clip_image_size mtmd_image_preprocessor_llava_uhd::resize_maintain_aspect_ratio(const clip_image_size & orig, const clip_image_size & target_max) const {
+mtmd_image_size mtmd_image_preprocessor_llava_uhd::resize_maintain_aspect_ratio(const mtmd_image_size & orig, const mtmd_image_size & target_max) const {
     float scale_width  = static_cast<float>(target_max.width)  / orig.width;
     float scale_height = static_cast<float>(target_max.height) / orig.height;
     float scale = std::min(scale_width, scale_height);
-    return clip_image_size{
+    return mtmd_image_size{
         static_cast<int>(orig.width  * scale),
         static_cast<int>(orig.height * scale),
     };
 }

-clip_image_size mtmd_image_preprocessor_llava_uhd::select_best_resolution(const clip_image_size & original_size, const std::vector<clip_image_size> & possible_resolutions) const {
-    clip_image_size best_fit;
+mtmd_image_size mtmd_image_preprocessor_llava_uhd::select_best_resolution(const mtmd_image_size & original_size, const std::vector<mtmd_image_size> & possible_resolutions) const {
+    mtmd_image_size best_fit;
     int min_wasted_area = std::numeric_limits<int>::max();
     int max_effective_resolution = 0;

-    for (const clip_image_size & candidate : possible_resolutions) {
+    for (const mtmd_image_size & candidate : possible_resolutions) {
         auto target_size = resize_maintain_aspect_ratio(original_size, candidate);
         int effective_resolution = std::min(
             target_size.width * target_size.height,
@@ -717,7 +717,7 @@ int mtmd_image_preprocessor_llava_uhd::ensure_divide(int length, int patch_size)
     return std::max(align_round(static_cast<double>(length) / patch_size) * patch_size, patch_size);
 }

-clip_image_size mtmd_image_preprocessor_llava_uhd::get_refine_size(const clip_image_size & original_size, const clip_image_size & grid, int scale_resolution, int patch_size, bool allow_upscale) const {
+mtmd_image_size mtmd_image_preprocessor_llava_uhd::get_refine_size(const mtmd_image_size & original_size, const mtmd_image_size & grid, int scale_resolution, int patch_size, bool allow_upscale) const {
     int width  = original_size.width;
     int height = original_size.height;
     int grid_x = grid.width;
@@ -726,7 +726,7 @@ clip_image_size mtmd_image_preprocessor_llava_uhd::get_refine_size(const clip_im
     int refine_width  = ensure_divide(width, grid_x);
     int refine_height = ensure_divide(height, grid_y);

-    clip_image_size grid_size;
+    mtmd_image_size grid_size;
     grid_size.width  = refine_width  / grid_x;
     grid_size.height = refine_height / grid_y;

@@ -734,13 +734,13 @@ clip_image_size mtmd_image_preprocessor_llava_uhd::get_refine_size(const clip_im
     int best_grid_width  = best_grid_size.width;
     int best_grid_height = best_grid_size.height;

-    clip_image_size refine_size;
+    mtmd_image_size refine_size;
     refine_size.width  = best_grid_width  * grid_x;
     refine_size.height = best_grid_height * grid_y;
     return refine_size;
 }

-clip_image_size mtmd_image_preprocessor_llava_uhd::get_best_grid(const int max_slice_nums, const int multiple, const float log_ratio) const {
+mtmd_image_size mtmd_image_preprocessor_llava_uhd::get_best_grid(const int max_slice_nums, const int multiple, const float log_ratio) const {
     std::vector<int> candidate_split_grids_nums;
     for (int i : {multiple - 1, multiple, multiple + 1}) {
         if (i == 1 || i > max_slice_nums) {
@@ -749,18 +749,18 @@ clip_image_size mtmd_image_preprocessor_llava_uhd::get_best_grid(const int max_s
         candidate_split_grids_nums.push_back(i);
     }

-    std::vector<clip_image_size> candidate_grids;
+    std::vector<mtmd_image_size> candidate_grids;
     for (int split_grids_nums : candidate_split_grids_nums) {
         int m = 1;
         while (m <= split_grids_nums) {
             if (split_grids_nums % m == 0) {
-                candidate_grids.push_back(clip_image_size{m, split_grids_nums / m});
+                candidate_grids.push_back(mtmd_image_size{m, split_grids_nums / m});
             }
             ++m;
         }
     }

-    clip_image_size best_grid{1, 1};
+    mtmd_image_size best_grid{1, 1};
     float min_error = std::numeric_limits<float>::infinity();
     for (const auto& grid : candidate_grids) {
         float error = std::abs(log_ratio - std::log(1.0 * grid.width / grid.height));
@@ -776,8 +776,8 @@ clip_image_size mtmd_image_preprocessor_llava_uhd::get_best_grid(const int max_s
 // mtmd_image_preprocessor_fixed_size
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_fixed_size::preprocess(const clip_image_u8 & img) const {
-    clip_image_u8 resized_image;
+mtmd_image_preproc_out mtmd_image_preprocessor_fixed_size::preprocess(const mtmd_image_u8 & img) const {
+    mtmd_image_u8 resized_image;
     int sz = hparams.image_size;
     img_tool::resize(img, resized_image, {sz, sz},
                         hparams.image_resize_algo,
@@ -792,7 +792,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_fixed_size::preprocess(const clip
 // mtmd_image_preprocessor_dyn_size
 //

-clip_image_size mtmd_image_preprocessor_dyn_size::get_target_size(const clip_hparams & hparams, const clip_image_size & original_size) {
+mtmd_image_size mtmd_image_preprocessor_dyn_size::get_target_size(const mtmd_hparams & hparams, const mtmd_image_size & original_size) {
     GGML_ASSERT(hparams.image_min_pixels > 0 && hparams.image_max_pixels > 0);
     // the original pixtral model doesn't have n_merge
     const int cur_merge = hparams.n_merge;
@@ -807,8 +807,8 @@ clip_image_size mtmd_image_preprocessor_dyn_size::get_target_size(const clip_hpa
         });
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_image_u8 & img) const {
-    clip_image_u8 resized_image;
+mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const mtmd_image_u8 & img) const {
+    mtmd_image_u8 resized_image;
     img_tool::resize(img, resized_image, target_size(img.get_size()),
                         hparams.image_resize_algo,
                         hparams.image_resize_pad,
@@ -823,7 +823,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_i
 //

 // rounds before the pixel checks (an edge may round to 0) and computes in double, as transformers does
-clip_image_size mtmd_image_preprocessor_qwen3vl::smart_resize(int width, int height, int factor, int64_t min_pixels, int64_t max_pixels,
+mtmd_image_size mtmd_image_preprocessor_qwen3vl::smart_resize(int width, int height, int factor, int64_t min_pixels, int64_t max_pixels,
                                                               int n_frames, int temporal_factor) {
     double h = height;
     double w = width;
@@ -850,13 +850,13 @@ clip_image_size mtmd_image_preprocessor_qwen3vl::smart_resize(int width, int hei
     return {std::max(factor, w_bar), std::max(factor, h_bar)};
 }

-bool mtmd_image_preprocessor_qwen3vl::get_video_frame_size(int n_frames, int width, int height, clip_image_size & out) const {
+bool mtmd_image_preprocessor_qwen3vl::get_video_frame_size(int n_frames, int width, int height, mtmd_image_size & out) const {
     // Qwen3VLVideoProcessor (and Lfm3VlVideoProcessor) size: shortest_edge 4096, longest_edge 25165824, temporal_patch_size 2
     out = smart_resize(width, height, hparams.patch_size * hparams.n_merge, 4096, 25165824, n_frames, 2);
     return true;
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::preprocess_video_frame(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::preprocess_video_frame(const mtmd_image_u8 & img) const {
     const int f = hparams.patch_size * hparams.n_merge;
     const auto size = img.get_size();
     return resize_to(img, {
@@ -865,8 +865,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::preprocess_video_frame(c
     });
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::resize_to(const clip_image_u8 & img, const clip_image_size & size) const {
-    clip_image_u8 resized_image;
+mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::resize_to(const mtmd_image_u8 & img, const mtmd_image_size & size) const {
+    mtmd_image_u8 resized_image;
     img_tool::resize(img, resized_image, size,
                         hparams.image_resize_algo,
                         hparams.image_resize_pad,
@@ -880,7 +880,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_qwen3vl::resize_to(const clip_ima
 // mtmd_image_preprocessor_lfm3vl
 //

-clip_image_size mtmd_image_preprocessor_lfm3vl::target_size(const clip_image_size & original_size) const {
+mtmd_image_size mtmd_image_preprocessor_lfm3vl::target_size(const mtmd_image_size & original_size) const {
     return smart_resize(original_size.width, original_size.height, hparams.patch_size * hparams.n_merge,
                         hparams.image_min_pixels, hparams.image_max_pixels);
 }
@@ -891,7 +891,7 @@ clip_image_size mtmd_image_preprocessor_lfm3vl::target_size(const clip_image_siz

 // The canvas is ceil-aligned to patch_size*n_merge and fitted to the token budget.
 // Only rescaled to meet the budget and sits top-left, with black padding on the right and bottom
-mtmd_image_preproc_out mtmd_image_preprocessor_glm5v::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_glm5v::preprocess(const mtmd_image_u8 & img) const {
     GGML_ASSERT(hparams.image_min_pixels > 0 && hparams.image_max_pixels > 0);

     const int64_t factor  = hparams.patch_size * hparams.n_merge;
@@ -941,11 +941,11 @@ mtmd_image_preproc_out mtmd_image_preprocessor_glm5v::preprocess(const clip_imag
     const int content_h = (int) std::max<int64_t>(1, std::min<int64_t>(canvas_h, (int64_t) std::floor(height * scale)));
     const int content_w = (int) std::max<int64_t>(1, std::min<int64_t>(canvas_w, (int64_t) std::floor(width  * scale)));

-    clip_image_u8 content;
-    img_tool::resize(img, content, clip_image_size{content_w, content_h}, hparams.image_resize_algo, PAD_NONE);
+    mtmd_image_u8 content;
+    img_tool::resize(img, content, mtmd_image_size{content_w, content_h}, hparams.image_resize_algo, PAD_NONE);

-    clip_image_u8 canvas;
-    canvas.set_size(clip_image_size{(int) canvas_w, (int) canvas_h}, img.is_placeholder());
+    mtmd_image_u8 canvas;
+    canvas.set_size(mtmd_image_size{(int) canvas_w, (int) canvas_h}, img.is_placeholder());
     img_tool::fill(canvas, {0, 0, 0});
     img_tool::composite(canvas, content, 0, 0);

@@ -958,9 +958,9 @@ mtmd_image_preproc_out mtmd_image_preprocessor_glm5v::preprocess(const clip_imag
 // mtmd_image_preprocessor_longest_edge
 //

-clip_image_size mtmd_image_preprocessor_longest_edge::get_target_size(const clip_hparams & hparams, const clip_image_size & original_size) {
+mtmd_image_size mtmd_image_preprocessor_longest_edge::get_target_size(const mtmd_hparams & hparams, const mtmd_image_size & original_size) {
     GGML_ASSERT(hparams.image_longest_edge > 0);
-    clip_image_size size = original_size;
+    mtmd_image_size size = original_size;
     if (hparams.image_floor_before_align) {
         const double ratio = double(std::max(size.width, size.height)) / hparams.image_longest_edge;
         if (ratio > 1.0) {
@@ -985,9 +985,9 @@ clip_image_size mtmd_image_preprocessor_longest_edge::get_target_size(const clip
         });
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const clip_image_u8 & img) const {
-    clip_image_u8 resized_image;
-    const clip_image_size target_size = get_target_size(hparams, img.get_size());
+mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const mtmd_image_u8 & img) const {
+    mtmd_image_u8 resized_image;
+    const mtmd_image_size target_size = get_target_size(hparams, img.get_size());
     img_tool::resize(img, resized_image, target_size,
                         hparams.image_resize_algo,
                         hparams.image_resize_pad,
@@ -1001,15 +1001,15 @@ mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const cl
 // mtmd_image_preprocessor_minicpmv
 //

-mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_minicpmv::get_slice_instructions(const clip_image_size & original_size) const {
+mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_minicpmv::get_slice_instructions(const mtmd_image_size & original_size) const {
     // overview only for small images, unlike generic llava-uhd which slices once one side exceeds scale resolution
     const int   slice_size = hparams.image_size;
     const float ratio      = (float) original_size.width * original_size.height / (slice_size * slice_size);
     if (ratio <= 1.0f) {
         mtmd_image_preprocessor_llava_uhd::slice_instructions inst;
         inst.overview_size = get_best_resize(original_size, slice_size, get_slice_align(), true);
-        inst.refined_size  = clip_image_size{0, 0};
-        inst.grid_size     = clip_image_size{0, 0};
+        inst.refined_size  = mtmd_image_size{0, 0};
+        inst.grid_size     = mtmd_image_size{0, 0};
         return inst;
     }
     return mtmd_image_preprocessor_llava_uhd::get_slice_instructions(original_size);
@@ -1019,7 +1019,7 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_mi
 // mtmd_image_preprocessor_lfm2
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_lfm2::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_lfm2::preprocess(const mtmd_image_u8 & img) const {
     auto const inst = get_slice_instructions(img.get_size());
     if (!inst.slices.empty()) {
         return mtmd_image_preprocessor_llava_uhd::preprocess(img);
@@ -1034,8 +1034,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_lfm2::preprocess(const clip_image
 }

 bool mtmd_image_preprocessor_lfm2::should_tile(
-        const clip_hparams & hparams,
-        const clip_image_size & original_size) {
+        const mtmd_hparams & hparams,
+        const mtmd_image_size & original_size) {
     const int align_size = hparams.patch_size * hparams.n_merge;

     const auto round_by_factor = [align_size](float x) {
@@ -1052,9 +1052,9 @@ bool mtmd_image_preprocessor_lfm2::should_tile(
 }

 // same as Lfm2VlImageProcessor.smart_resize: Python's round half to even, double precision
-clip_image_size mtmd_image_preprocessor_lfm2::smart_resize(
-        const clip_hparams & hparams,
-        const clip_image_size & original_size) {
+mtmd_image_size mtmd_image_preprocessor_lfm2::smart_resize(
+        const mtmd_hparams & hparams,
+        const mtmd_image_size & original_size) {
     const int    align_size = hparams.patch_size * hparams.n_merge;
     const double height     = original_size.height;
     const double width      = original_size.width;
@@ -1075,7 +1075,7 @@ clip_image_size mtmd_image_preprocessor_lfm2::smart_resize(
     return {w_bar, h_bar};
 }

-mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lfm2::get_slice_instructions(const clip_image_size & original_size) const {
+mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lfm2::get_slice_instructions(const mtmd_image_size & original_size) const {
     mtmd_image_preprocessor_llava_uhd::slice_instructions inst;
     inst.overview_size = smart_resize(hparams, original_size);

@@ -1083,15 +1083,15 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
     const int  tile_size    = hparams.preproc_tile_size;

     if (!needs_tiling) {
-        inst.refined_size = clip_image_size{0, 0};
-        inst.grid_size    = clip_image_size{0, 0};
+        inst.refined_size = mtmd_image_size{0, 0};
+        inst.grid_size    = mtmd_image_size{0, 0};
         return inst;
     }

-    const clip_image_size grid = get_grid_layout(hparams, original_size.height, original_size.width);
+    const mtmd_image_size grid = get_grid_layout(hparams, original_size.height, original_size.width);

     inst.grid_size    = grid;
-    inst.refined_size = clip_image_size{tile_size * grid.width, tile_size * grid.height};
+    inst.refined_size = mtmd_image_size{tile_size * grid.width, tile_size * grid.height};

     LOG_DBG("%s: original size: %d x %d, overview size: %d x %d, refined size: %d x %d, grid size: %d x %d\n",
             __func__,
@@ -1105,7 +1105,7 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
             mtmd_image_preprocessor_llava_uhd::slice_coordinates slice;
             slice.x    = col * tile_size;
             slice.y    = row * tile_size;
-            slice.size = clip_image_size{tile_size, tile_size};
+            slice.size = mtmd_image_size{tile_size, tile_size};
             inst.slices.push_back(slice);
             LOG_DBG("%s: slice %d: x=%d, y=%d, size=%d x %d\n",
                     __func__, (int)inst.slices.size() - 1,
@@ -1117,13 +1117,13 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
 }

 // double precision as in Python, float breaks exact ties such as 720/1600 between 2/4 and 2/5
-clip_image_size mtmd_image_preprocessor_lfm2::find_closest_aspect_ratio(
-        const clip_hparams & hparams,
+mtmd_image_size mtmd_image_preprocessor_lfm2::find_closest_aspect_ratio(
+        const mtmd_hparams & hparams,
         double aspect_ratio,
-        const std::vector<clip_image_size> & target_ratios,
+        const std::vector<mtmd_image_size> & target_ratios,
         int width, int height) {
     double best_ratio_diff = std::numeric_limits<double>::max();
-    clip_image_size best_ratio = {1, 1};
+    mtmd_image_size best_ratio = {1, 1};
     const double area = static_cast<double>(width) * height;

     for (const auto & ratio : target_ratios) {
@@ -1143,10 +1143,10 @@ clip_image_size mtmd_image_preprocessor_lfm2::find_closest_aspect_ratio(
     return best_ratio;
 }

-std::vector<clip_image_size> mtmd_image_preprocessor_lfm2::get_target_ratios(const clip_hparams & hparams) {
+std::vector<mtmd_image_size> mtmd_image_preprocessor_lfm2::get_target_ratios(const mtmd_hparams & hparams) {
     const int min_tiles = hparams.preproc_min_tiles;
     const int max_tiles = hparams.preproc_max_tiles;
-    std::vector<clip_image_size> ratios;
+    std::vector<mtmd_image_size> ratios;
     for (int n = min_tiles; n <= max_tiles; n++) {
         for (int w = 1; w <= n; w++) {
             for (int h = 1; h <= n; h++) {
@@ -1165,13 +1165,13 @@ std::vector<clip_image_size> mtmd_image_preprocessor_lfm2::get_target_ratios(con
             }
         }
     }
-    std::sort(ratios.begin(), ratios.end(), [](const clip_image_size & a, const clip_image_size & b) {
+    std::sort(ratios.begin(), ratios.end(), [](const mtmd_image_size & a, const mtmd_image_size & b) {
         return a.width * a.height < b.width * b.height;
     });
     return ratios;
 }

-clip_image_size mtmd_image_preprocessor_lfm2::get_grid_layout(const clip_hparams & hparams, int height, int width) {
+mtmd_image_size mtmd_image_preprocessor_lfm2::get_grid_layout(const mtmd_hparams & hparams, int height, int width) {
     const double aspect_ratio = static_cast<double>(width) / height;
     const auto ratios = get_target_ratios(hparams);
     return find_closest_aspect_ratio(hparams, aspect_ratio, ratios, width, height);
@@ -1181,7 +1181,7 @@ clip_image_size mtmd_image_preprocessor_lfm2::get_grid_layout(const clip_hparams
 // mtmd_image_preprocessor_idefics3
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const mtmd_image_u8 & img) const {
     // The refined size has two steps:
     // 1. Resize w/ aspect-ratio preserving such that the longer side is
     //      the preprocessor longest size
@@ -1189,7 +1189,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
     //      multiples of image_size (always rounding up)
     //
     // CITE: https://github.com/huggingface/transformers/blob/main/src/transformers/models/idefics3/image_processing_idefics3.py#L737
-    const clip_image_size original_size = img.get_size();
+    const mtmd_image_size original_size = img.get_size();

     // old gguf files have no preprocessor longest size, custom token limits also need the generic size below
     if (hparams.image_longest_edge > 0 && hparams.image_min_pixels <= 0 && hparams.image_max_pixels <= 0) {
@@ -1197,7 +1197,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
         const int    longest_edge = hparams.image_longest_edge;
         const double aspect_ratio = (double) original_size.width / original_size.height;

-        clip_image_size resized_size;
+        mtmd_image_size resized_size;
         if (original_size.width >= original_size.height) {
             resized_size.width   = longest_edge;
             resized_size.height  = (int) (longest_edge / aspect_ratio);
@@ -1210,21 +1210,21 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i

         const int grid_x = (resized_size.width  + tile_size - 1) / tile_size;
         const int grid_y = (resized_size.height + tile_size - 1) / tile_size;
-        const clip_image_size refined_size = clip_image_size{grid_x * tile_size, grid_y * tile_size};
+        const mtmd_image_size refined_size = mtmd_image_size{grid_x * tile_size, grid_y * tile_size};

-        clip_image_u8 resized_img;
+        mtmd_image_u8 resized_img;
         img_tool::resize(img, resized_img, resized_size, hparams.image_resize_algo, PAD_NONE);

-        clip_image_u8 refined_img;
+        mtmd_image_u8 refined_img;
         img_tool::resize(resized_img, refined_img, refined_size, hparams.image_resize_algo, PAD_NONE);

-        clip_image_u8 overview;
+        mtmd_image_u8 overview;
         img_tool::resize(refined_img, overview, {tile_size, tile_size}, hparams.image_resize_algo, PAD_NONE);

-        std::vector<clip_image_u8> slices;
+        std::vector<mtmd_image_u8> slices;
         for (int y = 0; y < grid_y; y++) {
             for (int x = 0; x < grid_x; x++) {
-                clip_image_u8 slice;
+                mtmd_image_u8 slice;
                 img_tool::crop(refined_img, slice, x * tile_size, y * tile_size, tile_size, tile_size);
                 slices.push_back(std::move(slice));
             }
@@ -1240,7 +1240,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
         return output;
     }

-    const clip_image_size refined_size = img_tool::calc_size_preserved_ratio(
+    const mtmd_image_size refined_size = img_tool::calc_size_preserved_ratio(
         original_size,
         { hparams.image_size, std::max(0, hparams.image_min_pixels), std::max(0, hparams.image_max_pixels), hparams.image_longest_edge });
     // LOG_INF("%s: original size: %d x %d, refined size: %d x %d\n",
@@ -1248,9 +1248,9 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
     //         refined_size.width, refined_size.height);

     mtmd_image_preprocessor_llava_uhd::slice_instructions instructions;
-    instructions.overview_size = clip_image_size{hparams.image_size, hparams.image_size};
+    instructions.overview_size = mtmd_image_size{hparams.image_size, hparams.image_size};
     instructions.refined_size = refined_size;
-    instructions.grid_size = clip_image_size{
+    instructions.grid_size = mtmd_image_size{
         static_cast<int>(std::ceil(static_cast<float>(refined_size.width) / hparams.image_size)),
         static_cast<int>(std::ceil(static_cast<float>(refined_size.height) / hparams.image_size)),
     };
@@ -1260,7 +1260,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
             instructions.slices.push_back(mtmd_image_preprocessor_llava_uhd::slice_coordinates{
                 /* x    */x,
                 /* y    */y,
-                /* size */clip_image_size{
+                /* size */mtmd_image_size{
                     std::min(hparams.image_size, refined_size.width - x),
                     std::min(hparams.image_size, refined_size.height - y)
                 }
@@ -1281,7 +1281,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
 // mtmd_image_preprocessor_cohere2v
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_cohere2v::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_cohere2v::preprocess(const mtmd_image_u8 & img) const {
     const auto inst = get_slice_instructions(img.get_size());
     auto sliced = slice_image(img, inst);

@@ -1298,21 +1298,21 @@ mtmd_image_preproc_out mtmd_image_preprocessor_cohere2v::preprocess(const clip_i
     return output;
 }

-mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_cohere2v::get_slice_instructions(const clip_image_size & original_size) const {
+mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_cohere2v::get_slice_instructions(const mtmd_image_size & original_size) const {
     const int tile = hparams.image_size;

     // pick the grid with the least upscale; if all grids need downscale, pick the one with the least downscale
     // grids are visited by tile count, then by width, same order as HF for ties
     double best_down = -1.0;
     double best_up   = std::numeric_limits<double>::max();
-    clip_image_size grid_down = { 1, 1 };
-    clip_image_size grid_up   = { 0, 0 };
+    mtmd_image_size grid_down = { 1, 1 };
+    mtmd_image_size grid_up   = { 0, 0 };
     for (int n = 1; n <= hparams.preproc_max_tiles; n++) {
         for (int w = 1; w <= n; w++) {
             if (n % w != 0) {
                 continue;
             }
-            const clip_image_size g = { w, n / w };
+            const mtmd_image_size g = { w, n / w };
             const double scale = std::min(
                 (double) (g.width  * tile) / original_size.width,
                 (double) (g.height * tile) / original_size.height);
@@ -1327,7 +1327,7 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_co
             }
         }
     }
-    const clip_image_size grid = grid_up.width > 0 ? grid_up : grid_down;
+    const mtmd_image_size grid = grid_up.width > 0 ? grid_up : grid_down;

     slice_instructions inst;
     inst.overview_size = { tile, tile };
@@ -1347,9 +1347,9 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_co
 // mtmd_image_preprocessor_internvl
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_internvl::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_internvl::preprocess(const mtmd_image_u8 & img) const {
     GGML_ASSERT(!hparams.image_res_candidates.empty());
-    const clip_image_size original_size = img.get_size();
+    const mtmd_image_size original_size = img.get_size();
     auto const inst = get_slice_instructions(original_size);
     auto sliced = slice_image(img, inst);

@@ -1366,8 +1366,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_internvl::preprocess(const clip_i
 // mtmd_image_preprocessor_deepseekocr
 //

-std::vector<clip_image_size> mtmd_image_preprocessor_deepseekocr::get_target_ratios() const {
-    std::vector<clip_image_size> ratios;
+std::vector<mtmd_image_size> mtmd_image_preprocessor_deepseekocr::get_target_ratios() const {
+    std::vector<mtmd_image_size> ratios;
     for (int n = min_tiles; n <= max_tiles; n++) {
         for (int w = 1; w <= n; w++) {
             for (int h = 1; h <= n; h++) {
@@ -1387,19 +1387,19 @@ std::vector<clip_image_size> mtmd_image_preprocessor_deepseekocr::get_target_rat
             }
         }
     }
-    std::sort(ratios.begin(), ratios.end(), [](const clip_image_size & a, const clip_image_size & b) {
+    std::sort(ratios.begin(), ratios.end(), [](const mtmd_image_size & a, const mtmd_image_size & b) {
         return a.width * a.height < b.width * b.height;
     });
     return ratios;
 }

-clip_image_size mtmd_image_preprocessor_deepseekocr::find_closest_aspect_ratio(
+mtmd_image_size mtmd_image_preprocessor_deepseekocr::find_closest_aspect_ratio(
     float                                aspect_ratio,
-    const std::vector<clip_image_size> & target_ratios,
+    const std::vector<mtmd_image_size> & target_ratios,
     int                                  width,
     int                                  height) const {
     float           best_ratio_diff = std::numeric_limits<float>::max();
-    clip_image_size best_ratio      = { 1, 1 };
+    mtmd_image_size best_ratio      = { 1, 1 };
     const float     area            = static_cast<float>(width * height);

     for (const auto & ratio : target_ratios) {
@@ -1482,7 +1482,7 @@ void mtmd_image_preprocessor_deepseek4v::safe_resize(int height, int width, int
 }

 // ref: load_image()
-mtmd_image_preproc_out mtmd_image_preprocessor_deepseek4v::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_deepseek4v::preprocess(const mtmd_image_u8 & img) const {
     mtmd_image_preproc_out out;

     const int p           = hparams.patch_size;
@@ -1490,7 +1490,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseek4v::preprocess(const clip
     const int max_n_token = hparams.dsv4_max_n_token;
     const int max_wh      = hparams.dsv4_max_wh_ratio;

-    const clip_image_size orig = img.get_size();
+    const mtmd_image_size orig = img.get_size();
     int width  = orig.width;
     int height = orig.height;
     if (max_wh > 0 && width > height * max_wh) {
@@ -1502,11 +1502,11 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseek4v::preprocess(const clip
         width  = (int) (width  * up);
         height = (int) (height * up);
     }
-    int best_width  = CLIP_ALIGN(width,  p);
-    int best_height = CLIP_ALIGN(height, p);
+    int best_width  = MTMD_ALIGN(width,  p);
+    int best_height = MTMD_ALIGN(height, p);
     safe_resize(height, width, best_height, best_width, p, r, max_n_token);

-    clip_image_u8 resized;
+    mtmd_image_u8 resized;
     if (max_wh > 0 && orig.width >= max_wh * orig.height) {
         // extreme aspect ratio: plain stretch resize, no padding
         img_tool::resize(img, resized, {best_width, best_height}, hparams.image_resize_algo, PAD_NONE);
@@ -1540,8 +1540,8 @@ void mtmd_image_preprocessor_deepseek41v::plan_grid(int width, int height, int p
         width  = (int) (width  * ratio);
         height = (int) (height * ratio);
     }
-    best_width  = CLIP_ALIGN(width,  p);
-    best_height = CLIP_ALIGN(height, p);
+    best_width  = MTMD_ALIGN(width,  p);
+    best_height = MTMD_ALIGN(height, p);
     if (n_tokens(best_height, best_width) > max_n_token) {
         // solve_resize_ratio: largest aspect-preserving size whose token block fits
         const double ratio   = (double) height / width;
@@ -1563,11 +1563,11 @@ void mtmd_image_preprocessor_deepseek41v::plan_grid(int width, int height, int p
     }
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_deepseek41v::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_deepseek41v::preprocess(const mtmd_image_u8 & img) const {
     mtmd_image_preproc_out out;

     const int p = hparams.patch_size;
-    const clip_image_size orig = img.get_size();
+    const mtmd_image_size orig = img.get_size();
     int best_width = 0, best_height = 0;
     plan_grid(orig.width, orig.height, p, hparams.n_merge, hparams.dsv4_max_n_token, hparams.image_min_pixels,
               best_width, best_height);
@@ -1585,10 +1585,10 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseek41v::preprocess(const cli
             new_w = round_half_even((double) orig.width / orig.height * best_height);
         }
     }
-    clip_image_u8 resized;
+    mtmd_image_u8 resized;
     img_tool::resize(img, resized, {new_w, new_h}, hparams.image_resize_algo, PAD_NONE);

-    clip_image_u8 padded;
+    mtmd_image_u8 padded;
     if (new_w == best_width && new_h == best_height) {
         padded = std::move(resized);
     } else {
@@ -1614,14 +1614,14 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseek41v::preprocess(const cli
     return out;
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const mtmd_image_u8 & img) const {
     mtmd_image_preproc_out output;
     int grid_w = 0;
     int grid_h = 0;
     const auto img_size = img.get_size();

     // global view: aspect-preserving fit-and-pad to base_size
-    clip_image_u8 padded;
+    mtmd_image_u8 padded;
     img_tool::resize(img, padded,
                     { base_size, base_size },
                     RESIZE_ALGO_BICUBIC,
@@ -1634,12 +1634,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const cli
     if (img_size.width > tile_size || img_size.height > tile_size) {
         const float           aspect_ratio  = static_cast<float>(img_size.width) / img_size.height;
         const auto            target_ratios = get_target_ratios();
-        const clip_image_size grid =
+        const mtmd_image_size grid =
             find_closest_aspect_ratio(aspect_ratio, target_ratios, img_size.width, img_size.height);
         grid_w = grid.width;
         grid_h = grid.height;

-        clip_image_u8 refined;
+        mtmd_image_u8 refined;
         img_tool::resize(img, refined, { tile_size * grid_w, tile_size * grid_h }, RESIZE_ALGO_BICUBIC,
                          PAD_NONE);

@@ -1648,7 +1648,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const cli
                 // concat all tiles in this row into a single image, along the H axis
                 // output image size: w = tile_size, h = tile_size * grid_w
                 // this is to ensure the whole row is always processed together
-                clip_image_u8 row_img;
+                mtmd_image_u8 row_img;
                 row_img.set_size({tile_size, tile_size * grid_w}, false);
                 for (int col = 0; col < grid_w; col++) {
                     for (int py = 0; py < tile_size; py++) {
@@ -1661,7 +1661,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const cli
                 output.append(hparams, row_img, true);
             } else {
                 for (int col = 0; col < grid_w; col++) {
-                    clip_image_u8 tile;
+                    mtmd_image_u8 tile;
                     img_tool::crop(refined, tile, col * tile_size, row * tile_size, tile_size, tile_size);
                     output.append(hparams, tile, true);
                 }
@@ -1685,8 +1685,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_deepseekocr::preprocess(const cli
 //

 void mtmd_image_preprocessor_step3vl::img_u8_resize_bilinear_to_f32(
-        const clip_image_u8 & src,
-        clip_image_f32 & dst,
+        const mtmd_image_u8 & src,
+        mtmd_image_f32 & dst,
         int target_width,
         int target_height,
         const float mean[3],
@@ -1745,11 +1745,11 @@ void mtmd_image_preprocessor_step3vl::img_u8_resize_bilinear_to_f32(
     dst.cpy_buf(local_buf);
 }

-int mtmd_image_preprocessor_step3vl::get_image_longest_edge(const clip_hparams & params) {
+int mtmd_image_preprocessor_step3vl::get_image_longest_edge(const mtmd_hparams & params) {
     return params.image_longest_edge > 0 ? params.image_longest_edge : default_image_longest_edge;
 }

-int mtmd_image_preprocessor_step3vl::determine_window_size(const clip_hparams & params, int longer, int shorter) {
+int mtmd_image_preprocessor_step3vl::determine_window_size(const mtmd_hparams & params, int longer, int shorter) {
     const int image_size = params.image_size;
     const int crop_size  = default_image_crop_size;
     const float aspect_ratio = static_cast<float>(longer) / shorter;
@@ -1791,15 +1791,15 @@ std::vector<int> mtmd_image_preprocessor_step3vl::calc_grid(int length, int wind
     return starts;
 }

-clip_image_u8 mtmd_image_preprocessor_step3vl::prepare_image(const clip_image_u8 & img, const clip_hparams & params) {
-    clip_image_u8 resized = img;
+mtmd_image_u8 mtmd_image_preprocessor_step3vl::prepare_image(const mtmd_image_u8 & img, const mtmd_hparams & params) {
+    mtmd_image_u8 resized = img;
     const auto img_size = img.get_size();
     const float aspect_ratio = img_size.height > 0 ? static_cast<float>(img_size.width) / img_size.height : 1.0f;
     if (std::min(img_size.width, img_size.height) < 32 &&
         (aspect_ratio > wide_aspect_ratio_limit ||
          aspect_ratio < 1.0f / wide_aspect_ratio_limit)) {
         const int square_size = std::max(img_size.width, img_size.height);
-        clip_image_u8 padded;
+        mtmd_image_u8 padded;
         padded.set_size({square_size, square_size}, false);
         img_tool::fill(padded, {0, 0, 0});
         img_tool::composite(padded, img, 0, 0);
@@ -1810,11 +1810,11 @@ clip_image_u8 mtmd_image_preprocessor_step3vl::prepare_image(const clip_image_u8
     const auto resized_size = resized.get_size();
     if (std::max(resized_size.width, resized_size.height) > max_image_size) {
         const float scale = static_cast<float>(max_image_size) / std::max(resized_size.width, resized_size.height);
-        const clip_image_size new_size = {
+        const mtmd_image_size new_size = {
             std::max(1, static_cast<int>(std::floor(resized_size.width  * scale))),
             std::max(1, static_cast<int>(std::floor(resized_size.height * scale))),
         };
-        clip_image_u8 scaled;
+        mtmd_image_u8 scaled;
         img_tool::resize(resized, scaled, new_size, RESIZE_ALGO_BILINEAR, PAD_NONE);
         resized = std::move(scaled);
     }
@@ -1822,8 +1822,8 @@ clip_image_u8 mtmd_image_preprocessor_step3vl::prepare_image(const clip_image_u8
     return resized;
 }

-clip_image_u8 mtmd_image_preprocessor_step3vl::crop_with_black_padding(const clip_image_u8 & image, int x, int y, int w, int h) {
-    clip_image_u8 dst;
+mtmd_image_u8 mtmd_image_preprocessor_step3vl::crop_with_black_padding(const mtmd_image_u8 & image, int x, int y, int w, int h) {
+    mtmd_image_u8 dst;
     dst.set_size({w, h}, false);
     img_tool::fill(dst, {0, 0, 0});

@@ -1850,8 +1850,8 @@ clip_image_u8 mtmd_image_preprocessor_step3vl::crop_with_black_padding(const cli
 }

 mtmd_image_preprocessor_step3vl::slice_instructions mtmd_image_preprocessor_step3vl::build_slice_instructions(
-        const clip_hparams & params,
-        const clip_image_size & prepared_size) {
+        const mtmd_hparams & params,
+        const mtmd_image_size & prepared_size) {
     slice_instructions instructions;
     instructions.overview_size = prepared_size;

@@ -1860,18 +1860,18 @@ mtmd_image_preprocessor_step3vl::slice_instructions mtmd_image_preprocessor_step
         std::max(prepared_size.width, prepared_size.height),
         std::min(prepared_size.width, prepared_size.height));
     if (window_size <= 0) {
-        instructions.refined_size = clip_image_size{0, 0};
-        instructions.grid_size    = clip_image_size{0, 0};
+        instructions.refined_size = mtmd_image_size{0, 0};
+        instructions.grid_size    = mtmd_image_size{0, 0};
         return instructions;
     }

     const int crop_width  = calc_crop_extent(prepared_size.width,  window_size);
     const int crop_height = calc_crop_extent(prepared_size.height, window_size);
-    instructions.refined_size = clip_image_size{crop_width, crop_height};
+    instructions.refined_size = mtmd_image_size{crop_width, crop_height};

     const auto xs = calc_grid(crop_width,  window_size);
     const auto ys = calc_grid(crop_height, window_size);
-    instructions.grid_size = clip_image_size{
+    instructions.grid_size = mtmd_image_size{
         static_cast<int>(xs.size()),
         static_cast<int>(ys.size()),
     };
@@ -1881,7 +1881,7 @@ mtmd_image_preprocessor_step3vl::slice_instructions mtmd_image_preprocessor_step
             instructions.slices.push_back(slice_coordinates{
                 /* x    */ x,
                 /* y    */ y,
-                /* size */ clip_image_size{window_size, window_size},
+                /* size */ mtmd_image_size{window_size, window_size},
             });
         }
     }
@@ -1889,8 +1889,8 @@ mtmd_image_preprocessor_step3vl::slice_instructions mtmd_image_preprocessor_step
     return instructions;
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_step3vl::preprocess(const clip_image_u8 & img) const {
-    clip_image_u8 prepared = prepare_image(img, hparams);
+mtmd_image_preproc_out mtmd_image_preprocessor_step3vl::preprocess(const mtmd_image_u8 & img) const {
+    mtmd_image_u8 prepared = prepare_image(img, hparams);
     const auto instructions = build_slice_instructions(hparams, prepared.get_size());

     mtmd_image_preproc_out output;
@@ -1909,10 +1909,10 @@ mtmd_image_preproc_out mtmd_image_preprocessor_step3vl::preprocess(const clip_im
         return output;
     }

-    clip_image_u8 img_for_crop = prepared;
+    mtmd_image_u8 img_for_crop = prepared;
     const auto prepared_size = prepared.get_size();
     if (instructions.refined_size.width != prepared_size.width || instructions.refined_size.height != prepared_size.height) {
-        clip_image_u8 refined;
+        mtmd_image_u8 refined;
         img_tool::resize(prepared, refined, instructions.refined_size, RESIZE_ALGO_BILINEAR, PAD_NONE);
         img_for_crop = std::move(refined);
     }
@@ -1920,9 +1920,9 @@ mtmd_image_preproc_out mtmd_image_preprocessor_step3vl::preprocess(const clip_im
     const int crop_size = default_image_crop_size;
     for (const auto & slice : instructions.slices) {
         // If the requested patch extends past the source image, pad the out-of-bounds area with black.
-        clip_image_u8 patch = crop_with_black_padding(img_for_crop, slice.x, slice.y, slice.size.width, slice.size.height);
+        mtmd_image_u8 patch = crop_with_black_padding(img_for_crop, slice.x, slice.y, slice.size.width, slice.size.height);

-        clip_image_f32 patch_f32;
+        mtmd_image_f32 patch_f32;
         img_u8_resize_bilinear_to_f32(
             patch,
             patch_f32,
@@ -1943,7 +1943,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_step3vl::preprocess(const clip_im
 // mtmd_image_preprocessor_youtuvl
 //

-mtmd_image_preproc_out mtmd_image_preprocessor_youtuvl::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_youtuvl::preprocess(const mtmd_image_u8 & img) const {
     const int patch_size = hparams.patch_size;   // typically 16
     const int merge_size = hparams.n_merge;      // typically 2
     const int align_size = patch_size * merge_size;  // 32
@@ -1981,10 +1981,10 @@ mtmd_image_preproc_out mtmd_image_preprocessor_youtuvl::preprocess(const clip_im
         }
     }

-    clip_image_size new_size = {target_width, target_height};
+    mtmd_image_size new_size = {target_width, target_height};

     // Resize the image
-    clip_image_u8 resized;
+    mtmd_image_u8 resized;
     img_tool::resize(img, resized, new_size, hparams.image_resize_algo, hparams.image_resize_pad);

     mtmd_image_preproc_out output;
@@ -1992,33 +1992,33 @@ mtmd_image_preproc_out mtmd_image_preprocessor_youtuvl::preprocess(const clip_im
     return output;
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_granite::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_granite::preprocess(const mtmd_image_u8 & img) const {
     GGML_ASSERT(!hparams.image_res_candidates.empty());

-    const clip_image_size orig_size = img.get_size();
+    const mtmd_image_size orig_size = img.get_size();
     const int             tile_size = hparams.image_size;
     GGML_ASSERT(tile_size > 0);

     // llava-next always encodes an overview plus a grid of tiles, even for small images
-    const clip_image_size refined_size = select_best_resolution(orig_size, hparams.image_res_candidates);
+    const mtmd_image_size refined_size = select_best_resolution(orig_size, hparams.image_res_candidates);
     const int             grid_x       = refined_size.width  / tile_size;
     const int             grid_y       = refined_size.height / tile_size;

     // the tiles are stacked on the Y axis, a big grid overflows the stacked image height
     GGML_ASSERT(grid_x >= 0 && grid_x <= 1024 && grid_y >= 0 && grid_y <= 1024);

-    clip_image_u8 overview;
+    mtmd_image_u8 overview;
     img_tool::resize(img, overview, {tile_size, tile_size}, hparams.image_resize_algo_ov,
                         hparams.image_pad_ov, hparams.image_pad_color_ov);

-    clip_image_u8 refined;
+    mtmd_image_u8 refined;
     img_tool::resize(img, refined, refined_size, hparams.image_resize_algo_rf,
                         hparams.image_pad_rf, hparams.image_pad_color_rf);

     // stack the overview and the tiles on the Y axis, so the whole grid goes through one graph
-    clip_image_u8 stacked;
+    mtmd_image_u8 stacked;
     stacked.set_size({tile_size, tile_size * (1 + grid_x * grid_y)}, false);
-    auto copy_tile = [&](const clip_image_u8 & src, int src_x, int src_y, int dst_idx) {
+    auto copy_tile = [&](const mtmd_image_u8 & src, int src_x, int src_y, int dst_idx) {
         for (int py = 0; py < tile_size; py++) {
             for (int px = 0; px < tile_size; px++) {
                 stacked.set_pixel(px, dst_idx * tile_size + py, src.get_pixel(src_x + px, src_y + py));
@@ -2049,7 +2049,7 @@ mtmd_image_preproc_out mtmd_image_preprocessor_granite::preprocess(const clip_im
 //

 // Replicates transformers' get_aspect_ratio_preserving_size
-static clip_image_size muse_glimmer_grid_size(int img_w, int img_h, int patch_hw, int max_tokens) {
+static mtmd_image_size muse_glimmer_grid_size(int img_w, int img_h, int patch_hw, int max_tokens) {
     double i_nph = (double) img_h / patch_hw;
     double i_npw = (double) img_w / patch_hw;
     const double ratio = i_nph > 0.0 ? i_npw / i_nph : 1.0;
@@ -2084,24 +2084,140 @@ static clip_image_size muse_glimmer_grid_size(int img_w, int img_h, int patch_hw
         best_nph = std::max(1, (int) std::lround(i_nph));
         best_npw = std::max(1, (int) std::lround(i_npw));
     }
-    return clip_image_size{ best_npw * patch_hw, best_nph * patch_hw };
+    return mtmd_image_size{ best_npw * patch_hw, best_nph * patch_hw };
 }

-mtmd_image_preproc_out mtmd_image_preprocessor_muse_glimmer::preprocess(const clip_image_u8 & img) const {
+mtmd_image_preproc_out mtmd_image_preprocessor_muse_glimmer::preprocess(const mtmd_image_u8 & img) const {
     const int patch_hw   = hparams.patch_size * hparams.n_merge;
     const int patch_area = hparams.patch_size * hparams.patch_size * hparams.n_merge * hparams.n_merge;
     GGML_ASSERT(patch_area > 0 && hparams.image_max_pixels > 0);
     const int max_tokens = hparams.image_max_pixels / patch_area;

-    const clip_image_size original_size = img.get_size();
-    const clip_image_size target_size   = muse_glimmer_grid_size(
+    const mtmd_image_size original_size = img.get_size();
+    const mtmd_image_size target_size   = muse_glimmer_grid_size(
         original_size.width, original_size.height, patch_hw, max_tokens);

     // PIL resizes directly to (target_w, target_h) -- a stretch, no padding.
-    clip_image_u8 resized_image;
+    mtmd_image_u8 resized_image;
     img_tool::resize(img, resized_image, target_size, hparams.image_resize_algo, PAD_NONE);

     mtmd_image_preproc_out output;
     output.append(hparams, resized_image, true);
     return output;
 }
+
+//#define MTMD_DEBUG_FUNCTIONS
+
+#ifdef MTMD_DEBUG_FUNCTIONS
+static void mtmd_image_write_image_to_ppm(const mtmd_image_u8& img, const std::string& filename) {
+    std::ofstream file(filename, std::ios::binary);
+    if (!file.is_open()) {
+        LOG_ERR("Failed to open file for writing: %s\n", filename.c_str());
+        return;
+    }
+
+    // PPM header: P6 format, width, height, and max color value
+    const auto ppm_size = img.get_size();
+    file << "P6\n" << ppm_size.width << " " << ppm_size.height << "\n255\n";
+
+    // Write pixel data
+    const auto & ppm_buf = img.get_ro_buf();
+    for (size_t i = 0; i < ppm_buf.size(); i += 3) {
+        // PPM expects binary data in RGB format, which matches our image buffer
+        file.write(reinterpret_cast<const char*>(&ppm_buf[i]), 3);
+    }
+
+    file.close();
+}
+
+static void mtmd_image_save_to_bmp(const mtmd_image_u8& img, const std::string& filename) {
+    std::ofstream file(filename, std::ios::binary);
+    if (!file.is_open()) {
+        LOG_ERR("Failed to open file for writing: %s\n", filename.c_str());
+        return;
+    }
+
+    const auto bmp_size = img.get_size();
+    int fileSize = 54 + 3 * bmp_size.width * bmp_size.height; // File header + info header + pixel data
+    int bytesPerPixel = 3;
+    int widthInBytes = bmp_size.width * bytesPerPixel;
+    int paddingAmount = (4 - (widthInBytes % 4)) % 4;
+    int stride = widthInBytes + paddingAmount;
+
+    // Bitmap file header
+    unsigned char fileHeader[14] = {
+        'B','M',     // Signature
+        0,0,0,0,    // Image file size in bytes
+        0,0,0,0,    // Reserved
+        54,0,0,0    // Start of pixel array
+    };
+
+    // Total file size
+    fileSize = 54 + (stride * bmp_size.height);
+    fileHeader[2] = (unsigned char)(fileSize);
+    fileHeader[3] = (unsigned char)(fileSize >> 8);
+    fileHeader[4] = (unsigned char)(fileSize >> 16);
+    fileHeader[5] = (unsigned char)(fileSize >> 24);
+
+    // Bitmap information header (BITMAPINFOHEADER)
+    unsigned char infoHeader[40] = {
+        40,0,0,0,   // Size of this header (40 bytes)
+        0,0,0,0,    // Image width
+        0,0,0,0,    // Image height
+        1,0,        // Number of color planes
+        24,0,       // Bits per pixel
+        0,0,0,0,    // No compression
+        0,0,0,0,    // Image size (can be 0 for no compression)
+        0,0,0,0,    // X pixels per meter (not specified)
+        0,0,0,0,    // Y pixels per meter (not specified)
+        0,0,0,0,    // Total colors (color table not used)
+        0,0,0,0     // Important colors (all are important)
+    };
+
+    // Width and height in the information header
+    infoHeader[4] = (unsigned char)(bmp_size.width);
+    infoHeader[5] = (unsigned char)(bmp_size.width >> 8);
+    infoHeader[6] = (unsigned char)(bmp_size.width >> 16);
+    infoHeader[7] = (unsigned char)(bmp_size.width >> 24);
+    infoHeader[8] = (unsigned char)(bmp_size.height);
+    infoHeader[9] = (unsigned char)(bmp_size.height >> 8);
+    infoHeader[10] = (unsigned char)(bmp_size.height >> 16);
+    infoHeader[11] = (unsigned char)(bmp_size.height >> 24);
+
+    // Write file headers
+    file.write(reinterpret_cast<char*>(fileHeader), sizeof(fileHeader));
+    file.write(reinterpret_cast<char*>(infoHeader), sizeof(infoHeader));
+
+    // Pixel data
+    std::vector<unsigned char> padding(3, 0); // Max padding size to be added to each row
+    for (int y = bmp_size.height - 1; y >= 0; --y) { // BMP files are stored bottom-to-top
+        for (int x = 0; x < bmp_size.width; ++x) {
+            // Each pixel
+            const auto px = img.get_pixel(x, y);
+            unsigned char pixel[3] = {
+                px[2], // BMP stores pixels in BGR format
+                px[1],
+                px[0]
+            };
+            file.write(reinterpret_cast<char*>(pixel), 3);
+        }
+        // Write padding for the row
+        file.write(reinterpret_cast<char*>(padding.data()), paddingAmount);
+    }
+
+    file.close();
+}
+
+// debug function to convert f32 to u8
+static void mtmd_image_convert_f32_to_u8(const mtmd_image_f32& src, mtmd_image_u8& dst) {
+    dst.set_size(src.get_size(), false);
+    const auto & src_buf = src.get_ro_buf();
+    std::vector<uint8_t> dst_buf(src.n_elements());
+    for (size_t i = 0; i < src.n_elements(); ++i) {
+        dst_buf[i] = static_cast<uint8_t>(std::min(std::max(int(src_buf[i] * 255.0f), 0), 255));
+    }
+    dst.cpy_buf(dst_buf);
+}
+#endif
+
+
diff --git a/tools/mtmd/mtmd-image.h b/tools/mtmd/mtmd-image.h
index 6eb49ca34..2cb9454f0 100644
--- a/tools/mtmd/mtmd-image.h
+++ b/tools/mtmd/mtmd-image.h
@@ -1,7 +1,7 @@
 #pragma once

 #include "ggml.h"
-#include "clip-model.h"
+#include "mtmd-model.h"

 #include <vector>
 #include <string>
@@ -9,18 +9,18 @@
 #define MTMD_INTERNAL_HEADER

 struct mtmd_image_preproc_out {
-    std::vector<clip_image_f32> entries;
+    std::vector<mtmd_image_f32> entries;
     // grid size is required for llava-uhd style models

-    clip_image_f32 overview; // overview image (downscaled image)
+    mtmd_image_f32 overview; // overview image (downscaled image)
     int grid_x = 0;
     int grid_y = 0;

-    void append(const clip_hparams & hparams, const clip_image_u8 & img, bool normalized = true);
-    void append(const clip_hparams & hparams, const std::vector<clip_image_u8> & imgs, bool normalized = true);
-    void append(const clip_hparams & hparams, clip_image_f32 & img, bool normalized = true);
+    void append(const mtmd_hparams & hparams, const mtmd_image_u8 & img, bool normalized = true);
+    void append(const mtmd_hparams & hparams, const std::vector<mtmd_image_u8> & imgs, bool normalized = true);
+    void append(const mtmd_hparams & hparams, mtmd_image_f32 & img, bool normalized = true);

-    void append_overview(const clip_hparams & hparams, const clip_image_u8 & img, bool normalized = true);
+    void append_overview(const mtmd_hparams & hparams, const mtmd_image_u8 & img, bool normalized = true);
     bool has_overview() const {
         return overview.nx() > 0 || overview.ny() > 0;
     }
@@ -28,17 +28,17 @@ struct mtmd_image_preproc_out {

 // base class, models must inherit from this class
 struct mtmd_image_preprocessor {
-    const clip_hparams & hparams;
+    const mtmd_hparams & hparams;

-    mtmd_image_preprocessor(const clip_ctx * ctx): hparams(*clip_get_hparams(ctx)) {}
-    mtmd_image_preprocessor(const clip_hparams & hparams): hparams(hparams) {}
+    mtmd_image_preprocessor(const mtmd_model * model): hparams(model->hparams) {}
+    mtmd_image_preprocessor(const mtmd_hparams & hparams): hparams(hparams) {}

     virtual ~mtmd_image_preprocessor() = default;
-    virtual mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const = 0;
+    virtual mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const = 0;
     // frames of a video, which the video helper may have already sized
-    virtual mtmd_image_preproc_out preprocess_video_frame(const clip_image_u8 & img) const { return preprocess(img); }
+    virtual mtmd_image_preproc_out preprocess_video_frame(const mtmd_image_u8 & img) const { return preprocess(img); }
     // frame size of a video with n_frames sampled frames, as the reference video processor; false if none
-    virtual bool get_video_frame_size(int /*n_frames*/, int /*width*/, int /*height*/, clip_image_size & /*out*/) const { return false; }
+    virtual bool get_video_frame_size(int /*n_frames*/, int /*width*/, int /*height*/, mtmd_image_size & /*out*/) const { return false; }
 };

 /**
@@ -63,30 +63,30 @@ struct mtmd_image_preprocessor {
  * NOTE: for the ordering of overview, set "ov_img_first" on the mtmd_context
  */
 struct mtmd_image_preprocessor_llava_uhd : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_llava_uhd(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preprocessor_llava_uhd(const clip_hparams & hparams) : mtmd_image_preprocessor(hparams) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_llava_uhd(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preprocessor_llava_uhd(const mtmd_hparams & hparams) : mtmd_image_preprocessor(hparams) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;

     struct slice_coordinates {
         int x;
         int y;
-        clip_image_size size;
+        mtmd_image_size size;
     };

     struct slice_instructions {
-        clip_image_size overview_size; // size of downscaled image
-        clip_image_size refined_size;  // size of image right before slicing (must be multiple of slice size)
-        clip_image_size grid_size;     // grid_size.width * grid_size.height = number of slices
+        mtmd_image_size overview_size; // size of downscaled image
+        mtmd_image_size refined_size;  // size of image right before slicing (must be multiple of slice size)
+        mtmd_image_size grid_size;     // grid_size.width * grid_size.height = number of slices
         std::vector<slice_coordinates> slices;
     };

-    virtual slice_instructions get_slice_instructions(const clip_image_size & original_size) const;
+    virtual slice_instructions get_slice_instructions(const mtmd_image_size & original_size) const;

     struct slice_output {
-        clip_image_u8 overview;
-        std::vector<clip_image_u8> slices;
+        mtmd_image_u8 overview;
+        std::vector<mtmd_image_u8> slices;
     };
-    slice_output slice_image(const clip_image_u8 & img, const slice_instructions & inst) const;
+    slice_output slice_image(const mtmd_image_u8 & img, const slice_instructions & inst) const;

 protected:
     // align slices to a multiple of the merger factor (integer merger tokens per slice)
@@ -100,7 +100,7 @@ protected:
         return static_cast<int>(std::round(v));
     }

-    clip_image_size get_best_resize(const clip_image_size & original_size, int scale_resolution, int patch_size, bool allow_upscale = false) const;
+    mtmd_image_size get_best_resize(const mtmd_image_size & original_size, int scale_resolution, int patch_size, bool allow_upscale = false) const;

     /**
      * Selects the best resolution from a list of possible resolutions based on the original size.
@@ -117,68 +117,68 @@ protected:
      * @param possible_resolutions A list of possible resolutions
      * @return The best fit resolution
      */
-    clip_image_size select_best_resolution(const clip_image_size & original_size, const std::vector<clip_image_size> & possible_resolutions) const;
+    mtmd_image_size select_best_resolution(const mtmd_image_size & original_size, const std::vector<mtmd_image_size> & possible_resolutions) const;

 private:
-    clip_image_size resize_maintain_aspect_ratio(const clip_image_size & orig, const clip_image_size & target_max) const;
+    mtmd_image_size resize_maintain_aspect_ratio(const mtmd_image_size & orig, const mtmd_image_size & target_max) const;
     int ensure_divide(int length, int patch_size) const;
-    clip_image_size get_refine_size(const clip_image_size & original_size, const clip_image_size & grid, int scale_resolution, int patch_size, bool allow_upscale = false) const;
-    clip_image_size get_best_grid(const int max_slice_nums, const int multiple, const float log_ratio) const;
+    mtmd_image_size get_refine_size(const mtmd_image_size & original_size, const mtmd_image_size & grid, int scale_resolution, int patch_size, bool allow_upscale = false) const;
+    mtmd_image_size get_best_grid(const int max_slice_nums, const int multiple, const float log_ratio) const;
 };

 // downscale or upscale the input image to fixed size
 struct mtmd_image_preprocessor_fixed_size : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_fixed_size(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_fixed_size(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 // resize image to multiple of patch_size*n_merge, while preserving aspect ratio
 // if image_resize_pad is true, the resized image will be padded, otherwise it will be either stretched or center-cropped depending on image_resize_pad
 // this is used by models with native support for dynamic image size, for example: Qwen-VL, Pixtral, Kimi-VL, etc
 struct mtmd_image_preprocessor_dyn_size : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_dyn_size(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preprocessor_dyn_size(const clip_hparams & hparams) : mtmd_image_preprocessor(hparams) {}
-    static clip_image_size get_target_size(const clip_hparams & hparams, const clip_image_size & original_size);
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
-    virtual clip_image_size target_size(const clip_image_size & original_size) const { return get_target_size(hparams, original_size); }
+    mtmd_image_preprocessor_dyn_size(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preprocessor_dyn_size(const mtmd_hparams & hparams) : mtmd_image_preprocessor(hparams) {}
+    static mtmd_image_size get_target_size(const mtmd_hparams & hparams, const mtmd_image_size & original_size);
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
+    virtual mtmd_image_size target_size(const mtmd_image_size & original_size) const { return get_target_size(hparams, original_size); }
 };

 // Qwen3-VL, same as dyn_size for images, video frames are sized as Qwen3VLVideoProcessor
 struct mtmd_image_preprocessor_qwen3vl : mtmd_image_preprocessor_dyn_size {
     using mtmd_image_preprocessor_dyn_size::mtmd_image_preprocessor_dyn_size;
     // the video helper sizes the frames (get_video_frame_size), they are only snapped to the grid
-    mtmd_image_preproc_out preprocess_video_frame(const clip_image_u8 & img) const override;
-    bool get_video_frame_size(int n_frames, int width, int height, clip_image_size & out) const override;
+    mtmd_image_preproc_out preprocess_video_frame(const mtmd_image_u8 & img) const override;
+    bool get_video_frame_size(int n_frames, int width, int height, mtmd_image_size & out) const override;

-    mtmd_image_preproc_out resize_to(const clip_image_u8 & img, const clip_image_size & size) const;
+    mtmd_image_preproc_out resize_to(const mtmd_image_u8 & img, const mtmd_image_size & size) const;
     // with temporal_factor > 1 it is the Qwen3-VL video variant over n_frames
-    static clip_image_size smart_resize(int width, int height, int factor, int64_t min_pixels, int64_t max_pixels,
+    static mtmd_image_size smart_resize(int width, int height, int factor, int64_t min_pixels, int64_t max_pixels,
                                         int n_frames = 1, int temporal_factor = 1);
 };

 // LFM3-VL, same as qwen3vl but images are sized exactly as the transformers Qwen2-VL smart_resize
 struct mtmd_image_preprocessor_lfm3vl : mtmd_image_preprocessor_qwen3vl {
     using mtmd_image_preprocessor_qwen3vl::mtmd_image_preprocessor_qwen3vl;
-    clip_image_size target_size(const clip_image_size & original_size) const override;
+    mtmd_image_size target_size(const mtmd_image_size & original_size) const override;
 };

 // GLM 5.3 flash, similar to dyn_size, but each edge is aligned up to patch_size*n_merge, and max budget is met by a search over the height with the width scaled proportionally
 struct mtmd_image_preprocessor_glm5v : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_glm5v(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_glm5v(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 // similar to mtmd_image_preprocessor_dyn_size, but cap the longest edge at hparams.image_longest_edge, while preserving aspect ratio
 struct mtmd_image_preprocessor_longest_edge : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_longest_edge(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    static clip_image_size get_target_size(const clip_hparams & hparams, const clip_image_size & original_size);
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_longest_edge(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    static mtmd_image_size get_target_size(const mtmd_hparams & hparams, const mtmd_image_size & original_size);
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 // ref: inference/image_processor.py in the HF repo (DeepSeek-V4-Flash-Vision)
 struct mtmd_image_preprocessor_deepseek4v : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_deepseek4v(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_deepseek4v(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;

 private:
     struct grid_info {
@@ -193,8 +193,8 @@ private:

 // ref: inference/image_processor.py in the HF repo (DeepSeek-V4.1-Flash): plan_image_grid / load_image
 struct mtmd_image_preprocessor_deepseek41v : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_deepseek41v(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_deepseek41v(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
     // target pixel size (multiples of patch_size) for an image of the given size, exposed for tests
     static void plan_grid(int width, int height, int p, int r, int max_n_token, int min_pixels,
                           int & best_width, int & best_height);
@@ -203,7 +203,7 @@ struct mtmd_image_preprocessor_deepseek41v : mtmd_image_preprocessor {
 // custom llava-uhd slicing logic for MiniCPM-V
 struct mtmd_image_preprocessor_minicpmv : mtmd_image_preprocessor_llava_uhd {
     using mtmd_image_preprocessor_llava_uhd::mtmd_image_preprocessor_llava_uhd;
-    slice_instructions get_slice_instructions(const clip_image_size & original_size) const override;
+    slice_instructions get_slice_instructions(const mtmd_image_size & original_size) const override;

 protected:
     // always patch_size * 4, even in 4x mode (the 2x2 vit_merger slot stays)
@@ -242,50 +242,50 @@ protected:
 // ref: https://github.com/huggingface/transformers/blob/v5.1.0/src/transformers/models/lfm2_vl/image_processing_lfm2_vl_fast.py
 struct mtmd_image_preprocessor_lfm2 : mtmd_image_preprocessor_llava_uhd {
     using mtmd_image_preprocessor_llava_uhd::mtmd_image_preprocessor_llava_uhd;
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
-    slice_instructions get_slice_instructions(const clip_image_size & original_size) const override;
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
+    slice_instructions get_slice_instructions(const mtmd_image_size & original_size) const override;

-    static bool should_tile(const clip_hparams & hparams, const clip_image_size & original_size);
-    static clip_image_size smart_resize(const clip_hparams & hparams, const clip_image_size & original_size);
-    static clip_image_size get_grid_layout(const clip_hparams & hparams, int height, int width);
+    static bool should_tile(const mtmd_hparams & hparams, const mtmd_image_size & original_size);
+    static mtmd_image_size smart_resize(const mtmd_hparams & hparams, const mtmd_image_size & original_size);
+    static mtmd_image_size get_grid_layout(const mtmd_hparams & hparams, int height, int width);

 private:
-    static clip_image_size find_closest_aspect_ratio(
-            const clip_hparams & hparams,
+    static mtmd_image_size find_closest_aspect_ratio(
+            const mtmd_hparams & hparams,
             double aspect_ratio,
-            const std::vector<clip_image_size> & target_ratios,
+            const std::vector<mtmd_image_size> & target_ratios,
             int width, int height);
-    static std::vector<clip_image_size> get_target_ratios(const clip_hparams & hparams);
+    static std::vector<mtmd_image_size> get_target_ratios(const mtmd_hparams & hparams);
 };

 struct mtmd_image_preprocessor_idefics3 : mtmd_image_preprocessor_llava_uhd {
-    mtmd_image_preprocessor_idefics3(const clip_ctx * ctx) : mtmd_image_preprocessor_llava_uhd(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_idefics3(const mtmd_model * model) : mtmd_image_preprocessor_llava_uhd(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 struct mtmd_image_preprocessor_internvl : mtmd_image_preprocessor_llava_uhd {
-    mtmd_image_preprocessor_internvl(const clip_ctx * ctx) : mtmd_image_preprocessor_llava_uhd(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_internvl(const mtmd_model * model) : mtmd_image_preprocessor_llava_uhd(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 // stretch the image to a grid of tiles, add a thumbnail if there is more than 1 tile
 // ref: https://github.com/huggingface/transformers/blob/main/src/transformers/models/cohere2_vision/image_processing_cohere2_vision.py
 struct mtmd_image_preprocessor_cohere2v : mtmd_image_preprocessor_llava_uhd {
-    mtmd_image_preprocessor_cohere2v(const clip_ctx * ctx) : mtmd_image_preprocessor_llava_uhd(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
-    slice_instructions get_slice_instructions(const clip_image_size & original_size) const override;
+    mtmd_image_preprocessor_cohere2v(const mtmd_model * model) : mtmd_image_preprocessor_llava_uhd(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
+    slice_instructions get_slice_instructions(const mtmd_image_size & original_size) const override;
 };

 // DeepSeek-OCR (v1/v2) global view + optional local tile grid
 struct mtmd_image_preprocessor_deepseekocr : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_deepseekocr(const clip_ctx * ctx)
-        : mtmd_image_preprocessor(ctx),
-            fuse_row(clip_get_projector_type(ctx) == PROJECTOR_TYPE_DEEPSEEKOCR),
+    mtmd_image_preprocessor_deepseekocr(const mtmd_model * model)
+        : mtmd_image_preprocessor(model),
+            fuse_row(model->proj_type == PROJECTOR_TYPE_DEEPSEEKOCR),
           base_size(hparams.image_size),
           tile_size(hparams.preproc_tile_size),
           min_tiles(hparams.preproc_min_tiles),
           max_tiles(hparams.preproc_max_tiles) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;

 private:
     bool fuse_row; // v1 fuses a tile-row into one image; v2 keeps tiles separate
@@ -294,19 +294,19 @@ private:
     int min_tiles;
     int max_tiles;

-    std::vector<clip_image_size> get_target_ratios() const;
-    clip_image_size find_closest_aspect_ratio(
+    std::vector<mtmd_image_size> get_target_ratios() const;
+    mtmd_image_size find_closest_aspect_ratio(
             float aspect_ratio,
-            const std::vector<clip_image_size> & target_ratios,
+            const std::vector<mtmd_image_size> & target_ratios,
             int width, int height) const;
 };

 // custom image preprocessing for Step3VL
 // ref: https://huggingface.co/stepfun-ai/Step3-VL-10B/blob/main/processing_step3.py
 struct mtmd_image_preprocessor_step3vl : mtmd_image_preprocessor_llava_uhd {
-    mtmd_image_preprocessor_step3vl(const clip_ctx * ctx) : mtmd_image_preprocessor_llava_uhd(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
-    static slice_instructions build_slice_instructions(const clip_hparams & params, const clip_image_size & prepared_size);
+    mtmd_image_preprocessor_step3vl(const mtmd_model * model) : mtmd_image_preprocessor_llava_uhd(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
+    static slice_instructions build_slice_instructions(const mtmd_hparams & params, const mtmd_image_size & prepared_size);

 private:
     static constexpr int   default_image_longest_edge = 3024;
@@ -316,33 +316,33 @@ private:
     static constexpr float crop_rounding_threshold    = 0.2f;

     void img_u8_resize_bilinear_to_f32(
-            const clip_image_u8 & src,
-            clip_image_f32 & dst,
+            const mtmd_image_u8 & src,
+            mtmd_image_f32 & dst,
             int target_width,
             int target_height,
             const float mean[3],
             const float std[3]) const;
-    static int get_image_longest_edge(const clip_hparams & params);
-    static int determine_window_size(const clip_hparams & params, int longer, int shorter);
+    static int get_image_longest_edge(const mtmd_hparams & params);
+    static int determine_window_size(const mtmd_hparams & params, int longer, int shorter);
     static int calc_crop_extent(int length, int window_size);
     static std::vector<int> calc_grid(int length, int window_size);
-    static clip_image_u8 prepare_image(const clip_image_u8 & img, const clip_hparams & params);
-    static clip_image_u8 crop_with_black_padding(const clip_image_u8 & image, int x, int y, int w, int h);
+    static mtmd_image_u8 prepare_image(const mtmd_image_u8 & img, const mtmd_hparams & params);
+    static mtmd_image_u8 crop_with_black_padding(const mtmd_image_u8 & image, int x, int y, int w, int h);
 };

 struct mtmd_image_preprocessor_youtuvl : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_youtuvl(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_youtuvl(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

-// llava-next "anyres": stacks the overview and all tiles into one image, assembled by clip in a single graph
+// llava-next "anyres": stacks the overview and all tiles into one image, assembled in a single graph
 struct mtmd_image_preprocessor_granite : mtmd_image_preprocessor_llava_uhd {
-    mtmd_image_preprocessor_granite(const clip_ctx * ctx) : mtmd_image_preprocessor_llava_uhd(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_granite(const mtmd_model * model) : mtmd_image_preprocessor_llava_uhd(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };

 // pick the patch grid closest to the input aspect ratio under the per-image token cap, stretch-resize.
 struct mtmd_image_preprocessor_muse_glimmer : mtmd_image_preprocessor {
-    mtmd_image_preprocessor_muse_glimmer(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
-    mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
+    mtmd_image_preprocessor_muse_glimmer(const mtmd_model * model) : mtmd_image_preprocessor(model) {}
+    mtmd_image_preproc_out preprocess(const mtmd_image_u8 & img) const override;
 };
diff --git a/tools/mtmd/clip-impl.h b/tools/mtmd/mtmd-impl.h
similarity index 92%
rename from tools/mtmd/clip-impl.h
rename to tools/mtmd/mtmd-impl.h
index 8a4098da8..e245f4914 100644
--- a/tools/mtmd/clip-impl.h
+++ b/tools/mtmd/mtmd-impl.h
@@ -3,7 +3,7 @@
 #include "ggml.h"
 #include "ggml-cpp.h"
 #include "gguf.h"
-#include "clip.h"
+#include "mtmd.h"

 #include <array>
 #include <climits>
@@ -25,7 +25,7 @@
 #include <windows.h>
 #endif

-// Internal header for clip.cpp
+// !!! Internal header, to be used by mtmd only !!!

 #define MTMD_INTERNAL_HEADER

@@ -490,42 +490,7 @@
 #define TN_A_FUSION_KERNEL       "a.fusion.spk_kernel"

 // align x to upper multiple of n
-#define CLIP_ALIGN(x, n) ((((x) + (n) - 1) / (n)) * (n))
-
-// sliding window KV cache of a causal audio encoder, see clip_enc_state_init()
-struct clip_enc_state {
-    int32_t n_ctx  = 0; // ring slots: attention window + frames per call
-    int32_t n_past = 0; // absolute position of the next frame
-
-    std::vector<int32_t> slot_pos;  // absolute position held by each slot, -1 if empty
-    std::vector<float>   stem_tail; // last conv stem output frame, empty on a cold start
-
-    ggml_context_ptr        ctx_data;
-    ggml_backend_buffer_ptr buf;
-    std::vector<ggml_tensor *> k; // per layer [n_embd_kv, n_ctx]
-    std::vector<ggml_tensor *> v;
-
-    // lfm3a: same order as list_lfm3a_state_slots(), F32 [ne0, ne1], updated in place
-    std::vector<ggml_tensor *> slots;
-
-    // nemotron transcribe: the window's normalized log-mel, cache and FIFO rows in; its speaker rows and predictions out
-    std::vector<float> mel_norm;
-    std::vector<float> spk_prev;
-    std::vector<float> spk_chunk, spk_preds;
-};
-
-// per-sequence state of audio generation, see clip_gen_state_init()
-struct clip_gen_state {
-    std::mt19937 rng;
-
-    ggml_context_ptr        ctx_data;
-    ggml_backend_buffer_ptr buf;
-    std::vector<ggml_tensor *> slots; // same order as list_gen_state_slots(), F32 [ne0, ne1]
-};
-
-// forward declaration
-// TODO: improve this later
-struct clip_ctx;
+#define MTMD_ALIGN(x, n) ((((x) + (n) - 1) / (n)) * (n))

 enum projector_type {
     PROJECTOR_TYPE_MLP,
@@ -555,7 +520,7 @@ enum projector_type {
     PROJECTOR_TYPE_QWEN2A,
     PROJECTOR_TYPE_QWEN3A,
     PROJECTOR_TYPE_GLMA,
-    PROJECTOR_TYPE_QWEN25O, // will be replaced by QWEN2A or QWEN25VL depending on clip_ctx
+    PROJECTOR_TYPE_QWEN25O, // will be replaced by QWEN2A or QWEN25VL depending on mtmd_subcontext
     PROJECTOR_TYPE_VOXTRAL,
     PROJECTOR_TYPE_VOXTRAL_RT,
     PROJECTOR_TYPE_MERALION,
@@ -699,7 +664,7 @@ static std::map<projector_type, std::string> PROJECTOR_TYPE_NAMES = {
     { PROJECTOR_TYPE_CHATTERBOX_SPKENC, "chatterbox_spkenc"},
 };

-static projector_type clip_projector_type_from_string(const std::string & str) {
+static projector_type projector_type_from_string(const std::string & str) {
     for (const auto & pair : PROJECTOR_TYPE_NAMES) {
         if (pair.second == str) {
             return pair.first;
@@ -708,13 +673,30 @@ static projector_type clip_projector_type_from_string(const std::string & str) {
     return PROJECTOR_TYPE_UNKNOWN;
 }

+struct mtmd_image_size {
+    int width;
+    int height;
+    bool operator==(const mtmd_image_size & other) const {
+        return width == other.width && height == other.height;
+    }
+    bool operator!=(const mtmd_image_size & other) const {
+        return !(*this == other);
+    }
+    int area() const {
+        // avoid overflow when computing area
+        GGML_ASSERT(width  >= 0 && width  <= 46000);
+        GGML_ASSERT(height >= 0 && height <= 46000);
+        return width * height;
+    }
+};
+
 // RGB uint8 image
-struct clip_image_u8 {
-    clip_image_size get_size() const {
+struct mtmd_image_u8 {
+    mtmd_image_size get_size() const {
         return { nx, ny };
     }

-    void set_size(clip_image_size size, bool is_placeholder) {
+    void set_size(mtmd_image_size size, bool is_placeholder) {
         nx = size.width;
         ny = size.height;
         if (is_placeholder) {
@@ -730,7 +712,7 @@ struct clip_image_u8 {

     const std::vector<uint8_t> & get_ro_buf() const {
         if (is_placeholder()) {
-            throw std::runtime_error("this clip_image_u8 is a placeholder");
+            throw std::runtime_error("this mtmd_image_u8 is a placeholder");
         }
         return buf;
     }
@@ -777,20 +759,20 @@ struct clip_image_u8 {
 struct mtmd_serialization; // forward declaration

 // separators appended after the image tokens of one entry, as rows of v.tok_embd_sep
-enum clip_suffix_type : int32_t {
-    CLIP_SUFFIX_NONE = 0,
+enum mtmd_suffix_type : int32_t {
+    MTMD_SUFFIX_NONE = 0,
     // MiniCPM-V 4.7 tiles
-    CLIP_SUFFIX_MINICPMV_OV,       // </image>
-    CLIP_SUFFIX_MINICPMV_OV_SLICE, // </image><slice>
-    CLIP_SUFFIX_MINICPMV_SLICE,    // </slice><slice>
-    CLIP_SUFFIX_MINICPMV_ROW_END,  // </slice>\n<slice>
-    CLIP_SUFFIX_MINICPMV_LAST,     // </slice>
-    CLIP_SUFFIX_COUNT,
+    MTMD_SUFFIX_MINICPMV_OV,       // </image>
+    MTMD_SUFFIX_MINICPMV_OV_SLICE, // </image><slice>
+    MTMD_SUFFIX_MINICPMV_SLICE,    // </slice><slice>
+    MTMD_SUFFIX_MINICPMV_ROW_END,  // </slice>\n<slice>
+    MTMD_SUFFIX_MINICPMV_LAST,     // </slice>
+    MTMD_SUFFIX_COUNT,
 };

 // rows of v.tok_embd_sep for each suffix type
 // MiniCPM-V 4.7 rows (set by the converter): 0 = </image>, 1 = <slice>, 2 = </slice>, 3 = \n
-static inline const std::vector<int> & clip_suffix_rows(clip_suffix_type type) {
+static inline const std::vector<int> & mtmd_suffix_rows(mtmd_suffix_type type) {
     static const std::vector<int> none;
     static const std::vector<int> minicpmv_ov       = { 0 };
     static const std::vector<int> minicpmv_ov_slice = { 0, 1 };
@@ -798,12 +780,12 @@ static inline const std::vector<int> & clip_suffix_rows(clip_suffix_type type) {
     static const std::vector<int> minicpmv_row_end  = { 2, 3, 1 };
     static const std::vector<int> minicpmv_last     = { 2 };
     switch (type) {
-        case CLIP_SUFFIX_NONE:              return none;
-        case CLIP_SUFFIX_MINICPMV_OV:       return minicpmv_ov;
-        case CLIP_SUFFIX_MINICPMV_OV_SLICE: return minicpmv_ov_slice;
-        case CLIP_SUFFIX_MINICPMV_SLICE:    return minicpmv_slice;
-        case CLIP_SUFFIX_MINICPMV_ROW_END:  return minicpmv_row_end;
-        case CLIP_SUFFIX_MINICPMV_LAST:     return minicpmv_last;
+        case MTMD_SUFFIX_NONE:              return none;
+        case MTMD_SUFFIX_MINICPMV_OV:       return minicpmv_ov;
+        case MTMD_SUFFIX_MINICPMV_OV_SLICE: return minicpmv_ov_slice;
+        case MTMD_SUFFIX_MINICPMV_SLICE:    return minicpmv_slice;
+        case MTMD_SUFFIX_MINICPMV_ROW_END:  return minicpmv_row_end;
+        case MTMD_SUFFIX_MINICPMV_LAST:     return minicpmv_last;
         default: GGML_ABORT("invalid suffix type");
     }
 }
@@ -814,7 +796,7 @@ static inline const std::vector<int> & clip_suffix_rows(clip_suffix_type type) {
 //     Memory layout: RGBRGB...RGBRGB... (nt times)
 // For audio, only one channel is used, buf.size() == nx*ny
 //     nx will be n_frames and ny will be n_mel
-struct clip_image_f32 {
+struct mtmd_image_f32 {
     // marks the global view in e.g., DeepSeek-OCR Models
     bool add_viewsep = false;
     // appends a learned newline (or EOI) token after the image
@@ -824,7 +806,7 @@ struct clip_image_f32 {
     // depends on the chunk position, set at tokenize time (see mtmd_tokenizer::add_media)
     int32_t lead_pad = 0;
     // separators appended after the image tokens
-    clip_suffix_type suffix_type = CLIP_SUFFIX_NONE;
+    mtmd_suffix_type suffix_type = MTMD_SUFFIX_NONE;

     // tile grid of the image group this entry belongs to
     // llava-next "anyres" (Granite4 Vision): the whole grid is encoded and assembled in a single graph
@@ -841,14 +823,14 @@ struct clip_image_f32 {
     };
     anyres_info anyres;

-    clip_image_size get_size() const {
+    mtmd_image_size get_size() const {
         return { nx_, ny_ };
     }

     int nx() const { return nx_; }
     int ny() const { return ny_; }

-    void set_size(clip_image_size size, bool is_placeholder, bool is_audio) {
+    void set_size(mtmd_image_size size, bool is_placeholder, bool is_audio) {
         nx_ = size.width;
         ny_ = size.height;
         if (is_placeholder) {
@@ -866,7 +848,7 @@ struct clip_image_f32 {
         buf = new_buf;
     }

-    void from_u8(const clip_image_u8 & img) {
+    void from_u8(const mtmd_image_u8 & img) {
         auto size = img.get_size();
         nx_ = size.width;
         ny_ = size.height;
@@ -898,7 +880,7 @@ struct clip_image_f32 {

     const std::vector<float> & get_ro_buf() const {
         if (is_placeholder()) {
-            throw std::runtime_error("this clip_image_f32 is a placeholder");
+            throw std::runtime_error("this mtmd_image_f32 is a placeholder");
         }
         return buf;
     }
@@ -924,7 +906,7 @@ struct clip_image_f32 {

 // token area kept after removing the padding added by the anyres resize
 // ref: https://github.com/huggingface/transformers/blob/v5.0.0/src/transformers/models/llava_next/modeling_llava_next.py#L109
-static inline void clip_anyres_unpad(int cur_w, int cur_h, int orig_w, int orig_h,
+static inline void mtmd_anyres_unpad(int cur_w, int cur_h, int orig_w, int orig_h,
                                      int & off_x, int & off_y, int & out_w, int & out_h) {
     off_x = 0;
     off_y = 0;
@@ -966,21 +948,21 @@ static inline dsv4_block_layout dsv4_get_block_layout(int n_llm_w, int n_llm_h,
 // logging
 //

-static void clip_log_callback_default(enum ggml_log_level level, const char * text, void * user_data) {
+static void mtmd_log_callback_default(enum ggml_log_level level, const char * text, void * user_data) {
     (void) level;
     (void) user_data;
     fputs(text, stderr);
     fflush(stderr);
 }

-struct clip_logger_state {
+struct mtmd_logger_state {
     ggml_log_callback log_callback;
     void * log_callback_user_data;
 };

-extern struct clip_logger_state g_logger_state;
+extern struct mtmd_logger_state g_logger_state;

-static void clip_log_internal_v(enum ggml_log_level level, const char * format, va_list args) {
+static void mtmd_log_internal_v(enum ggml_log_level level, const char * format, va_list args) {
     if (format == NULL) {
         return;
     }
@@ -1000,31 +982,31 @@ static void clip_log_internal_v(enum ggml_log_level level, const char * format,
     va_end(args_copy);
 }

-static void clip_log_internal(enum ggml_log_level level, const char * format, ...) {
+static void mtmd_log_internal(enum ggml_log_level level, const char * format, ...) {
     va_list args;
     va_start(args, format);
-    clip_log_internal_v(level, format, args);
+    mtmd_log_internal_v(level, format, args);
     va_end(args);
 }

-#define LOG_TRC(...) clip_log_internal(GGML_LOG_LEVEL_DEBUG, __VA_ARGS__)
-#define LOG_DBG(...) clip_log_internal(GGML_LOG_LEVEL_DEBUG, __VA_ARGS__)
-#define LOG_INF(...) clip_log_internal(GGML_LOG_LEVEL_INFO,  __VA_ARGS__)
-#define LOG_WRN(...) clip_log_internal(GGML_LOG_LEVEL_WARN,  __VA_ARGS__)
-#define LOG_ERR(...) clip_log_internal(GGML_LOG_LEVEL_ERROR, __VA_ARGS__)
-#define LOG_CNT(...) clip_log_internal(GGML_LOG_LEVEL_CONT,  __VA_ARGS__)
+#define LOG_TRC(...) mtmd_log_internal(GGML_LOG_LEVEL_DEBUG, __VA_ARGS__)
+#define LOG_DBG(...) mtmd_log_internal(GGML_LOG_LEVEL_DEBUG, __VA_ARGS__)
+#define LOG_INF(...) mtmd_log_internal(GGML_LOG_LEVEL_INFO,  __VA_ARGS__)
+#define LOG_WRN(...) mtmd_log_internal(GGML_LOG_LEVEL_WARN,  __VA_ARGS__)
+#define LOG_ERR(...) mtmd_log_internal(GGML_LOG_LEVEL_ERROR, __VA_ARGS__)
+#define LOG_CNT(...) mtmd_log_internal(GGML_LOG_LEVEL_CONT,  __VA_ARGS__)

 //
 // cpp wrappers
 //

-struct clip_image_f32_batch {
-    std::vector<clip_image_f32> entries;
+struct mtmd_image_f32_batch {
+    std::vector<mtmd_image_f32> entries;
     bool is_audio = false;
     bool is_video = false;

-    clip_image_f32_batch clone() const {
-        clip_image_f32_batch new_batch{
+    mtmd_image_f32_batch clone() const {
+        mtmd_image_f32_batch new_batch{
             /* entries  */ {},
             /* is_audio */ is_audio,
             /* is_video */ is_video,
@@ -1244,9 +1226,3 @@ static void print_tensor_data(ggml_tensor * t, uint8_t * data, int64_t n) {
     }
 }

-//
-// API used internally with mtmd
-//
-
-projector_type clip_get_projector_type(const struct clip_ctx * ctx);
-void clip_set_debug_output_embeddings(struct clip_ctx * ctx, bool debug);
diff --git a/tools/mtmd/clip.cpp b/tools/mtmd/mtmd-model.cpp
similarity index 57%
rename from tools/mtmd/clip.cpp
rename to tools/mtmd/mtmd-model.cpp
index 0a3b3c98d..ae7a942ac 100644
--- a/tools/mtmd/clip.cpp
+++ b/tools/mtmd/mtmd-model.cpp
@@ -1,15 +1,11 @@
-#include "clip.h"
-#include "clip-impl.h"
-#include "clip-model.h"
-#include "clip-graph.h"
+#include "mtmd-impl.h"
+#include "mtmd-model.h"
 #include "models/models.h"
-#include "mtmd-audio.h"

 #include "ggml.h"
 #include "ggml-cpp.h"
 #include "ggml-alloc.h"
 #include "ggml-backend.h"
-#include "ggml-cpu.h"
 #include "gguf.h"

 #include <algorithm>
@@ -19,7 +15,6 @@
 #include <cstring>
 #include <fstream>
 #include <map>
-#include <memory>
 #include <random>
 #include <stdexcept>
 #include <unordered_set>
@@ -30,1553 +25,245 @@
 #include <functional>
 #include <float.h>

-struct clip_logger_state g_logger_state = {clip_log_callback_default, NULL};
-
-//#define CLIP_DEBUG_FUNCTIONS
+//
+// mtmd_model_loader
+//

-#ifdef CLIP_DEBUG_FUNCTIONS
-static void clip_image_write_image_to_ppm(const clip_image_u8& img, const std::string& filename) {
-    std::ofstream file(filename, std::ios::binary);
-    if (!file.is_open()) {
-        LOG_ERR("Failed to open file for writing: %s\n", filename.c_str());
-        return;
-    }
+struct mtmd_model_loader {
+    ggml_context_ptr ctx_meta;
+    gguf_context_ptr ctx_gguf;

-    // PPM header: P6 format, width, height, and max color value
-    const auto ppm_size = img.get_size();
-    file << "P6\n" << ppm_size.width << " " << ppm_size.height << "\n255\n";
+    std::string fname;

-    // Write pixel data
-    const auto & ppm_buf = img.get_ro_buf();
-    for (size_t i = 0; i < ppm_buf.size(); i += 3) {
-        // PPM expects binary data in RGB format, which matches our image buffer
-        file.write(reinterpret_cast<const char*>(&ppm_buf[i]), 3);
-    }
+    size_t model_size = 0; // in bytes

-    file.close();
-}
+    bool has_vision    = false;
+    bool has_audio     = false;
+    bool has_gen_audio = false;

-static void clip_image_save_to_bmp(const clip_image_u8& img, const std::string& filename) {
-    std::ofstream file(filename, std::ios::binary);
-    if (!file.is_open()) {
-        LOG_ERR("Failed to open file for writing: %s\n", filename.c_str());
-        return;
-    }
+    mtmd_progress_callback progress_callback = nullptr;
+    void * progress_callback_user_data = nullptr;

-    const auto bmp_size = img.get_size();
-    int fileSize = 54 + 3 * bmp_size.width * bmp_size.height; // File header + info header + pixel data
-    int bytesPerPixel = 3;
-    int widthInBytes = bmp_size.width * bytesPerPixel;
-    int paddingAmount = (4 - (widthInBytes % 4)) % 4;
-    int stride = widthInBytes + paddingAmount;
-
-    // Bitmap file header
-    unsigned char fileHeader[14] = {
-        'B','M',     // Signature
-        0,0,0,0,    // Image file size in bytes
-        0,0,0,0,    // Reserved
-        54,0,0,0    // Start of pixel array
-    };
+    mtmd_model_loader(const char * fname,
+            bool skip_tensors = false,
+            mtmd_progress_callback progress_cb = nullptr,
+            void * progress_user_data = nullptr)
+        : fname(fname),
+          progress_callback(progress_cb),
+          progress_callback_user_data(progress_user_data) {
+        struct ggml_context * meta = nullptr;

-    // Total file size
-    fileSize = 54 + (stride * bmp_size.height);
-    fileHeader[2] = (unsigned char)(fileSize);
-    fileHeader[3] = (unsigned char)(fileSize >> 8);
-    fileHeader[4] = (unsigned char)(fileSize >> 16);
-    fileHeader[5] = (unsigned char)(fileSize >> 24);
-
-    // Bitmap information header (BITMAPINFOHEADER)
-    unsigned char infoHeader[40] = {
-        40,0,0,0,   // Size of this header (40 bytes)
-        0,0,0,0,    // Image width
-        0,0,0,0,    // Image height
-        1,0,        // Number of color planes
-        24,0,       // Bits per pixel
-        0,0,0,0,    // No compression
-        0,0,0,0,    // Image size (can be 0 for no compression)
-        0,0,0,0,    // X pixels per meter (not specified)
-        0,0,0,0,    // Y pixels per meter (not specified)
-        0,0,0,0,    // Total colors (color table not used)
-        0,0,0,0     // Important colors (all are important)
-    };
+        struct gguf_init_params params = {
+            /*.no_alloc = */ true,
+            /*.ctx      = */ &meta,
+        };

-    // Width and height in the information header
-    infoHeader[4] = (unsigned char)(bmp_size.width);
-    infoHeader[5] = (unsigned char)(bmp_size.width >> 8);
-    infoHeader[6] = (unsigned char)(bmp_size.width >> 16);
-    infoHeader[7] = (unsigned char)(bmp_size.width >> 24);
-    infoHeader[8] = (unsigned char)(bmp_size.height);
-    infoHeader[9] = (unsigned char)(bmp_size.height >> 8);
-    infoHeader[10] = (unsigned char)(bmp_size.height >> 16);
-    infoHeader[11] = (unsigned char)(bmp_size.height >> 24);
-
-    // Write file headers
-    file.write(reinterpret_cast<char*>(fileHeader), sizeof(fileHeader));
-    file.write(reinterpret_cast<char*>(infoHeader), sizeof(infoHeader));
-
-    // Pixel data
-    std::vector<unsigned char> padding(3, 0); // Max padding size to be added to each row
-    for (int y = bmp_size.height - 1; y >= 0; --y) { // BMP files are stored bottom-to-top
-        for (int x = 0; x < bmp_size.width; ++x) {
-            // Each pixel
-            const auto px = img.get_pixel(x, y);
-            unsigned char pixel[3] = {
-                px[2], // BMP stores pixels in BGR format
-                px[1],
-                px[0]
-            };
-            file.write(reinterpret_cast<char*>(pixel), 3);
+        ctx_gguf = gguf_context_ptr(gguf_init_from_file(fname, params));
+        if (!ctx_gguf.get()) {
+            throw std::runtime_error(string_format("%s: failed to load mmproj model from %s. Does this file exist?\n", __func__, fname));
         }
-        // Write padding for the row
-        file.write(reinterpret_cast<char*>(padding.data()), paddingAmount);
-    }

-    file.close();
-}
-
-// debug function to convert f32 to u8
-static void clip_image_convert_f32_to_u8(const clip_image_f32& src, clip_image_u8& dst) {
-    dst.set_size(src.get_size(), false);
-    const auto & src_buf = src.get_ro_buf();
-    std::vector<uint8_t> dst_buf(src.n_elements());
-    for (size_t i = 0; i < src.n_elements(); ++i) {
-        dst_buf[i] = static_cast<uint8_t>(std::min(std::max(int(src_buf[i] * 255.0f), 0), 255));
-    }
-    dst.cpy_buf(dst_buf);
-}
-#endif
+        ctx_meta.reset(meta);

+        const int n_tensors = gguf_get_n_tensors(ctx_gguf.get());

-// ggml_backend_cpu_set_n_threads(backend_cpu, n_threads);
-static void clip_backend_cpu_set_n_threads(ggml_backend_t backend_cpu, int n_threads) {
-    ggml_backend_dev_t dev = ggml_backend_get_device(backend_cpu);
-    ggml_backend_reg_t reg = dev ? ggml_backend_dev_backend_reg(dev) : nullptr;
-    if (reg) {
-        auto ggml_backend_set_n_threads_fn = (ggml_backend_set_n_threads_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_set_n_threads");
-        if (ggml_backend_set_n_threads_fn) {
-            ggml_backend_set_n_threads_fn(backend_cpu, n_threads);
+        // print gguf info
+        {
+            std::string name;
+            get_string(KEY_NAME, name, false);
+            std::string description;
+            get_string(KEY_DESCRIPTION, description, false);
+            LOG_INF("%s: model name:   %s\n",  __func__, name.c_str());
+            LOG_INF("%s: description:  %s\n",  __func__, description.c_str());
+            LOG_INF("%s: GGUF version: %d\n",  __func__, gguf_get_version(ctx_gguf.get()));
+            LOG_INF("%s: alignment:    %zu\n", __func__, gguf_get_alignment(ctx_gguf.get()));
+            LOG_INF("%s: n_tensors:    %d\n",  __func__, n_tensors);
+            LOG_INF("%s: n_kv:         %d\n",  __func__, (int)gguf_get_n_kv(ctx_gguf.get()));
+            LOG_INF("\n");
         }
-    }
-}
-
-static std::shared_ptr<ggml_threadpool> clip_threadpool_init(int n_threads) {
-    ggml_backend_dev_t dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);
-    ggml_backend_reg_t reg = dev ? ggml_backend_dev_backend_reg(dev) : nullptr;
-    if (!reg || n_threads < 2) {
-        return nullptr;
-    }
-    auto * new_fn  = (decltype(ggml_threadpool_new) *)  ggml_backend_reg_get_proc_address(reg, "ggml_threadpool_new");
-    auto * free_fn = (decltype(ggml_threadpool_free) *) ggml_backend_reg_get_proc_address(reg, "ggml_threadpool_free");
-    if (!new_fn || !free_fn) {
-        return nullptr;
-    }
-    ggml_threadpool_params tpp = ggml_threadpool_params_default(n_threads);
-    tpp.poll = 0; // the pool is idle most of the time, don't take CPU away from the text model
-    ggml_threadpool_t threadpool = new_fn(&tpp);
-    if (!threadpool) {
-        throw std::runtime_error("failed to create threadpool");
-    }
-    return std::shared_ptr<ggml_threadpool>(threadpool, free_fn);
-}
-
-struct clip_ctx {
-    clip_model model;
-
-    gguf_context_ptr ctx_gguf;
-    ggml_context_ptr ctx_data;
-
-    std::vector<uint8_t> buf_compute_meta;
-
-    std::vector<ggml_backend_t> backend_ptrs;
-    std::vector<ggml_backend_buffer_type_t> backend_buft;
-
-    std::shared_ptr<ggml_threadpool> threadpool; // shared by the contexts of a model

-    ggml_backend_t backend = nullptr;
-    ggml_backend_t backend_cpu = nullptr;
-    ggml_backend_buffer_ptr buf;
-
-
-    int max_nodes = 8192;
-    ggml_backend_sched_ptr sched;
-    clip_flash_attn_type flash_attn_type = CLIP_FLASH_ATTN_TYPE_AUTO;
-    bool is_allocated = false;
-
-    bool debug_output_embeddings = false;
-
-    // for measuring memory usage
-    bool no_alloc = false;
-    std::map<ggml_backend_dev_t, size_t> mem_usage;
-    std::map<ggml_backend_dev_t, size_t> mem_compute;
-
-    bool support_batch = false;
+        // modalities
+        {
+            get_bool(KEY_HAS_VISION_ENC,    has_vision,    false);
+            get_bool(KEY_HAS_AUDIO_ENC,     has_audio,     false);
+            get_bool(KEY_HAS_GEN_AUDIO_ENC, has_gen_audio, false);

-    clip_ctx(clip_context_params & ctx_params, std::shared_ptr<ggml_threadpool> threadpool) : threadpool(std::move(threadpool)) {
-        flash_attn_type = ctx_params.flash_attn_type;
-        no_alloc = ctx_params.no_alloc;
-        backend_cpu = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_CPU, nullptr);
-        if (!backend_cpu) {
-            throw std::runtime_error("failed to initialize CPU backend");
-        }
-        if (this->threadpool) {
-            ggml_backend_reg_t reg = ggml_backend_dev_backend_reg(ggml_backend_get_device(backend_cpu));
-            auto * set_threadpool_fn = (decltype(ggml_backend_cpu_set_threadpool) *) ggml_backend_reg_get_proc_address(reg, "ggml_backend_cpu_set_threadpool");
-            if (set_threadpool_fn) {
-                set_threadpool_fn(backend_cpu, this->threadpool.get());
+            if (has_vision) {
+                LOG_INF("%s: has vision encoder\n", __func__);
             }
-        }
-        if (ctx_params.use_gpu) {
-            if (ctx_params.device != nullptr) {
-                backend = ggml_backend_dev_init(ctx_params.device, nullptr);
-                if (!backend) {
-                    throw std::runtime_error(string_format("%s: failed to initialize \"%s\" backend\n",
-                                                           __func__, ggml_backend_dev_name(ctx_params.device)));
-                }
-            } else {
-                backend = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_GPU, nullptr);
-                backend = backend ? backend : ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_IGPU, nullptr);
+            if (has_audio) {
+                LOG_INF("%s: has audio encoder\n", __func__);
+            }
+            if (has_gen_audio) {
+                LOG_INF("%s: has audio generation (gen) encoder\n", __func__);
             }
         }

-        if (backend) {
-            LOG_INF("%s: CLIP using %s backend\n", __func__, ggml_backend_name(backend));
-            backend_ptrs.push_back(backend);
-            backend_buft.push_back(ggml_backend_get_default_buffer_type(backend));
-        } else {
-            backend = backend_cpu;
-            LOG_INF("%s: CLIP using CPU backend\n", __func__);
-        }
-
-        if (ctx_params.image_min_tokens > 0) {
-            model.hparams.custom_image_min_tokens = ctx_params.image_min_tokens;
-        }
-        if (ctx_params.image_max_tokens > 0) {
-            model.hparams.custom_image_max_tokens = ctx_params.image_max_tokens;
-        }
-
-        backend_ptrs.push_back(backend_cpu);
-        backend_buft.push_back(ggml_backend_get_default_buffer_type(backend_cpu));
-
-        sched.reset(
-            ggml_backend_sched_new(backend_ptrs.data(), backend_buft.data(), backend_ptrs.size(), 8192, false, true)
-        );
-
-        if (ctx_params.cb_eval != nullptr) {
-            ggml_backend_sched_set_eval_callback(sched.get(), ctx_params.cb_eval, ctx_params.cb_eval_user_data);
-        }
-
-        debug_output_embeddings = std::getenv("MTMD_DEBUG_EMBEDDINGS") != nullptr;
-    }
-
-    ~clip_ctx() {
-        ggml_backend_free(backend);
-        if (backend != backend_cpu) {
-            ggml_backend_free(backend_cpu);
+        // tensors
+        if (!skip_tensors) {
+            for (int i = 0; i < n_tensors; ++i) {
+                const char * name = gguf_get_tensor_name(ctx_gguf.get(), i);
+                const size_t offset = gguf_get_tensor_offset(ctx_gguf.get(), i);
+                enum ggml_type type = gguf_get_tensor_type(ctx_gguf.get(), i);
+                ggml_tensor * cur = ggml_get_tensor(meta, name);
+                size_t tensor_size = ggml_nbytes(cur);
+                model_size += tensor_size;
+                LOG_DBG("%s: tensor[%d]: n_dims = %d, name = %s, tensor_size=%zu, offset=%zu, shape:[%" PRIu64 ", %" PRIu64 ", %" PRIu64 ", %" PRIu64 "], type = %s\n",
+                    __func__, i, ggml_n_dims(cur), cur->name, tensor_size, offset, cur->ne[0], cur->ne[1], cur->ne[2], cur->ne[3], ggml_type_name(type));
+            }
         }
     }

-    // this function is added so that we don't change too much of the existing code
-    projector_type proj_type() const {
-        return model.proj_type;
-    }
-};
-
-//
-// clip_graph
-//
-
-clip_graph::clip_graph(clip_ctx * ctx, const clip_image_f32 & img) :
-        model(ctx->model),
-        hparams(model.hparams),
-        proj_type(ctx->proj_type()),
-        img(img),
-        patch_size(hparams.patch_size),
-        n_patches_x(img.nx() / patch_size),
-        n_patches_y(img.ny() / patch_size),
-        n_patches(n_patches_x * n_patches_y),
-        n_embd(hparams.n_embd),
-        n_head(hparams.n_head),
-        n_head_kv(hparams.n_head_kv),
-        d_head(hparams.n_embd_head > 0 ? hparams.n_embd_head : (n_head > 0 ? n_embd / n_head : 0)),
-        n_layer(hparams.n_layer),
-        n_mmproj_embd(clip_n_mmproj_embd(ctx)),
-        eps(hparams.eps),
-        kq_scale(d_head > 0 ? 1.0f / sqrtf((float)d_head) : 0.0f),
-        flash_attn_type(ctx->flash_attn_type) {
-    struct ggml_init_params params = {
-        /*.mem_size   =*/ ctx->buf_compute_meta.size(),
-        /*.mem_buffer =*/ ctx->buf_compute_meta.data(),
-        /*.no_alloc   =*/ true,
-    };
-    ctx0_ptr.reset(ggml_init(params));
-    ctx0 = ctx0_ptr.get();
-    gf = ggml_new_graph_custom(ctx0, ctx->max_nodes, false);
-}
-
-clip_graph::clip_graph(const clip_graph & parent) :
-        model(parent.model),
-        hparams(parent.hparams),
-        proj_type(parent.proj_type),
-        img(parent.img),
-        patch_size(parent.patch_size),
-        n_patches_x(parent.n_patches_x),
-        n_patches_y(parent.n_patches_y),
-        n_patches(parent.n_patches),
-        n_embd(parent.n_embd),
-        n_head(parent.n_head),
-        n_head_kv(parent.n_head_kv),
-        d_head(parent.d_head),
-        n_layer(parent.n_layer),
-        n_mmproj_embd(parent.n_mmproj_embd),
-        eps(parent.eps),
-        kq_scale(parent.kq_scale),
-        flash_attn_type(parent.flash_attn_type) {
-    // reuse from parent
-    ctx0 = parent.ctx0;
-    gf   = parent.gf;
-}
-
-ggml_tensor * clip_graph::build_mm(ggml_tensor * w, ggml_tensor * x) const {
-    return ggml_mul_mat(ctx0, w, x);
-}
-
-void clip_graph::cb(ggml_tensor * cur, const char * name, int il) const {
-    if (il >= 0) {
-        ggml_format_name(cur, "%s-%d", name, il);
-    } else {
-        ggml_set_name(cur, name);
-    }
-}
-
-// siglip2 naflex
-ggml_tensor * clip_graph::resize_position_embeddings(uint32_t interpolation_mode) {
-    ggml_tensor * pos_embd = model.position_embeddings;
-    const int height       = img.ny() / patch_size;
-    const int width        = img.nx() / patch_size;
-    const uint32_t mode    = interpolation_mode;
-    const int n_per_side   = (int)std::sqrt(pos_embd->ne[1]);
-
-    GGML_ASSERT(pos_embd);
-
-    if (height == n_per_side && width == n_per_side) {
-        return pos_embd;
-    }
-
-    pos_embd = ggml_reshape_3d(ctx0, pos_embd, n_embd, n_per_side, n_per_side);  // -> (n_embd, n_per_side, n_per_side)
-    pos_embd = ggml_permute(ctx0, pos_embd, 2, 0, 1, 3);                         // -> (n_per_side, n_per_side, n_embd)
-    pos_embd = ggml_interpolate(ctx0, pos_embd, width, height, n_embd, 1, mode); // -> (width, height, n_embd)
-    pos_embd = ggml_permute(ctx0, pos_embd, 1, 2, 0, 3);                         // -> (n_embd, width, height)
-    pos_embd = ggml_cont_2d(ctx0, pos_embd, n_embd, width * height);             // -> (n_embd, width * height)
+    void load_hparams(mtmd_model & model, mtmd_modality modality) {
+        auto & hparams = model.hparams;
+        std::string log_ffn_op; // for logging

-    return pos_embd;
-}
+        // sanity check
+        if (modality == MTMD_MODALITY_VISION) {
+            GGML_ASSERT(has_vision);
+        } else if (modality == MTMD_MODALITY_AUDIO) {
+            GGML_ASSERT(has_audio);
+        } else if (modality == MTMD_MODALITY_GEN_AUDIO) {
+            GGML_ASSERT(has_gen_audio);
+        }
+        model.modality = modality;

-// build vision transformer (ViT) cgraph
-// this function should cover most of the models
-// if your model has specific features, you should probably duplicate this function
-ggml_tensor * clip_graph::build_vit(
-            ggml_tensor * inp,
-            int64_t n_pos,
-            norm_type norm_t,
-            ffn_op_type ffn_t,
-            ggml_tensor * learned_pos_embd,
-            std::function<ggml_tensor *(ggml_tensor *, const clip_layer &)> add_pos,
-            const build_vit_opts & opts
-        ) {
-    // batch dim: inp is [n_embd, n_pos, B]
-    const int64_t B = inp->ne[2];
-
-    if (learned_pos_embd) {
-        inp = ggml_add(ctx0, inp, learned_pos_embd);
-        cb(inp, "pos_embed", -1);
-    }

-    // flatten batch; unflatten again in attention
-    inp = ggml_reshape_2d(ctx0, inp, n_embd, n_pos * B);
+        // projector type
+        std::string proj_type;
+        {
+            // default key
+            get_string(KEY_PROJ_TYPE, proj_type, false);

-    ggml_tensor * inpL = inp;
+            // for models with mixed modalities
+            if (proj_type.empty()) {
+                if (modality == MTMD_MODALITY_VISION) {
+                    get_string(KEY_VISION_PROJ_TYPE, proj_type, false);
+                } else if (modality == MTMD_MODALITY_AUDIO) {
+                    get_string(KEY_AUDIO_PROJ_TYPE, proj_type, false);
+                } else if (modality == MTMD_MODALITY_GEN_AUDIO) {
+                    get_string(KEY_GEN_AUDIO_PROJ_TYPE, proj_type, false);
+                } else {
+                    GGML_ABORT("unknown modality");
+                }
+            }

-    // pre-layernorm
-    if (model.pre_ln_w) {
-        inpL = build_norm(inpL, model.pre_ln_w, model.pre_ln_b, norm_t, eps, -1);
-        cb(inpL, "pre_ln", -1);
-    }
+            model.proj_type = projector_type_from_string(proj_type);

-    // loop over layers
-    for (int il = 0; il < n_layer; il++) {
-        auto & layer = model.layers[il];
-        ggml_tensor * cur = inpL; // inpL = residual, cur = hidden_states
+            if (model.proj_type == PROJECTOR_TYPE_UNKNOWN) {
+                throw std::runtime_error(string_format("%s: unknown projector type: %s\n", __func__, proj_type.c_str()));
+            }

-        ggml_tensor * attn_mask = opts.attn_mask;
-        if (opts.attn_mask_layers.size() > (size_t) il) {
-            attn_mask = opts.attn_mask_layers[il];
+            // correct arch for multimodal models (legacy method)
+            if (model.proj_type == PROJECTOR_TYPE_QWEN25O) {
+                model.proj_type = modality == MTMD_MODALITY_VISION
+                                    ? PROJECTOR_TYPE_QWEN25VL
+                                    : PROJECTOR_TYPE_QWEN2A;
+            }
         }

-        // layernorm1
-        cur = build_norm(cur, layer.ln_1_w, layer.ln_1_b, norm_t, eps, il);
-        cb(cur, "layer_inp_normed", il);
+        const bool is_vision    = model.modality == MTMD_MODALITY_VISION;
+        const bool is_audio     = model.modality == MTMD_MODALITY_AUDIO;
+        const bool is_gen_audio = model.modality == MTMD_MODALITY_GEN_AUDIO;

-        // self-attention
+        // other hparams
         {
-            ggml_tensor * Qcur = nullptr;
-            ggml_tensor * Kcur = nullptr;
-            ggml_tensor * Vcur = nullptr;
-            if (layer.qkv_w != nullptr) {
-                // fused qkv
-                cur = build_mm(layer.qkv_w, cur);
-                if (layer.qkv_b != nullptr) {
-                    cur = ggml_add(ctx0, cur, layer.qkv_b);
-                }
+            const char * prefix = is_vision ? "vision" : (is_audio ? "audio" : "gen.audio");
+            get_u32(string_format(KEY_N_EMBD,         prefix), hparams.n_embd);
+            get_u32(string_format(KEY_N_HEAD,         prefix), hparams.n_head);
+            get_u32(string_format(KEY_N_EMBD_HEAD,    prefix), hparams.n_embd_head, false);
+            get_u32(string_format(KEY_N_FF,           prefix), hparams.n_ff);
+            get_u32(string_format(KEY_N_BLOCK,        prefix), hparams.n_layer);
+            get_u32(string_format(KEY_PROJ_DIM,       prefix), hparams.projection_dim);
+            get_f32(string_format(KEY_LAYER_NORM_EPS, prefix), hparams.eps);

-                // Q/K/V as [d_head, n_head, n_pos, B], the batch stride is cur->nb[1]*n_pos.
-                Qcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
-                /* nb1    */ ggml_row_size(cur->type, d_head),
-                /* nb2    */ cur->nb[1],
-                /* nb3    */ cur->nb[1] * n_pos,
-                /* offset */ 0);
-
-                Kcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
-                /* nb1    */ ggml_row_size(cur->type, d_head),
-                /* nb2    */ cur->nb[1],
-                /* nb3    */ cur->nb[1] * n_pos,
-                /* offset */ ggml_row_size(cur->type, n_head * d_head));
-
-                Vcur = ggml_view_4d(ctx0, cur, d_head, n_head, n_pos, B,
-                /* nb1    */ ggml_row_size(cur->type, d_head),
-                /* nb2    */ cur->nb[1],
-                /* nb3    */ cur->nb[1] * n_pos,
-                /* offset */ ggml_row_size(cur->type, 2 * n_head * d_head));
-
-                if (layer.q_norm) {
-                    GGML_ASSERT(layer.q_norm->ne[0] == Qcur->ne[0]);
-                    Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
-                    cb(Qcur, "Qcur_norm", il);
-                }
+            // n_head_kv is optional (for GQA), default to n_head
+            hparams.n_head_kv = hparams.n_head;
+            get_u32(string_format(KEY_N_HEAD_KV, prefix), hparams.n_head_kv, false);

-                if (layer.k_norm) {
-                    GGML_ASSERT(layer.k_norm->ne[0] == Kcur->ne[0]);
-                    Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
-                    cb(Kcur, "Kcur_norm", il);
+            if (is_vision) {
+                get_u32(KEY_IMAGE_SIZE, hparams.image_size);
+                get_u32(KEY_PATCH_SIZE, hparams.patch_size);
+                get_u32(KEY_MAX_SLICE_NUMS, hparams.max_slice_nums, false);
+                get_i32(KEY_MINICPMV_VERSION, hparams.minicpmv_version, false); // legacy
+                get_u32(KEY_MINICPMV_QUERY_NUM, hparams.minicpmv_query_num, false);
+                if (hparams.minicpmv_query_num == 0) {
+                    // Fallback to hardcoded values for legacy models
+                    if (hparams.minicpmv_version == 3) {
+                        hparams.minicpmv_query_num = 64;
+                    } else if (hparams.minicpmv_version == 4) {
+                        hparams.minicpmv_query_num = 64;
+                    } else if (hparams.minicpmv_version == 5) {
+                        hparams.minicpmv_query_num = 64;
+                    } else if (hparams.minicpmv_version == 6) {
+                        hparams.minicpmv_query_num = 64;
+                    } else if (hparams.minicpmv_version == 100045) {
+                        hparams.minicpmv_query_num = 64;
+                    } else {
+                        hparams.minicpmv_query_num = 96;
+                    }
                 }
+            } else if (is_audio) {
+                get_u32(KEY_A_NUM_MEL_BINS, hparams.n_mel_bins);
+                // some hparams are unused, but still need to set to avoid issues
+                hparams.image_size = 0;
+                hparams.patch_size = 1;
+
+            } else if (is_gen_audio) {
+                // these are unused, but still need to be set to avoid issues
+                hparams.image_size = 0;
+                hparams.patch_size = 1;
+                get_string(KEY_GEN_AUDIO_VARIANT, hparams.gen_model_variant, false);

             } else {
-                // separate q, k, v
-                Qcur = build_mm(layer.q_w, cur);
-                if (layer.q_b) {
-                    Qcur = ggml_add(ctx0, Qcur, layer.q_b);
-                }
+                GGML_ASSERT(false && "unknown modality");
+            }

-                Kcur = build_mm(layer.k_w, cur);
-                if (layer.k_b) {
-                    Kcur = ggml_add(ctx0, Kcur, layer.k_b);
+            // for pinpoints, we need to convert it into a list of resolution candidates
+            {
+                std::vector<int> pinpoints;
+                get_arr_int(KEY_IMAGE_GRID_PINPOINTS, pinpoints, false);
+                if (pinpoints.size() % 2 != 0) {
+                    throw std::runtime_error(string_format("%s: image_grid_pinpoints must have an even number of elements, got %zu\n", __func__, pinpoints.size()));
                 }
-
-                Vcur = build_mm(layer.v_w, cur);
-                if (layer.v_b) {
-                    Vcur = ggml_add(ctx0, Vcur, layer.v_b);
+                if (!pinpoints.empty()) {
+                    for (size_t i = 0; i < pinpoints.size(); i += 2) {
+                        hparams.image_res_candidates.push_back({
+                            pinpoints[i],
+                            pinpoints[i+1],
+                        });
+                    }
                 }
+            }

-                // if true, norm must be applied after reshaping to (d_head, n_head, n_pos)
-                bool norm_per_head = layer.q_norm && layer.q_norm->ne[0] == d_head;
+            // default warmup value
+            hparams.warmup_image_size = hparams.image_size;

-                if (!norm_per_head) {
-                    if (layer.q_norm) {
-                        Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
-                        cb(Qcur, "Qcur_norm", il);
-                    }
-                    if (layer.k_norm) {
-                        Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
-                        cb(Kcur, "Kcur_norm", il);
-                    }
+            {
+                bool use_gelu = false;
+                bool use_silu = false;
+                get_bool(KEY_USE_GELU, use_gelu, false);
+                get_bool(KEY_USE_SILU, use_silu, false);
+                if (use_gelu && use_silu) {
+                    throw std::runtime_error(string_format("%s: both use_gelu and use_silu are set to true\n", __func__));
                 }
+                if (use_gelu) {
+                    hparams.ffn_op = FFN_GELU;
+                    log_ffn_op = "gelu";
+                } else if (use_silu) {
+                    hparams.ffn_op = FFN_SILU;
+                    log_ffn_op = "silu";
+                } else {
+                    hparams.ffn_op = FFN_GELU_QUICK;
+                    log_ffn_op = "gelu_quick";
+                }
+            }

-                Qcur = ggml_reshape_4d(ctx0, Qcur, d_head, n_head,    n_pos, B);
-                Kcur = ggml_reshape_4d(ctx0, Kcur, d_head, n_head_kv, n_pos, B);
-                Vcur = ggml_reshape_4d(ctx0, Vcur, d_head, n_head_kv, n_pos, B);
-
-                if (norm_per_head) {
-                    if (layer.q_norm) {
-                        Qcur = build_norm(Qcur, layer.q_norm, layer.q_norm_b, norm_t, eps, il);
-                        cb(Qcur, "Qcur_norm_per_head", il);
-                    }
-                    if (layer.k_norm) {
-                        Kcur = build_norm(Kcur, layer.k_norm, layer.k_norm_b, norm_t, eps, il);
-                        cb(Kcur, "Kcur_norm_per_head", il);
-                    }
-                }
-            }
-
-            cb(Qcur, "Qcur", il);
-            cb(Kcur, "Kcur", il);
-            cb(Vcur, "Vcur", il);
-
-            if (add_pos) {
-                Qcur = add_pos(Qcur, layer);
-                Kcur = add_pos(Kcur, layer);
-                cb(Qcur, "Qcur_pos", il);
-                cb(Kcur, "Kcur_pos", il);
-            }
-
-            if (proj_type == PROJECTOR_TYPE_GEMMA4V) {
-                Vcur = ggml_rms_norm(ctx0, Vcur, eps);
-                cb(Vcur, "Vcur_normed", il);
-            }
-
-            // build_attn returns a flat 2D [n_embd, n_pos*B]
-            cur = build_attn(layer.o_w, layer.o_b,
-                Qcur, Kcur, Vcur, attn_mask, kq_scale, il);
-            cb(cur, "attn_out", il);
-        }
-
-        if (layer.ls_1_w) {
-            cur = ggml_mul(ctx0, cur, layer.ls_1_w);
-            cb(cur, "attn_out_scaled", il);
-        }
-
-        if (layer.attn_post_norm_w) {
-            cur = build_norm(cur, layer.attn_post_norm_w, nullptr, norm_t, eps, il);
-            cb(cur, "attn_post_normed", il);
-        }
-
-        // re-add the layer input, e.g., residual
-        cur = ggml_add(ctx0, cur, inpL);
-
-        inpL = cur; // inpL = residual, cur = hidden_states
-
-        if (opts.callback_layer_out) {
-            opts.callback_layer_out(cur, il);
-        }
-
-        cb(cur, "ffn_inp", il);
-
-        // layernorm2 (pre-ffn norm)
-        cur = build_norm(cur, layer.ln_2_w, layer.ln_2_b, norm_t, eps, il);
-        cb(cur, "ffn_inp_normed", il);
-
-        // ffn
-        cur = layer.ff_gate_exps_w
-            ? build_moe_ffn(cur, layer, ffn_t, il)
-            : build_ffn(cur,
-                layer.ff_up_w, layer.ff_up_b,
-                layer.ff_gate_w, layer.ff_gate_b,
-                layer.ff_down_w, layer.ff_down_b,
-                ffn_t, il);
-
-        cb(cur, "ffn_out", il);
-
-        if (layer.ff_post_norm_w) {
-            cur = build_norm(cur, layer.ff_post_norm_w, nullptr, norm_t, eps, il);
-            cb(cur, "ffn_post_normed", il);
-        }
-
-        if (layer.ls_2_w) {
-            cur = ggml_mul(ctx0, cur, layer.ls_2_w);
-            cb(cur, "ffn_out_scaled", il);
-        }
-
-        // residual 2
-        cur = ggml_add(ctx0, inpL, cur);
-        cb(cur, "layer_out", il);
-
-        if (layer.ls_out_w) {
-            cur = ggml_mul(ctx0, cur, layer.ls_out_w);
-            cb(cur, "layer_out_scaled", il);
-        }
-
-        inpL = cur;
-    }
-
-    if (model.audio_has_avgpool()) {
-        ggml_tensor * cur = inpL;
-        cur = ggml_transpose(ctx0, cur);
-        cur = ggml_cont(ctx0, cur);
-        cur = ggml_pool_1d(ctx0, cur, GGML_OP_POOL_AVG, 2, 2, 0);
-        cur = ggml_transpose(ctx0, cur);
-        cur = ggml_cont(ctx0, cur);
-        inpL = cur;
-    }
-
-    // post-layernorm
-    if (model.post_ln_w && !opts.skip_post_ln) {
-        inpL = build_norm(inpL, model.post_ln_w, model.post_ln_b, norm_t, eps, -1);
-    }
-
-    // restore the batch dim
-    GGML_ASSERT(inpL->ne[1] % B == 0);
-    inpL = ggml_reshape_3d(ctx0, inpL, n_embd, inpL->ne[1] / B, B);
-    return inpL;
-}
-
-// build the input after conv2d (inp_raw --> patches)
-// returns tensor with shape [n_embd, n_patches]
-ggml_tensor * clip_graph::build_inp() {
-    ggml_tensor * inp_raw = build_inp_raw();
-    ggml_tensor * inp = ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_raw, patch_size, patch_size, 0, 0, 1, 1);
-    inp = ggml_reshape_3d(ctx0, inp, n_patches, n_embd, n_batch);
-    inp = ggml_cont(ctx0, ggml_transpose(ctx0, inp));
-    if (model.patch_bias) {
-        inp = ggml_add(ctx0, inp, model.patch_bias);
-        cb(inp, "patch_bias", -1);
-    }
-    return inp;
-}
-
-ggml_tensor * clip_graph::build_inp_raw(int channels) {
-    ggml_tensor * inp_raw = ggml_new_tensor_4d(ctx0, GGML_TYPE_F32, img.nx(), img.ny(), channels, n_batch);
-    ggml_set_name(inp_raw, "inp_raw");
-    ggml_set_input(inp_raw);
-    return inp_raw;
-}
-
-// qwen-vl style temporal merge: Conv3D(kt=2) split into 2 Conv2D
-ggml_tensor * clip_graph::build_inp_temporal_merge() {
-    ggml_tensor * inp_raw = build_inp_raw();
-
-    GGML_ASSERT(img.nx() % (patch_size * 2) == 0);
-    GGML_ASSERT(img.ny() % (patch_size * 2) == 0);
-
-    const size_t nb1 = ggml_row_size(inp_raw->type, img.nx());
-    const size_t nb2 = ggml_row_size(inp_raw->type, img.nx() * img.ny());
-
-    if (n_batch == 1) {
-        // still image input
-        return ggml_add(ctx0,
-            ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_raw, patch_size, patch_size, 0, 0, 1, 1),
-            ggml_conv_2d(ctx0, model.patch_embeddings_1, inp_raw, patch_size, patch_size, 0, 0, 1, 1));
-    } else if (n_batch == 2) {
-        // 2 frames input (video input)
-        ggml_tensor * inp_0 = ggml_view_3d(ctx0, inp_raw,
-                                    img.nx(), img.ny(), 3, nb1, nb2, 0);
-        ggml_tensor * inp_1 = ggml_view_3d(ctx0, inp_raw,
-                                    img.nx(), img.ny(), 3, nb1, nb2,
-                                    nb2 * 3); // move to the second frame
-        return ggml_add(ctx0,
-            ggml_conv_2d(ctx0, model.patch_embeddings_0, inp_0, patch_size, patch_size, 0, 0, 1, 1),
-            ggml_conv_2d(ctx0, model.patch_embeddings_1, inp_1, patch_size, patch_size, 0, 0, 1, 1));
-    } else {
-        GGML_ASSERT(false && "n_batch > 2 is not supported");
-    }
-}
-
-static std::string get_attn_mask_name(int idx) {
-    return idx == 0 ? "attn_mask" : "attn_mask_" + std::to_string(idx);
-}
-
-ggml_tensor * clip_graph::build_inp_attn_mask(int64_t n_kv, int64_t n_q, int idx) {
-    const ggml_type type = flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED ? GGML_TYPE_F16 : GGML_TYPE_F32;
-    ggml_tensor * mask = ggml_new_tensor_2d(ctx0, type, n_kv, n_q);
-    ggml_set_name(mask, get_attn_mask_name(idx).c_str());
-    ggml_set_input(mask);
-    return mask;
-}
-
-ggml_tensor * clip_graph::build_norm(
-        ggml_tensor * cur,
-        ggml_tensor * mw,
-        ggml_tensor * mb,
-        norm_type type,
-        float norm_eps,
-        int il) const {
-
-    cur = type == NORM_TYPE_RMS
-        ? ggml_rms_norm(ctx0, cur, norm_eps)
-        : ggml_norm(ctx0, cur, norm_eps);
-
-    if (mw) {
-        cur = ggml_mul(ctx0, cur, mw);
-        cb(cur, "norm_w", il);
-    }
-
-    if (mb) {
-        cur = ggml_add(ctx0, cur, mb);
-        cb(cur, "norm_b", il);
-    }
-
-    return cur;
-}
-
-ggml_tensor * clip_graph::build_ffn(
-        ggml_tensor * cur,
-        ggml_tensor * up,
-        ggml_tensor * up_b,
-        ggml_tensor * gate,
-        ggml_tensor * gate_b,
-        ggml_tensor * down,
-        ggml_tensor * down_b,
-        ffn_op_type type_op,
-        int il) const {
-
-    ggml_tensor * tmp = up ? build_mm(up, cur) : cur;
-    cb(tmp, "ffn_up", il);
-
-    if (up_b) {
-        tmp = ggml_add(ctx0, tmp, up_b);
-        cb(tmp, "ffn_up_b", il);
-    }
-
-    if (gate) {
-        cur = build_mm(gate, cur);
-        cb(cur, "ffn_gate", il);
-
-        if (gate_b) {
-            cur = ggml_add(ctx0, cur, gate_b);
-            cb(cur, "ffn_gate_b", il);
-        }
-    } else {
-        cur = tmp;
-    }
-
-    // we only support parallel ffn for now
-    switch (type_op) {
-        case FFN_SILU:
-            if (gate) {
-                cur = hparams.swiglu_clamp > 0.0f
-                    ? ggml_swiglu_clamp(ctx0, cur, tmp, hparams.swiglu_clamp)
-                    : ggml_swiglu_split(ctx0, cur, tmp);
-                cb(cur, "ffn_swiglu", il);
-            } else {
-                cur = ggml_silu(ctx0, cur);
-                cb(cur, "ffn_silu", il);
-            } break;
-        case FFN_GELU:
-            if (gate) {
-                cur = ggml_geglu_split(ctx0, cur, tmp);
-                cb(cur, "ffn_geglu", il);
-            } else {
-                cur = ggml_gelu(ctx0, cur);
-                cb(cur, "ffn_gelu", il);
-            } break;
-        case FFN_GELU_ERF:
-            if (gate) {
-                cur = ggml_geglu_erf_split(ctx0, cur, tmp);
-                cb(cur, "ffn_geglu_erf", il);
-            } else {
-                cur = ggml_gelu_erf(ctx0, cur);
-                cb(cur, "ffn_gelu_erf", il);
-            } break;
-        case FFN_GELU_QUICK:
-            if (gate) {
-                cur = ggml_geglu_quick_split(ctx0, cur, tmp);
-                cb(cur, "ffn_geglu_quick", il);
-            } else {
-                cur = ggml_gelu_quick(ctx0, cur);
-                cb(cur, "ffn_gelu_quick", il);
-            } break;
-        case FFN_RELU_SQR:
-            {
-                cur = ggml_relu(ctx0, cur);
-                cur = ggml_sqr(ctx0, cur);
-                cb(cur, "ffn_relu_sqr", il);
-            } break;
-    }
-
-    if (down) {
-        cur = build_mm(down, cur);
-    }
-
-    if (down_b) {
-        cb(cur, "ffn_down", il);
-    }
-
-    if (down_b) {
-        cur = ggml_add(ctx0, cur, down_b);
-    }
-
-    return cur;
-}
-
-// MoE FFN with sigmoid router and normalized top-k weights (dots3note vision)
-// the router runs in fp32; exp_probs_b only affects expert selection, not the weights
-ggml_tensor * clip_graph::build_moe_ffn(ggml_tensor * cur, const clip_layer & layer, ffn_op_type type_op, int il) const {
-    const int64_t n_tokens      = cur->ne[1];
-    const int64_t n_expert      = layer.ff_gate_exps_w->ne[2];
-    const int64_t n_expert_used = std::min((int64_t) hparams.n_expert_used, n_expert);
-    GGML_ASSERT(n_expert_used > 0);
-    GGML_ASSERT(type_op == FFN_SILU);
-
-    ggml_tensor * probs = ggml_sigmoid(ctx0, build_mm(layer.ff_gate_inp_w, cur)); // [n_expert, n_tokens]
-    cb(probs, "ffn_moe_probs", il);
-
-    ggml_tensor * sel = layer.ff_exp_probs_b
-        ? ggml_add(ctx0, probs, layer.ff_exp_probs_b)
-        : probs;
-    ggml_tensor * selected = ggml_top_k(ctx0, sel, n_expert_used); // [n_expert_used, n_tokens]
-
-    ggml_tensor * weights = ggml_get_rows(ctx0,
-        ggml_reshape_3d(ctx0, probs, 1, n_expert, n_tokens), selected);
-    weights = ggml_reshape_2d(ctx0, weights, n_expert_used, n_tokens);
-    weights = ggml_div(ctx0, weights, ggml_sum_rows(ctx0, weights));
-    weights = ggml_reshape_3d(ctx0, weights, 1, n_expert_used, n_tokens);
-    cb(weights, "ffn_moe_weights", il);
-
-    cur = ggml_reshape_3d(ctx0, cur, cur->ne[0], 1, n_tokens);
-    ggml_tensor * gate = ggml_mul_mat_id(ctx0, layer.ff_gate_exps_w, cur, selected); // [n_ff, n_expert_used, n_tokens]
-    ggml_tensor * up   = ggml_mul_mat_id(ctx0, layer.ff_up_exps_w,   cur, selected);
-    cur = ggml_mul(ctx0, ggml_silu(ctx0, gate), up);
-    cur = ggml_mul_mat_id(ctx0, layer.ff_down_exps_w, cur, selected); // [n_embd, n_expert_used, n_tokens]
-    cur = ggml_mul(ctx0, cur, weights);
-
-    // sum over the selected experts
-    ggml_tensor * out = nullptr;
-    for (int64_t i = 0; i < n_expert_used; i++) {
-        ggml_tensor * v = ggml_view_2d(ctx0, cur, cur->ne[0], n_tokens, cur->nb[2], i * cur->nb[1]);
-        out = out ? ggml_add(ctx0, out, v) : v;
-    }
-    if (n_expert_used == 1) {
-        out = ggml_cont(ctx0, out);
-    }
-    cb(out, "ffn_moe_out", il);
-    return out;
-}
-
-ggml_tensor * clip_graph::build_attn(
-        ggml_tensor * wo,
-        ggml_tensor * wo_b,
-        ggml_tensor * q_cur,
-        ggml_tensor * k_cur,
-        ggml_tensor * v_cur,
-        ggml_tensor * kq_mask,
-        float kq_scale,
-        int il,
-        ggml_tensor * sinks) const {
-    // these nodes are added to the graph together so that they are not reordered
-    // by doing so, the number of splits in the graph is reduced
-    // the order is fixed without the compute flag, so an unselected branch stays out of the compute set
-    ggml_build_forward_order(gf, q_cur);
-    ggml_build_forward_order(gf, k_cur);
-    ggml_build_forward_order(gf, v_cur);
-
-    ggml_tensor * q = ggml_permute(ctx0, q_cur, 0, 2, 1, 3);
-    //cb(q, "q", il);
-
-    ggml_tensor * k = ggml_permute(ctx0, k_cur, 0, 2, 1, 3);
-    //cb(k, "k", il);
-
-    ggml_tensor * cur;
-
-    if (flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
-        ggml_tensor * v = ggml_permute(ctx0, v_cur, 0, 2, 1, 3);
-
-        k = ggml_cast(ctx0, k, GGML_TYPE_F16);
-        v = ggml_cast(ctx0, v, GGML_TYPE_F16);
-        // mask must be f16 here, use build_inp_attn_mask()
-        GGML_ASSERT(!kq_mask || kq_mask->type == GGML_TYPE_F16);
-
-        cur = ggml_flash_attn_ext(ctx0, q, k, v, kq_mask, kq_scale, 0.0f, 0.0f);
-        ggml_prec_set_acc(cur, GGML_PREC_F32);
-        if (sinks != nullptr) {
-            ggml_flash_attn_ext_add_sinks(cur, sinks);
-        }
-
-        cur = ggml_reshape_2d(ctx0, cur, cur->ne[0]*cur->ne[1], cur->ne[2]*cur->ne[3]);
-
-    } else {
-        ggml_tensor * v = ggml_permute(ctx0, v_cur, 1, 2, 0, 3);
-        v = ggml_cont(ctx0, v);
-
-        ggml_tensor * kq = ggml_mul_mat(ctx0, k, q);
-        // TODO: this most likely requires GGML_PREC_BF16, but will enable it later to avoid scope-creep
-        // ggml_prec_set_acc(kq, GGML_PREC_BF16);
-
-        kq = ggml_soft_max_ext(ctx0, kq, kq_mask, kq_scale, 0.0f);
-        if (sinks != nullptr) {
-            ggml_soft_max_add_sinks(kq, sinks);
-        }
-
-        ggml_tensor * kqv = ggml_mul_mat(ctx0, v, kq);
-        cur = ggml_permute(ctx0, kqv, 0, 2, 1, 3);
-        cur = ggml_cont_2d(ctx0, cur, cur->ne[0] * cur->ne[1], cur->ne[2] * cur->ne[3]);
-    }
-
-    cb(cur, "kqv_out", il);
-
-    if (wo) {
-        cur = build_mm(wo, cur);
-    }
-
-    if (wo_b) {
-        cur = ggml_add(ctx0, cur, wo_b);
-    }
-
-    return cur;
-}
-
-// implementation of the 2D RoPE without adding a new op in ggml
-ggml_tensor * clip_graph::build_rope_2d(
-    ggml_context * ctx0,
-    ggml_tensor * cur,
-    ggml_tensor * pos_a, // first half
-    ggml_tensor * pos_b, // second half
-    const float freq_base,
-    const bool interleave_freq
-) {
-    const int64_t n_dim = cur->ne[0];
-
-    // for example, if we have cur tensor of shape (n_dim=8, n_head, n_pos)
-    // we will have a list of 4 inv_freq: 1e-0, 1e-1, 1e-2, 1e-3
-    // first half of cur will use 1e-0, 1e-2 (even)
-    // second half of cur will use 1e-1, 1e-3 (odd)
-    // the trick here is to rotate just half of n_dim, so inv_freq will automatically be even
-    //  ^ don't ask me why, it's math! -2(2i) / n_dim == -2i / (n_dim/2)
-    // then for the second half, we use freq_scale to shift the inv_freq
-    //  ^ why? replace (2i) with (2i+1) in the above equation
-    const float freq_scale_odd = interleave_freq
-                                ? std::pow(freq_base, (float)-2/n_dim)
-                                : 1.0;
-
-    // first half, dims [0, n_dim/2)
-    cur = ggml_rope_ext(
-        ctx0,
-        cur,
-        pos_a,      // positions
-        nullptr,    // freq factors
-        n_dim/2,    // n_dims
-        0, 0, freq_base,
-        1.0f, 0.0f, 1.0f, 0.0f, 0.0f
-    );
-
-    // second half, dims [n_dim/2, n_dim)
-    cur = ggml_rope_ext(
-        ctx0,
-        cur,
-        pos_b,      // positions
-        nullptr,    // freq factors
-        n_dim/2,    // n_dims
-        0, 0, freq_base,
-        freq_scale_odd,
-        0.0f, 1.0f, 0.0f, 0.0f
-    );
-    cur = ggml_rope_set_offset(cur, n_dim/2);
-
-    return cur;
-}
-
-// Generic function to stack frames for audio processing
-// Abstracts out the StackAudioFrames logic used by ultravox
-ggml_tensor * clip_graph::build_stack(ggml_tensor * cur, int32_t stack_factor, int32_t n_embed) {
-    if (stack_factor <= 1) {
-        return cur;
-    }
-
-    int64_t total_elements = ggml_nelements(cur);
-    int64_t stride = n_embed * stack_factor;
-
-    // Calculate padded length
-    int64_t padded_len = GGML_PAD(total_elements, stride);
-    int64_t pad = padded_len - total_elements;
-
-    if (pad > 0) {
-        // Pad the tensor to make it divisible by stride
-        cur = ggml_view_1d(ctx0, cur, total_elements, 0);
-        cur = ggml_pad(ctx0, cur, pad, 0, 0, 0);
-    }
-
-    // Reshape to [stride, padded_len / stride]
-    cur = ggml_view_2d(ctx0, cur, stride, padded_len / stride,
-                        ggml_row_size(cur->type, stride), 0);
-    return cur;
-}
-
-// aka pixel_shuffle / pixel_unshuffle / patch_merger (Kimi-VL)
-// support dynamic resolution
-ggml_tensor * clip_graph::build_suffix(ggml_tensor * cur) {
-    for (int idx : clip_suffix_rows(img.suffix_type)) {
-        GGML_ASSERT(model.tok_embd_sep && idx < model.tok_embd_sep->ne[1]);
-        ggml_tensor * row = ggml_view_2d(ctx0, model.tok_embd_sep, model.tok_embd_sep->ne[0], 1,
-                                         model.tok_embd_sep->nb[1], idx * model.tok_embd_sep->nb[1]);
-        cur = ggml_concat(ctx0, cur, ggml_cast(ctx0, row, cur->type), 1);
-    }
-    return cur;
-}
-
-ggml_tensor * clip_graph::build_patch_merge_permute(ggml_tensor * cur, int scale_factor) {
-    GGML_ASSERT(scale_factor > 1);
-
-    const int n_embd = cur->ne[0];
-    int width  = img.nx() / patch_size;
-    int height = img.ny() / patch_size;
-
-    // pad width and height to factor
-    const int64_t pad_width  = CLIP_ALIGN(width,  scale_factor) - width;
-    const int64_t pad_height = CLIP_ALIGN(height, scale_factor) - height;
-    cur = ggml_reshape_3d(ctx0, cur, n_embd, width, height);
-    if (pad_width || pad_height) {
-        cur     = ggml_pad(ctx0, cur, 0, pad_width, pad_height, 0);
-        width  += pad_width;
-        height += pad_height;
-    }
-
-    // unshuffle h
-    cur = ggml_reshape_3d(ctx0, cur, n_embd * scale_factor, width / scale_factor, height);
-    cur = ggml_permute(ctx0, cur, 0, 2, 1, 3);
-
-    // unshuffle w
-    cur = ggml_cont_3d(ctx0, cur, n_embd * scale_factor * scale_factor, height / scale_factor, width / scale_factor);
-    cur = ggml_permute(ctx0, cur, 0, 2, 1, 3);
-
-    cur = ggml_cont_2d(ctx0, cur, cur->ne[0], cur->ne[1] * cur->ne[2]);
-    cb(cur, "pixel_shuffle", -1);
-
-    return cur;
-}
-
-static std::unique_ptr<clip_graph> clip_get_graph_builder(clip_ctx * ctx, const clip_image_f32_batch & imgs,
-                                                            const clip_encode_params * params = nullptr) {
-    const clip_image_f32 & img = imgs.entries[0];
-    std::unique_ptr<clip_graph> builder;
-
-    switch (ctx->proj_type()) {
-        case PROJECTOR_TYPE_GEMMA3:
-        case PROJECTOR_TYPE_IDEFICS3:
-        case PROJECTOR_TYPE_COHERE2V:
-        case PROJECTOR_TYPE_LFM2:
-        case PROJECTOR_TYPE_D1OMNI_V:
-        case PROJECTOR_TYPE_JANUS_PRO:
-        case PROJECTOR_TYPE_PHI4:
-            {
-                builder = std::make_unique<clip_graph_siglip>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GEMMA3NV:
-            {
-                builder = std::make_unique<clip_graph_mobilenetv5>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GEMMA4V:
-            {
-                builder = std::make_unique<clip_graph_gemma4v>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GEMMA4UV:
-            {
-                builder = std::make_unique<clip_graph_gemma4uv>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_PIXTRAL:
-        case PROJECTOR_TYPE_LIGHTONOCR:
-            {
-                builder = std::make_unique<clip_graph_pixtral>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_DOTS_OCR:
-        case PROJECTOR_TYPE_DOTS3NOTE_V: // same ViT + merger; pyramid MoE is handled by build_vit
-            {
-                builder = std::make_unique<clip_graph_dotsocr>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_DOTS3NOTE_A:
-            {
-                builder = std::make_unique<clip_graph_dots3note_a>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_QWEN2VL:
-        case PROJECTOR_TYPE_QWEN25VL:
-            {
-                builder = std::make_unique<clip_graph_qwen2vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_QWEN3VL:
-            {
-                builder = std::make_unique<clip_graph_qwen3vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MAGEVL:
-            {
-                builder = std::make_unique<clip_graph_magevl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_SENSENOVA_U1:
-            {
-                builder = std::make_unique<clip_graph_sensenova_u1>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_LFM3VL:
-            {
-                builder = std::make_unique<clip_graph_lfm3vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_LING3VL:
-            {
-                builder = std::make_unique<clip_graph_ling3vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_EXAONE4_5:
-            {
-                builder = std::make_unique<clip_graph_exaone4_5>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MIMOVL:
-            {
-                builder = std::make_unique<clip_graph_mimovl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MINIMAX_M3:
-            {
-                builder = std::make_unique<clip_graph_minimax_m3>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MUSE_GLIMMER:
-            {
-                builder = std::make_unique<clip_graph_muse_glimmer>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_STEP3VL:
-            {
-                builder = std::make_unique<clip_graph_step3vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MINICPMV:
-            {
-                builder = std::make_unique<clip_graph_minicpmv>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MINICPMV4_6:
-        case PROJECTOR_TYPE_MINICPMV4_7:
-        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
-            {
-                builder = std::make_unique<clip_graph_minicpmv4_6>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_INTERNVL:
-            {
-                builder = std::make_unique<clip_graph_internvl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
-            {
-                builder = std::make_unique<clip_graph_nemotron_v2_vl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_NEMOTRON_OMNI:
-            {
-                builder = std::make_unique<clip_graph_nemotron_omni>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_LLAMA4:
-            {
-                builder = std::make_unique<clip_graph_llama4>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_VOXTRAL_RT:
-            {
-                builder = std::make_unique<clip_graph_voxtral_rt>(ctx, img, params ? params->enc_state : nullptr);
-            } break;
-        case PROJECTOR_TYPE_ULTRAVOX:
-        case PROJECTOR_TYPE_VOXTRAL:
-        case PROJECTOR_TYPE_QWEN2A:
-        case PROJECTOR_TYPE_GLMA:
-        case PROJECTOR_TYPE_MERALION:
-        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
-            {
-                builder = std::make_unique<clip_graph_whisper_enc>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
-            {
-                builder = std::make_unique<clip_graph_muse_spark_audio>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_KIMIVL:
-            {
-                builder = std::make_unique<clip_graph_kimivl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_PADDLEOCR:
-            {
-                builder = std::make_unique<clip_graph_paddleocr>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_KIMIK25:
-            {
-                builder = std::make_unique<clip_graph_kimik25>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEK4V:
-        case PROJECTOR_TYPE_DEEPSEEK41V:
-            {
-                builder = std::make_unique<clip_graph_deepseek4v>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_COGVLM:
-            {
-                builder = std::make_unique<clip_graph_cogvlm>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_HUNYUANVL:
-            {
-                builder = std::make_unique<clip_graph_hunyuanvl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MLP:
-        case PROJECTOR_TYPE_MLP_NORM:
-        case PROJECTOR_TYPE_LDP:
-        case PROJECTOR_TYPE_LDPV2:
-        case PROJECTOR_TYPE_GLM_EDGE:
-            {
-                builder = std::make_unique<clip_graph_llava>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEKOCR:
-            {
-                builder = std::make_unique<clip_graph_deepseekocr>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEKOCR2:
-             {
-                builder = std::make_unique<clip_graph_deepseekocr2>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_LFM2A:
-        case PROJECTOR_TYPE_D1OMNI_A:
-            {
-                builder = std::make_unique<clip_graph_conformer>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_LFM3A:
-            {
-                builder = std::make_unique<clip_graph_lfm3a>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GEMMA4A:
-            {
-                builder = std::make_unique<clip_graph_gemma4a>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GEMMA4UA:
-            {
-                builder = std::make_unique<clip_graph_gemma4ua>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GRANITE_SPEECH:
-            {
-                builder = std::make_unique<clip_graph_granite_speech>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_GLM4V:
-        case PROJECTOR_TYPE_GLM5V:
-            {
-                builder = std::make_unique<clip_graph_glm4v>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_QWEN3A:
-            {
-                builder = std::make_unique<clip_graph_qwen3a>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_MIMO_AUDIO:
-            {
-                builder = std::make_unique<clip_graph_mimo_audio>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
-            {
-                builder = std::make_unique<clip_graph_qwen3tts_spkenc>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
-            {
-                builder = std::make_unique<clip_graph_pockettts_spkenc>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_POCKETTTS_GEN:
-            {
-                const auto gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
-                const int  n_step = ctx->model.hparams.flow_n_step;
-                const int64_t n_latent = ctx->model.gen_input_lin_w->ne[0];
-                GGML_ASSERT(n_step > 0);
-                GGML_ASSERT(n_latent > 0);
-                // "inp_feats" takes the caller's buffer as-is, the graph must consume all of it
-                if (params && params->feats) {
-                    GGML_ASSERT(params->feats->size() % (size_t) n_latent == 0);
-                    GGML_ASSERT(params->feats->size() >= (size_t) n_latent);
-                }
-                const int  n_frames = params && params->feats ? (int) (params->feats->size() / n_latent) : 1;
-                builder = std::make_unique<clip_graph_pockettts_gen>(ctx, img, gen_process, n_step, n_frames);
-            } break;
-        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
-            {
-                builder = std::make_unique<clip_graph_chatterbox_spkenc>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_CHATTERBOX:
-            {
-                const auto gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
-                int n_tokens = params && params->codes ? (int) params->codes->size() : 0;
-                int n_prompt_mel = 0;
-                if (n_tokens > 0) {
-                    GGML_ASSERT(params->ref);
-                    n_tokens += (int) params->ref->tokens.size();
-                    n_prompt_mel = (int) (params->ref->feat.size() / 80);
-                }
-                const int vnm = params ? params->vocode_n_mel  : 0;
-                const int vns = params ? params->vocode_n_stft : 0;
-                builder = std::make_unique<clip_graph_chatterbox>(ctx, img, gen_process, n_tokens, n_prompt_mel, vnm, vns);
-            } break;
-        case PROJECTOR_TYPE_LFM3A_GEN:
-            {
-                const auto    gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
-                const int64_t n_cb        = ctx->model.gen_code_embd_w->ne[2];
-                bool all_known = false;
-                int  n_frames  = 1;
-                if (params && params->codes) {
-                    GGML_ASSERT(params->codes->size() % (size_t) n_cb == 0);
-                    if (gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-                        n_frames = (int) (params->codes->size() / n_cb);
-                        GGML_ASSERT(n_frames > 0);
-                    } else {
-                        // -1 marks a masked codebook, none masked means only the feedback embedding is needed
-                        all_known = std::all_of(params->codes->begin(), params->codes->end(), [](int32_t c) { return c >= 0; });
-                    }
-                }
-                builder = std::make_unique<clip_graph_lfm3a_gen>(ctx, img, gen_process, all_known, n_frames);
-            } break;
-        case PROJECTOR_TYPE_DRAMA3_GEN:
-            {
-                const auto gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
-                const int64_t n_cb = ctx->model.gen_code_embd_w->ne[2];
-                const int n_frames = params && params->codes ? (int) (params->codes->size() / n_cb) : 1;
-                builder = std::make_unique<clip_graph_drama3_gen>(ctx, img, gen_process, params ? params->gen_state : nullptr, n_frames,
-                                                                  params ? params->top_k : 30, params ? params->top_p : 0.9f,
-                                                                  params ? params->temp : 1.0f);
-            } break;
-        case PROJECTOR_TYPE_QWEN3TTS_GEN:
-            {
-                const auto  gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
-                const int   top_k = params ? params->top_k : 50;
-                const float top_p = params ? params->top_p : 1.0f;
-                builder = std::make_unique<clip_graph_qwen3tts_gen>(ctx, img, gen_process, top_k, top_p);
-            } break;
-        case PROJECTOR_TYPE_SOPRANO:
-            {
-                const int n_frames = params && params->feats ? (int) (params->feats->size() / ctx->model.hparams.projection_dim) : 2;
-                builder = std::make_unique<clip_graph_soprano>(ctx, img, n_frames);
-            } break;
-        case PROJECTOR_TYPE_YOUTUVL:
-            {
-                builder = std::make_unique<clip_graph_youtuvl>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_YASA2:
-            {
-                builder = std::make_unique<clip_graph_yasa2>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_PARAKEET:
-            {
-                builder = std::make_unique<clip_graph_parakeet>(ctx, img);
-            } break;
-        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
-            {
-                builder = std::make_unique<clip_graph_nemotron_transcribe>(ctx, img, params ? params->enc_state : nullptr);
-            } break;
-        case PROJECTOR_TYPE_GRANITE4_VISION:
-            {
-                builder = std::make_unique<clip_graph_granite4_vision>(ctx, img);
-            } break;
-        default:
-            GGML_ABORT("missing cgraph builder");
-    }
-
-    builder->img_batch = &imgs;
-
-    // TODO [QWEN_VIDEO]: improve this in the future
-    builder->n_batch = imgs.entries.size();
-
-    return builder;
-}
-
-//
-// clip_model_loader
-//
-
-struct clip_model_loader {
-    ggml_context_ptr ctx_meta;
-    gguf_context_ptr ctx_gguf;
-
-    std::string fname;
-
-    size_t model_size = 0; // in bytes
-
-    bool has_vision    = false;
-    bool has_audio     = false;
-    bool has_gen_audio = false;
-
-    mtmd_progress_callback progress_callback = nullptr;
-    void * progress_callback_user_data = nullptr;
-
-    // TODO @ngxson : we should not pass clip_ctx here, it should be clip_model
-    clip_model_loader(const char * fname,
-            bool skip_tensors = false,
-            mtmd_progress_callback progress_cb = nullptr,
-            void * progress_user_data = nullptr)
-        : fname(fname),
-          progress_callback(progress_cb),
-          progress_callback_user_data(progress_user_data) {
-        struct ggml_context * meta = nullptr;
-
-        struct gguf_init_params params = {
-            /*.no_alloc = */ true,
-            /*.ctx      = */ &meta,
-        };
-
-        ctx_gguf = gguf_context_ptr(gguf_init_from_file(fname, params));
-        if (!ctx_gguf.get()) {
-            throw std::runtime_error(string_format("%s: failed to load CLIP model from %s. Does this file exist?\n", __func__, fname));
-        }
-
-        ctx_meta.reset(meta);
-
-        const int n_tensors = gguf_get_n_tensors(ctx_gguf.get());
-
-        // print gguf info
-        {
-            std::string name;
-            get_string(KEY_NAME, name, false);
-            std::string description;
-            get_string(KEY_DESCRIPTION, description, false);
-            LOG_INF("%s: model name:   %s\n",  __func__, name.c_str());
-            LOG_INF("%s: description:  %s\n",  __func__, description.c_str());
-            LOG_INF("%s: GGUF version: %d\n",  __func__, gguf_get_version(ctx_gguf.get()));
-            LOG_INF("%s: alignment:    %zu\n", __func__, gguf_get_alignment(ctx_gguf.get()));
-            LOG_INF("%s: n_tensors:    %d\n",  __func__, n_tensors);
-            LOG_INF("%s: n_kv:         %d\n",  __func__, (int)gguf_get_n_kv(ctx_gguf.get()));
-            LOG_INF("\n");
-        }
-
-        // modalities
-        {
-            get_bool(KEY_HAS_VISION_ENC,    has_vision,    false);
-            get_bool(KEY_HAS_AUDIO_ENC,     has_audio,     false);
-            get_bool(KEY_HAS_GEN_AUDIO_ENC, has_gen_audio, false);
-
-            if (has_vision) {
-                LOG_INF("%s: has vision encoder\n", __func__);
-            }
-            if (has_audio) {
-                LOG_INF("%s: has audio encoder\n", __func__);
-            }
-            if (has_gen_audio) {
-                LOG_INF("%s: has audio generation (gen) encoder\n", __func__);
-            }
-        }
-
-        // tensors
-        if (!skip_tensors) {
-            for (int i = 0; i < n_tensors; ++i) {
-                const char * name = gguf_get_tensor_name(ctx_gguf.get(), i);
-                const size_t offset = gguf_get_tensor_offset(ctx_gguf.get(), i);
-                enum ggml_type type = gguf_get_tensor_type(ctx_gguf.get(), i);
-                ggml_tensor * cur = ggml_get_tensor(meta, name);
-                size_t tensor_size = ggml_nbytes(cur);
-                model_size += tensor_size;
-                LOG_DBG("%s: tensor[%d]: n_dims = %d, name = %s, tensor_size=%zu, offset=%zu, shape:[%" PRIu64 ", %" PRIu64 ", %" PRIu64 ", %" PRIu64 "], type = %s\n",
-                    __func__, i, ggml_n_dims(cur), cur->name, tensor_size, offset, cur->ne[0], cur->ne[1], cur->ne[2], cur->ne[3], ggml_type_name(type));
-            }
-        }
-    }
-
-    void load_hparams(clip_model & model, clip_modality modality) {
-        auto & hparams = model.hparams;
-        std::string log_ffn_op; // for logging
-
-        // sanity check
-        if (modality == CLIP_MODALITY_VISION) {
-            GGML_ASSERT(has_vision);
-        } else if (modality == CLIP_MODALITY_AUDIO) {
-            GGML_ASSERT(has_audio);
-        } else if (modality == CLIP_MODALITY_GEN_AUDIO) {
-            GGML_ASSERT(has_gen_audio);
-        }
-        model.modality = modality;
-
-
-        // projector type
-        std::string proj_type;
-        {
-            // default key
-            get_string(KEY_PROJ_TYPE, proj_type, false);
-
-            // for models with mixed modalities
-            if (proj_type.empty()) {
-                if (modality == CLIP_MODALITY_VISION) {
-                    get_string(KEY_VISION_PROJ_TYPE, proj_type, false);
-                } else if (modality == CLIP_MODALITY_AUDIO) {
-                    get_string(KEY_AUDIO_PROJ_TYPE, proj_type, false);
-                } else if (modality == CLIP_MODALITY_GEN_AUDIO) {
-                    get_string(KEY_GEN_AUDIO_PROJ_TYPE, proj_type, false);
-                } else {
-                    GGML_ABORT("unknown modality");
-                }
-            }
-
-            model.proj_type = clip_projector_type_from_string(proj_type);
-
-            if (model.proj_type == PROJECTOR_TYPE_UNKNOWN) {
-                throw std::runtime_error(string_format("%s: unknown projector type: %s\n", __func__, proj_type.c_str()));
-            }
-
-            // correct arch for multimodal models (legacy method)
-            if (model.proj_type == PROJECTOR_TYPE_QWEN25O) {
-                model.proj_type = modality == CLIP_MODALITY_VISION
-                                    ? PROJECTOR_TYPE_QWEN25VL
-                                    : PROJECTOR_TYPE_QWEN2A;
-            }
-        }
-
-        const bool is_vision    = model.modality == CLIP_MODALITY_VISION;
-        const bool is_audio     = model.modality == CLIP_MODALITY_AUDIO;
-        const bool is_gen_audio = model.modality == CLIP_MODALITY_GEN_AUDIO;
-
-        // other hparams
-        {
-            const char * prefix = is_vision ? "vision" : (is_audio ? "audio" : "gen.audio");
-            get_u32(string_format(KEY_N_EMBD,         prefix), hparams.n_embd);
-            get_u32(string_format(KEY_N_HEAD,         prefix), hparams.n_head);
-            get_u32(string_format(KEY_N_EMBD_HEAD,    prefix), hparams.n_embd_head, false);
-            get_u32(string_format(KEY_N_FF,           prefix), hparams.n_ff);
-            get_u32(string_format(KEY_N_BLOCK,        prefix), hparams.n_layer);
-            get_u32(string_format(KEY_PROJ_DIM,       prefix), hparams.projection_dim);
-            get_f32(string_format(KEY_LAYER_NORM_EPS, prefix), hparams.eps);
-
-            // n_head_kv is optional (for GQA), default to n_head
-            hparams.n_head_kv = hparams.n_head;
-            get_u32(string_format(KEY_N_HEAD_KV, prefix), hparams.n_head_kv, false);
-
-            if (is_vision) {
-                get_u32(KEY_IMAGE_SIZE, hparams.image_size);
-                get_u32(KEY_PATCH_SIZE, hparams.patch_size);
-                get_u32(KEY_MAX_SLICE_NUMS, hparams.max_slice_nums, false);
-                get_i32(KEY_MINICPMV_VERSION, hparams.minicpmv_version, false); // legacy
-                get_u32(KEY_MINICPMV_QUERY_NUM, hparams.minicpmv_query_num, false);
-                if (hparams.minicpmv_query_num == 0) {
-                    // Fallback to hardcoded values for legacy models
-                    if (hparams.minicpmv_version == 3) {
-                        hparams.minicpmv_query_num = 64;
-                    } else if (hparams.minicpmv_version == 4) {
-                        hparams.minicpmv_query_num = 64;
-                    } else if (hparams.minicpmv_version == 5) {
-                        hparams.minicpmv_query_num = 64;
-                    } else if (hparams.minicpmv_version == 6) {
-                        hparams.minicpmv_query_num = 64;
-                    } else if (hparams.minicpmv_version == 100045) {
-                        hparams.minicpmv_query_num = 64;
-                    } else {
-                        hparams.minicpmv_query_num = 96;
-                    }
-                }
-            } else if (is_audio) {
-                get_u32(KEY_A_NUM_MEL_BINS, hparams.n_mel_bins);
-                // some hparams are unused, but still need to set to avoid issues
-                hparams.image_size = 0;
-                hparams.patch_size = 1;
-
-            } else if (is_gen_audio) {
-                // these are unused, but still need to be set to avoid issues
-                hparams.image_size = 0;
-                hparams.patch_size = 1;
-                get_string(KEY_GEN_AUDIO_VARIANT, hparams.gen_model_variant, false);
-
-            } else {
-                GGML_ASSERT(false && "unknown modality");
-            }
-
-            // for pinpoints, we need to convert it into a list of resolution candidates
-            {
-                std::vector<int> pinpoints;
-                get_arr_int(KEY_IMAGE_GRID_PINPOINTS, pinpoints, false);
-                if (pinpoints.size() % 2 != 0) {
-                    throw std::runtime_error(string_format("%s: image_grid_pinpoints must have an even number of elements, got %zu\n", __func__, pinpoints.size()));
-                }
-                if (!pinpoints.empty()) {
-                    for (size_t i = 0; i < pinpoints.size(); i += 2) {
-                        hparams.image_res_candidates.push_back({
-                            pinpoints[i],
-                            pinpoints[i+1],
-                        });
-                    }
-                }
-            }
-
-            // default warmup value
-            hparams.warmup_image_size = hparams.image_size;
-
-            {
-                bool use_gelu = false;
-                bool use_silu = false;
-                get_bool(KEY_USE_GELU, use_gelu, false);
-                get_bool(KEY_USE_SILU, use_silu, false);
-                if (use_gelu && use_silu) {
-                    throw std::runtime_error(string_format("%s: both use_gelu and use_silu are set to true\n", __func__));
-                }
-                if (use_gelu) {
-                    hparams.ffn_op = FFN_GELU;
-                    log_ffn_op = "gelu";
-                } else if (use_silu) {
-                    hparams.ffn_op = FFN_SILU;
-                    log_ffn_op = "silu";
-                } else {
-                    hparams.ffn_op = FFN_GELU_QUICK;
-                    log_ffn_op = "gelu_quick";
-                }
-            }
-
-            {
-                std::string mm_patch_merge_type;
-                get_string(KEY_MM_PATCH_MERGE_TYPE, mm_patch_merge_type, false);
-                if (mm_patch_merge_type == "spatial_unpad") {
-                    hparams.mm_patch_merge_type = PATCH_MERGE_SPATIAL_UNPAD;
+            {
+                std::string mm_patch_merge_type;
+                get_string(KEY_MM_PATCH_MERGE_TYPE, mm_patch_merge_type, false);
+                if (mm_patch_merge_type == "spatial_unpad") {
+                    hparams.mm_patch_merge_type = PATCH_MERGE_SPATIAL_UNPAD;
                 }
             }

@@ -1692,7 +379,7 @@ struct clip_model_loader {
                     {
                         get_u32(KEY_AUDIO_SUBSMPL_FACTOR, hparams.subsampling_factor);
                         GGML_ASSERT(hparams.subsampling_factor == 8 &&
-                            "subsampling_factor must match the conv strides in clip_graph_parakeet::build()");
+                            "subsampling_factor must match the conv strides in mtmd_graph_parakeet::build()");
                         get_u32(KEY_A_CONV_KERNEL_SIZE,       hparams.audio_conv_kernel_size);
                         GGML_ASSERT(hparams.audio_conv_kernel_size > 0 && hparams.audio_conv_kernel_size % 2 == 1 &&
                             "audio_conv_kernel_size must be a positive odd integer");
@@ -1720,7 +407,7 @@ struct clip_model_loader {
                         hparams.audio_window_len   = 400;
                         hparams.audio_hop_len      = 160;
                         // one full window
-                        using nt = clip_graph_nemotron_transcribe;
+                        using nt = mtmd_graph_nemotron_transcribe;
                         hparams.warmup_audio_size = (nt::window_left + nt::window_core + nt::window_right) * hparams.subsampling_factor;
                     } break;
                 case PROJECTOR_TYPE_IDEFICS3:
@@ -2054,7 +741,7 @@ struct clip_model_loader {
                         hparams.image_resize_algo = RESIZE_ALGO_BICUBIC;
                         hparams.image_resize_pad  = PAD_NONE;
                         get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false);
-                        // n_merge is used as a divisor in clip_image_batch_encode
+                        // n_merge is used as a divisor in mtmd_subcontext::encode()
                         // (gh / n_merge); reject 0 to avoid int div-by-zero (DoS).
                         GGML_ASSERT(hparams.n_merge > 0);
                         hparams.rope_theta = 10000.0f; // vision_config.rope_theta
@@ -2221,7 +908,7 @@ struct clip_model_loader {
                         hparams.audio_n_fft        = 400;
                         hparams.audio_window_len   = 400;
                         hparams.audio_hop_len      = 160;
-                        // 2 context frames + 8 s of new frames, see clip_enc_state_init()
+                        // 2 context frames + 8 s of new frames, see mtmd_subcontext::enc_state_init()
                         hparams.warmup_audio_size  = 2 + 2*400;
                     } break;
                 case PROJECTOR_TYPE_MIMO_AUDIO:
@@ -2281,6 +968,9 @@ struct clip_model_loader {
                     } break;
                 case PROJECTOR_TYPE_LFM3A_GEN:
                     {
+                        // TODO: CUDA has no flash attention kernel for the depthformer head dim 32, enable it once it does
+                        model.flash_attn_auto = false;
+
                         // fixed by LiquidCodecConfig, head counts come from the tensors
                         hparams.rope_theta = 1000000.0f;

@@ -2359,6 +1049,9 @@ struct clip_model_loader {
                     } break;
                 case PROJECTOR_TYPE_CHATTERBOX:
                     {
+                        // the classic cfm solver unrolls up to 10 steps x 2 cfg estimator evaluations in one graph
+                        model.max_nodes = 65536;
+
                         // s3gen output rate
                         hparams.audio_sample_rate = 24000;
                     } break;
@@ -2617,8 +1310,7 @@ struct clip_model_loader {
         }
     }

-    void load_tensors(clip_ctx & ctx_clip) {
-        auto & model = ctx_clip.model;
+    void load_tensors(mtmd_model & model, ggml_backend_buffer_type_t buft, bool no_alloc) {
         auto & hparams = model.hparams;
         std::map<std::string, size_t> tensor_offset;
         std::vector<ggml_tensor *> tensors_to_load;
@@ -2629,8 +1321,8 @@ struct clip_model_loader {
         }

         // TODO @ngxson : support both audio and video in the future
-        const char * prefix = model.modality == CLIP_MODALITY_AUDIO ? "a"
-                             : model.modality == CLIP_MODALITY_GEN_AUDIO ? "a.gen.code"
+        const char * prefix = model.modality == MTMD_MODALITY_AUDIO ? "a"
+                             : model.modality == MTMD_MODALITY_GEN_AUDIO ? "a.gen.code"
                              : "v";

         // get offsets
@@ -2645,8 +1337,8 @@ struct clip_model_loader {
             /*.mem_buffer =*/ NULL,
             /*.no_alloc =*/ true,
         };
-        ctx_clip.ctx_data.reset(ggml_init(params));
-        if (!ctx_clip.ctx_data) {
+        model.ctx_data.reset(ggml_init(params));
+        if (!model.ctx_data) {
             throw std::runtime_error(string_format("%s: failed to init ggml context\n", __func__));
         }

@@ -2663,18 +1355,18 @@ struct clip_model_loader {
             }
             if (cur) {
                 tensors_to_load.push_back(cur);
-                ggml_tensor * data_tensor = ggml_dup_tensor(ctx_clip.ctx_data.get(), cur);
+                ggml_tensor * data_tensor = ggml_dup_tensor(model.ctx_data.get(), cur);
                 ggml_set_name(data_tensor, cur->name);
                 loaded_tensor_names.insert(name);
                 cur = data_tensor;
                 // add to weight memory counter
-                ctx_clip.mem_usage[ggml_backend_get_device(ctx_clip.backend)] += ggml_nbytes(cur);
+                model.mem_weights[ggml_backend_buft_get_device(buft)] += ggml_nbytes(cur);
             }
             return cur;
         };

         // pocket-tts: the encoder and the decoder share the same layout, only the prefix differs
-        auto load_seanet = [&](clip_seanet & seanet, bool is_decoder) {
+        auto load_seanet = [&](mtmd_seanet & seanet, bool is_decoder) {
             const char * conv_in  = is_decoder ? TN_A_GEN_WAV_SEANET_CONV_IN    : TN_A_SEANET_CONV_IN;
             const char * conv_out = is_decoder ? TN_A_GEN_WAV_SEANET_CONV_OUT   : TN_A_SEANET_CONV_OUT;
             const char * res1     = is_decoder ? TN_A_GEN_WAV_SEANET_RES_CONV1  : TN_A_SEANET_RES_CONV1;
@@ -3706,7 +2398,7 @@ struct clip_model_loader {
                         if (probe == nullptr) {
                             break;
                         }
-                        clip_flow_net::block blk;
+                        mtmd_flow_net::block blk;
                         blk.norm_w = probe;
                         blk.norm_b = get_tensor(string_format(TN_A_GEN_FLOW_BLK_NORM, il, "bias"));
                         blk.up_w   = get_tensor(string_format(TN_A_GEN_FLOW_BLK_UP,   il, "weight"));
@@ -3866,7 +2558,7 @@ struct clip_model_loader {
                     auto has = [&](const std::string & name) {
                         return gguf_find_tensor(ctx_gguf.get(), name.c_str()) >= 0;
                     };
-                    auto load_enc_layer = [&](const std::string & p, clip_chatterbox::enc_layer & l) {
+                    auto load_enc_layer = [&](const std::string & p, mtmd_chatterbox::enc_layer & l) {
                         l.norm_mha_w      = get_tensor(p + ".norm_mha.weight");
                         l.norm_mha_b      = get_tensor(p + ".norm_mha.bias");
                         l.attn_q_w        = get_tensor(p + ".self_attn.linear_q.weight");
@@ -3887,13 +2579,13 @@ struct clip_model_loader {
                         l.ffn_2_w         = get_tensor(p + ".feed_forward.w_2.weight");
                         l.ffn_2_b         = get_tensor(p + ".feed_forward.w_2.bias");
                     };
-                    auto load_causal = [&](const std::string & p, clip_chatterbox::causal_block & b) {
+                    auto load_causal = [&](const std::string & p, mtmd_chatterbox::causal_block & b) {
                         b.conv_w = get_tensor(p + ".block.0.weight");
                         b.conv_b = get_tensor(p + ".block.0.bias");
                         b.norm_w = get_tensor(p + ".block.2.weight");
                         b.norm_b = get_tensor(p + ".block.2.bias");
                     };
-                    auto load_resnet = [&](const std::string & p, clip_chatterbox::resnet & r) {
+                    auto load_resnet = [&](const std::string & p, mtmd_chatterbox::resnet & r) {
                         load_causal(p + ".block1", r.block1);
                         load_causal(p + ".block2", r.block2);
                         r.mlp_w      = get_tensor(p + ".mlp.1.weight");
@@ -3901,7 +2593,7 @@ struct clip_model_loader {
                         r.res_conv_w = get_tensor(p + ".res_conv.weight");
                         r.res_conv_b = get_tensor(p + ".res_conv.bias");
                     };
-                    auto load_tfm = [&](const std::string & p, clip_chatterbox::tfm_block & b) {
+                    auto load_tfm = [&](const std::string & p, mtmd_chatterbox::tfm_block & b) {
                         b.norm1_w    = get_tensor(p + ".norm1.weight");
                         b.norm1_b    = get_tensor(p + ".norm1.bias");
                         b.attn_q_w   = get_tensor(p + ".attn1.to_q.weight");
@@ -3916,10 +2608,10 @@ struct clip_model_loader {
                         b.ff_out_w   = get_tensor(p + ".ff.net.2.weight");
                         b.ff_out_b   = get_tensor(p + ".ff.net.2.bias");
                     };
-                    auto load_stage = [&](const std::string & p, clip_chatterbox::est_stage & s, bool boundary) {
+                    auto load_stage = [&](const std::string & p, mtmd_chatterbox::est_stage & s, bool boundary) {
                         load_resnet(p + ".0", s.res);
                         for (int j = 0; has(p + ".1." + std::to_string(j) + ".norm1.weight"); j++) {
-                            clip_chatterbox::tfm_block b;
+                            mtmd_chatterbox::tfm_block b;
                             load_tfm(p + ".1." + std::to_string(j), b);
                             s.tfm.push_back(b);
                         }
@@ -3928,10 +2620,10 @@ struct clip_model_loader {
                             s.conv_b = get_tensor(p + ".2.bias");
                         }
                     };
-                    auto load_hres = [&](const std::string & p, clip_chatterbox::hift_res & r) {
+                    auto load_hres = [&](const std::string & p, mtmd_chatterbox::hift_res & r) {
                         for (int j = 0; has(p + ".convs1." + std::to_string(j) + ".weight"); j++) {
                             const std::string js = std::to_string(j);
-                            clip_chatterbox::hift_res_unit u;
+                            mtmd_chatterbox::hift_res_unit u;
                             u.act1_alpha = get_tensor(p + ".activations1." + js + ".alpha");
                             u.act2_alpha = get_tensor(p + ".activations2." + js + ".alpha");
                             u.conv1_w    = get_tensor(p + ".convs1." + js + ".weight");
@@ -3955,7 +2647,7 @@ struct clip_model_loader {
                     c.pre_conv2_w       = get_tensor("a.gen.fenc.pre_lookahead_layer.conv2.weight");
                     c.pre_conv2_b       = get_tensor("a.gen.fenc.pre_lookahead_layer.conv2.bias");
                     for (int i = 0; has("a.gen.fenc.encoders." + std::to_string(i) + ".norm_mha.weight"); i++) {
-                        clip_chatterbox::enc_layer l;
+                        mtmd_chatterbox::enc_layer l;
                         load_enc_layer("a.gen.fenc.encoders." + std::to_string(i), l);
                         c.enc.push_back(l);
                     }
@@ -3966,7 +2658,7 @@ struct clip_model_loader {
                     c.up_embed_norm_w   = get_tensor("a.gen.fenc.up_embed.out.1.weight");
                     c.up_embed_norm_b   = get_tensor("a.gen.fenc.up_embed.out.1.bias");
                     for (int i = 0; has("a.gen.fenc.up_encoders." + std::to_string(i) + ".norm_mha.weight"); i++) {
-                        clip_chatterbox::enc_layer l;
+                        mtmd_chatterbox::enc_layer l;
                         load_enc_layer("a.gen.fenc.up_encoders." + std::to_string(i), l);
                         c.up_enc.push_back(l);
                     }
@@ -3983,7 +2675,7 @@ struct clip_model_loader {
                     c.time_embed_mixer_w  = get_tensor("a.gen.est.time_embed_mixer.weight", false);
                     load_stage("a.gen.est.down_blocks.0", c.est_down, true);
                     for (int i = 0; has("a.gen.est.mid_blocks." + std::to_string(i) + ".0.block1.block.0.weight"); i++) {
-                        clip_chatterbox::est_stage s;
+                        mtmd_chatterbox::est_stage s;
                         load_stage("a.gen.est.mid_blocks." + std::to_string(i), s, false);
                         c.est_mid.push_back(std::move(s));
                     }
@@ -3999,7 +2691,7 @@ struct clip_model_loader {
                     c.hift_post_b = get_tensor("a.gen.hift.conv_post.bias");
                     for (int i = 0; has("a.gen.hift.ups." + std::to_string(i) + ".weight"); i++) {
                         const std::string is = std::to_string(i);
-                        clip_chatterbox::hift_up up;
+                        mtmd_chatterbox::hift_up up;
                         up.up_w          = get_tensor("a.gen.hift.ups." + is + ".weight");
                         up.up_b          = get_tensor("a.gen.hift.ups." + is + ".bias");
                         up.source_down_w = get_tensor("a.gen.hift.source_downs." + is + ".weight");
@@ -4012,7 +2704,7 @@ struct clip_model_loader {
                     }
                     for (int i = 0; has("a.gen.hift.f0_predictor.condnet." + std::to_string(i) + ".weight"); i += 2) {
                         const std::string is = std::to_string(i);
-                        clip_chatterbox::f0_conv fc;
+                        mtmd_chatterbox::f0_conv fc;
                         fc.w = get_tensor("a.gen.hift.f0_predictor.condnet." + is + ".weight");
                         fc.b = get_tensor("a.gen.hift.f0_predictor.condnet." + is + ".bias");
                         c.f0_condnet.push_back(fc);
@@ -4027,7 +2719,7 @@ struct clip_model_loader {
                     c.s3tok_conv2_b = get_tensor("a.s3tok.encoder.conv2.bias");
                     for (int i = 0; has("a.s3tok.encoder.blocks." + std::to_string(i) + ".attn_ln.weight"); i++) {
                         const std::string p = "a.s3tok.encoder.blocks." + std::to_string(i);
-                        clip_chatterbox::s3tok_block b;
+                        mtmd_chatterbox::s3tok_block b;
                         b.attn_ln_w  = get_tensor(p + ".attn_ln.weight");
                         b.attn_ln_b  = get_tensor(p + ".attn_ln.bias");
                         b.attn_q_w   = get_tensor(p + ".attn.query.weight");
@@ -4121,13 +2813,13 @@ struct clip_model_loader {
                     auto has = [&](const std::string & name) {
                         return gguf_find_tensor(ctx_gguf.get(), name.c_str()) >= 0;
                     };
-                    auto load_bn = [&](const std::string & p, clip_chatterbox::bn & n) {
+                    auto load_bn = [&](const std::string & p, mtmd_chatterbox::bn & n) {
                         n.mean = get_tensor(p + ".running_mean");
                         n.var  = get_tensor(p + ".running_var");
                         n.w    = get_tensor(p + ".weight", false);
                         n.b    = get_tensor(p + ".bias",   false);
                     };
-                    auto load_res2d = [&](const std::string & p, clip_chatterbox::spk_res2d & r) {
+                    auto load_res2d = [&](const std::string & p, mtmd_chatterbox::spk_res2d & r) {
                         r.conv1_w = get_tensor(p + ".conv1.weight");
                         load_bn(p + ".bn1", r.bn1);
                         r.conv2_w = get_tensor(p + ".conv2.weight");
@@ -4137,10 +2829,10 @@ struct clip_model_loader {
                             load_bn(p + ".shortcut.1", r.shortcut_bn);
                         }
                     };
-                    auto load_cam_block = [&](const std::string & bp, const std::string & tp, clip_chatterbox::spk_cam_block & blk) {
+                    auto load_cam_block = [&](const std::string & bp, const std::string & tp, mtmd_chatterbox::spk_cam_block & blk) {
                         for (int li = 1; has(bp + ".tdnnd" + std::to_string(li) + ".linear1.weight"); li++) {
                             const std::string p = bp + ".tdnnd" + std::to_string(li);
-                            clip_chatterbox::spk_cam_layer l;
+                            mtmd_chatterbox::spk_cam_layer l;
                             load_bn(p + ".nonlinear1.batchnorm", l.nl1_bn);
                             l.linear1_w = get_tensor(p + ".linear1.weight");
                             load_bn(p + ".nonlinear2.batchnorm", l.nl2_bn);
@@ -4155,4017 +2847,1361 @@ struct clip_model_loader {
                         blk.transit_w = get_tensor(tp + ".linear.weight");
                     };

-                    c.spk_conv1_w = get_tensor("a.spk.head.conv1.weight");
-                    load_bn("a.spk.head.bn1", c.spk_bn1);
-                    load_res2d("a.spk.head.layer1.0", c.spk_layer1_0);
-                    load_res2d("a.spk.head.layer1.1", c.spk_layer1_1);
-                    load_res2d("a.spk.head.layer2.0", c.spk_layer2_0);
-                    load_res2d("a.spk.head.layer2.1", c.spk_layer2_1);
-                    c.spk_conv2_w = get_tensor("a.spk.head.conv2.weight");
-                    load_bn("a.spk.head.bn2", c.spk_bn2);
-                    c.spk_tdnn_w = get_tensor("a.spk.xvector.tdnn.linear.weight");
-                    load_bn("a.spk.xvector.tdnn.nonlinear.batchnorm", c.spk_tdnn_bn);
-                    load_cam_block("a.spk.xvector.block1", "a.spk.xvector.transit1", c.spk_block1);
-                    load_cam_block("a.spk.xvector.block2", "a.spk.xvector.transit2", c.spk_block2);
-                    load_cam_block("a.spk.xvector.block3", "a.spk.xvector.transit3", c.spk_block3);
-                    load_bn("a.spk.xvector.out_nonlinear.batchnorm", c.spk_out_bn);
-                    c.spk_dense_w = get_tensor("a.spk.xvector.dense.linear.weight");
-                    load_bn("a.spk.xvector.dense.nonlinear.batchnorm", c.spk_dense_bn);
-
-                    // read on the host: voice encoder lstm, conditioning encoder
-                    for (ggml_tensor * t = ggml_get_first_tensor(ctx_meta.get()); t; t = ggml_get_next_tensor(ctx_meta.get(), t)) {
-                        const std::string name = t->name;
-                        if (name.rfind("a.ve.", 0) == 0 || name.rfind("a.cenc.", 0) == 0) {
-                            model.cbx_tensors[name] = get_tensor(name);
-                        }
-                    }
-                } break;
-            case PROJECTOR_TYPE_SOPRANO:
-                {
-                    auto & v = model.vocos;
-                    v.input_w       = get_tensor(string_format(TN_A_GEN_WAV_INPUT, "weight"));
-                    v.input_b       = get_tensor(string_format(TN_A_GEN_WAV_INPUT, "bias"));
-                    v.norm_w        = get_tensor(string_format(TN_A_GEN_WAV_NORM, "weight"));
-                    v.norm_b        = get_tensor(string_format(TN_A_GEN_WAV_NORM, "bias"));
-                    v.output_norm_w = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT_NORM, "weight"));
-                    v.output_norm_b = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT_NORM, "bias"));
-                    v.output_w      = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT, "weight"));
-                    v.output_b      = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT, "bias"));
-                    v.blocks.resize(hparams.n_layer);
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & b = v.blocks[il];
-                        b.dwconv_w = get_tensor(string_format(TN_A_GEN_WAV_UP_DWCONV, il, "weight"));
-                        b.dwconv_b = get_tensor(string_format(TN_A_GEN_WAV_UP_DWCONV, il, "bias"));
-                        b.norm_w   = get_tensor(string_format(TN_A_GEN_WAV_UP_NORM, il, "weight"));
-                        b.norm_b   = get_tensor(string_format(TN_A_GEN_WAV_UP_NORM, il, "bias"));
-                        b.pw1_w    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW1, il, "weight"));
-                        b.pw1_b    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW1, il, "bias"));
-                        b.pw2_w    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW2, il, "weight"));
-                        b.pw2_b    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW2, il, "bias"));
-                        b.gamma    = get_tensor(string_format(TN_A_GEN_WAV_UP_GAMMA, il));
-                    }
-                } break;
-            case PROJECTOR_TYPE_VOXTRAL:
-            case PROJECTOR_TYPE_VOXTRAL_RT:
-                {
-                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
-                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
-                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
-                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
-                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
-                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
-                } break;
-            case PROJECTOR_TYPE_MUSIC_FLAMINGO:
-                {
-                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
-                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
-                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
-                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
-                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
-                    model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
-                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
-                    model.mm_2_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "bias"));
-                } break;
-            case PROJECTOR_TYPE_INTERNVL:
-                {
-                    model.mm_0_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "bias"));
-                    model.mm_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
-                    model.mm_1_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "bias"));
-                    model.mm_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
-                    model.mm_3_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "bias"));
-                } break;
-            case PROJECTOR_TYPE_NEMOTRON_V2_VL:
-            case PROJECTOR_TYPE_NEMOTRON_OMNI:
-                {
-                    model.mm_0_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
-                    model.mm_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
-                    model.mm_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
-                } break;
-            case PROJECTOR_TYPE_GLMA:
-                {
-                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
-                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
-                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
-                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
-                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
-                    model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
-                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
-                    model.mm_2_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "bias"));
-                    model.mm_norm_pre_w = get_tensor(string_format(TN_MM_NORM_PRE, "weight"));
-                    model.mm_norm_pre_b = get_tensor(string_format(TN_MM_NORM_PRE, "bias"));
-                    model.mm_boi = get_tensor(string_format(TN_TOK_BOI));
-                    model.mm_eoi = get_tensor(string_format(TN_TOK_EOI));
-                } break;
-            case PROJECTOR_TYPE_LLAMA4:
-                {
-                    model.mm_model_proj    = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
-                    model.mm_model_mlp_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
-                    model.mm_model_mlp_2_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 2, "weight"));
-                } break;
-            case PROJECTOR_TYPE_COGVLM:
-                {
-                    model.mm_model_proj     = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
-                    model.mm_post_fc_norm_w = get_tensor(string_format(TN_MM_POST_FC_NORM, "weight"));
-                    model.mm_post_fc_norm_b = get_tensor(string_format(TN_MM_POST_FC_NORM, "bias"));
-                    model.mm_h_to_4h_w      = get_tensor(string_format(TN_MM_H_TO_4H,      "weight"));
-                    model.mm_gate_w         = get_tensor(string_format(TN_MM_GATE,         "weight"));
-                    model.mm_4h_to_h_w      = get_tensor(string_format(TN_MM_4H_TO_H,      "weight"));
-                    model.mm_boi            = get_tensor(TN_TOK_BOI);
-                    model.mm_eoi            = get_tensor(TN_TOK_EOI);
-                } break;
-            case PROJECTOR_TYPE_HUNYUANVL:
-                {
-                    // proj.0 -> mm.0 (conv1), proj.2 -> mm.2 (conv2), mlp -> mm.model.fc (linear)
-                    model.mm_0_w            = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
-                    model.mm_0_b            = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
-                    model.mm_1_w            = get_tensor(string_format(TN_LLAVA_PROJ, 2, "weight"));
-                    model.mm_1_b            = get_tensor(string_format(TN_LLAVA_PROJ, 2, "bias"));
-                    model.mm_model_proj     = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
-                    model.mm_model_proj_b   = get_tensor(string_format(TN_MM_PROJECTOR, "bias"));
-                    model.mm_pre_norm_w     = get_tensor(string_format(TN_MM_PRE_NORM, "weight"));
-                    model.mm_post_norm_w    = get_tensor(string_format(TN_MM_POST_NORM, "weight"));
-                    model.mm_img_begin      = get_tensor(TN_MM_IMG_BEGIN);
-                    model.mm_img_end        = get_tensor(TN_MM_IMG_END);
-                    model.image_newline     = get_tensor(TN_IMAGE_NEWLINE);
-                    model.view_seperator    = get_tensor(TN_IMAGE_SEPERATOR, false);
-                } break;
-            case PROJECTOR_TYPE_JANUS_PRO:
-                {
-                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
-                    model.mm_1_w = get_tensor(string_format(TN_LLAVA_PROJ, 1, "weight"));
-                    model.mm_1_b = get_tensor(string_format(TN_LLAVA_PROJ, 1, "bias"));
-                } break;
-            case PROJECTOR_TYPE_PHI4:
-                {
-                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
-                    model.mm_2_w = get_tensor(string_format(TN_LLAVA_PROJ, 2, "weight"));
-                    model.mm_2_b = get_tensor(string_format(TN_LLAVA_PROJ, 2, "bias"));
-                } break;
-            case PROJECTOR_TYPE_DEEPSEEKOCR:
-            case PROJECTOR_TYPE_DEEPSEEKOCR2:
-                {
-                    model.pos_embed          = get_tensor(string_format(TN_SAM_POS_EMBD,   "weight"));
-                    model.patch_embed_proj_w = get_tensor(string_format(TN_SAM_PATCH_EMBD, "weight"));
-                    model.patch_embed_proj_b = get_tensor(string_format(TN_SAM_PATCH_EMBD, "bias"));
-                    model.n_sam_layers = hparams.sam_n_layer;
-                    model.sam_layers.resize(model.n_sam_layers);
-                    for (int il = 0; il < model.n_sam_layers; ++il) {
-                        auto & layer    = model.sam_layers[il];
-                        layer.qkv_w     = get_tensor(string_format(TN_SAM_ATTN_QKV, il, "weight"));
-                        layer.qkv_b     = get_tensor(string_format(TN_SAM_ATTN_QKV, il, "bias"));
-                        layer.o_w       = get_tensor(string_format(TN_SAM_ATTN_OUT, il, "weight"));
-                        layer.o_b       = get_tensor(string_format(TN_SAM_ATTN_OUT, il, "bias"));
-                        layer.ln_1_w    = get_tensor(string_format(TN_SAM_PRE_NORM, il, "weight"));
-                        layer.ln_1_b    = get_tensor(string_format(TN_SAM_PRE_NORM, il, "bias"));
-                        layer.ln_2_w    = get_tensor(string_format(TN_SAM_POST_NORM, il, "weight"));
-                        layer.ln_2_b    = get_tensor(string_format(TN_SAM_POST_NORM, il, "bias"));
-                        layer.rel_pos_h = get_tensor(string_format(TN_SAM_ATTN_POS_H, il, "weight"));
-                        layer.rel_pos_w = get_tensor(string_format(TN_SAM_ATTN_POS_W, il, "weight"));
-                        layer.ff_up_w   = get_tensor(string_format(TN_SAM_FFN_UP, il, "weight"));
-                        layer.ff_up_b   = get_tensor(string_format(TN_SAM_FFN_UP, il, "bias"));
-                        layer.ff_down_w = get_tensor(string_format(TN_SAM_FFN_DOWN, il, "weight"));
-                        layer.ff_down_b = get_tensor(string_format(TN_SAM_FFN_DOWN, il, "bias"));
-                    }
-                    model.neck_0_w       = get_tensor(string_format(TN_SAM_NECK, 0, "weight"));
-                    model.neck_1_b       = get_tensor(string_format(TN_SAM_NECK, 1, "bias"));
-                    model.neck_1_w       = get_tensor(string_format(TN_SAM_NECK, 1, "weight"));
-                    model.neck_2_w       = get_tensor(string_format(TN_SAM_NECK, 2, "weight"));
-                    model.neck_3_b       = get_tensor(string_format(TN_SAM_NECK, 3, "bias"));
-                    model.neck_3_w       = get_tensor(string_format(TN_SAM_NECK, 3, "weight"));
-                    model.net_2          = get_tensor(string_format(TN_SAM_NET, 2, "weight"));
-                    model.net_3          = get_tensor(string_format(TN_SAM_NET, 3, "weight"));
-                    model.image_newline  = get_tensor(TN_IMAGE_NEWLINE, false);
-                    model.view_seperator = get_tensor(TN_IMAGE_SEPERATOR);
-                    model.mm_fc_w        = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
-                    model.mm_fc_b        = get_tensor(string_format(TN_MM_PROJECTOR, "bias"));
-                    model.resample_query_768  = get_tensor(string_format(TN_RESMPL_QUERY, 768, "weight"), false);
-                    model.resample_query_1024 = get_tensor(string_format(TN_RESMPL_QUERY, 1024, "weight"), false);
-                 } break;
-            case PROJECTOR_TYPE_GEMMA4A:
-                {
-                    for (int i = 0; i < 2; i++) {
-                        model.sscp_conv_w[i] = get_tensor(string_format(TN_A_CONV1D, i, "weight"));
-                        model.sscp_conv_b[i] = get_tensor(string_format(TN_A_CONV1D, i, "bias"), false);
-                        model.sscp_norm_w[i] = get_tensor(string_format(TN_A_CONV1D_NORM, i, "weight"), false);
-                    }
-                    model.sscp_inp_proj_w = get_tensor(string_format(TN_A_INP_PROJ, "weight"));
-                    model.sscp_inp_proj_b = get_tensor(string_format(TN_A_INP_PROJ, "bias"), false);
-                    model.audio_out_proj_w = get_tensor(string_format(TN_A_OUT_PROJ, "weight"), false);
-                    model.audio_out_proj_b = get_tensor(string_format(TN_A_OUT_PROJ, "bias"), false);
-                    // audio multimodal embedder (mm.a.* namespace, not mm.*)
-                    model.mm_soft_emb_norm_w = get_tensor(string_format(TN_A_MM_SOFT_EMB_N, "weight"), false);
-                    model.mm_input_proj_w    = get_tensor(string_format(TN_A_MM_INP_PROJ, "weight"), false);
-
-                    // Per-layer tensors NOT loaded by the generic loop above
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-
-                        // Gemma4 audio conformer-specific tensors
-                        layer.ff_norm_w        = get_tensor(string_format(TN_FFN_NORM, prefix, il, "weight"));
-                        layer.attn_pre_norm_w  = get_tensor(string_format(TN_A_ATTN_PRE_NORM, prefix, il, "weight"), false);
-                        layer.per_dim_scale_w  = get_tensor(string_format(TN_A_PER_DIM_SCALE, prefix, il, "weight"), false);
-                        layer.per_dim_k_scale_w = get_tensor(string_format(TN_A_PER_DIM_K_SCALE, prefix, il, "weight"), false);
-                        layer.attn_k_rel_w     = get_tensor(string_format(TN_A_ATTN_K_REL, prefix, il, "weight"), false);
-
-                        // Convolution module
-                        // Note: conv_norm / norm_conv are swapped in GGUF due to
-                        // upstream tensor_mapping.py, so we load them in reverse order
-                        layer.norm_conv_w  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"), false);
-                        layer.norm_conv_b  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"), false);
-                        layer.conv_pw1_w   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
-                        layer.conv_pw1_b   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"), false);
-                        layer.conv_dw_w    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
-                        layer.conv_dw_b    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "bias"), false);
-                        layer.conv_norm_w  = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"), false);
-                        layer.conv_norm_b  = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"), false);
-                        layer.conv_pw2_w   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
-                        layer.conv_pw2_b   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"), false);
-
-                        // FFN2 (second half-step)
-                        layer.ff_norm_1_w      = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
-                        layer.ff_up_1_w        = get_tensor(string_format(TN_FFN_UP_1, prefix, il, "weight"));
-                        layer.ff_up_1_b        = get_tensor(string_format(TN_FFN_UP_1, prefix, il, "bias"), false);
-                        layer.ff_down_1_w      = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
-                        layer.ff_down_1_b      = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"), false);
-                        layer.ff_post_norm_1_w = get_tensor(string_format(TN_A_FFN_POST_NORM_1, prefix, il, "weight"), false);
-                    }
+                    c.spk_conv1_w = get_tensor("a.spk.head.conv1.weight");
+                    load_bn("a.spk.head.bn1", c.spk_bn1);
+                    load_res2d("a.spk.head.layer1.0", c.spk_layer1_0);
+                    load_res2d("a.spk.head.layer1.1", c.spk_layer1_1);
+                    load_res2d("a.spk.head.layer2.0", c.spk_layer2_0);
+                    load_res2d("a.spk.head.layer2.1", c.spk_layer2_1);
+                    c.spk_conv2_w = get_tensor("a.spk.head.conv2.weight");
+                    load_bn("a.spk.head.bn2", c.spk_bn2);
+                    c.spk_tdnn_w = get_tensor("a.spk.xvector.tdnn.linear.weight");
+                    load_bn("a.spk.xvector.tdnn.nonlinear.batchnorm", c.spk_tdnn_bn);
+                    load_cam_block("a.spk.xvector.block1", "a.spk.xvector.transit1", c.spk_block1);
+                    load_cam_block("a.spk.xvector.block2", "a.spk.xvector.transit2", c.spk_block2);
+                    load_cam_block("a.spk.xvector.block3", "a.spk.xvector.transit3", c.spk_block3);
+                    load_bn("a.spk.xvector.out_nonlinear.batchnorm", c.spk_out_bn);
+                    c.spk_dense_w = get_tensor("a.spk.xvector.dense.linear.weight");
+                    load_bn("a.spk.xvector.dense.nonlinear.batchnorm", c.spk_dense_bn);

-                    // Load clamp info for ClippableLinear AFTER all tensors are loaded
-                    for (auto * tensor : tensors_to_load) {
-                        std::string name = tensor->name;
-                        if (string_ends_with(name, ".weight")) {
-                            std::string name_inp_max = name;
-                            std::string name_inp_min = name;
-                            std::string name_out_max = name;
-                            std::string name_out_min = name;
-                            string_replace_all(name_inp_max, ".weight", ".input_max");
-                            string_replace_all(name_inp_min, ".weight", ".input_min");
-                            string_replace_all(name_out_max, ".weight", ".output_max");
-                            string_replace_all(name_out_min, ".weight", ".output_min");
-                            model.clamp_info_map[name] = {
-                                get_scalar(name_inp_max, FLT_MAX),
-                                get_scalar(name_inp_min, -FLT_MAX),
-                                get_scalar(name_out_max, FLT_MAX),
-                                get_scalar(name_out_min, -FLT_MAX)
-                            };
+                    // read on the host: voice encoder lstm, conditioning encoder
+                    for (ggml_tensor * t = ggml_get_first_tensor(ctx_meta.get()); t; t = ggml_get_next_tensor(ctx_meta.get(), t)) {
+                        const std::string name = t->name;
+                        if (name.rfind("a.ve.", 0) == 0 || name.rfind("a.cenc.", 0) == 0) {
+                            model.cbx_tensors[name] = get_tensor(name);
                         }
                     }
                 } break;
-            case PROJECTOR_TYPE_GEMMA4UA:
+            case PROJECTOR_TYPE_SOPRANO:
                 {
-                    model.mm_input_proj_w = get_tensor(string_format(TN_A_MM_INP_PROJ, "weight"));
+                    auto & v = model.vocos;
+                    v.input_w       = get_tensor(string_format(TN_A_GEN_WAV_INPUT, "weight"));
+                    v.input_b       = get_tensor(string_format(TN_A_GEN_WAV_INPUT, "bias"));
+                    v.norm_w        = get_tensor(string_format(TN_A_GEN_WAV_NORM, "weight"));
+                    v.norm_b        = get_tensor(string_format(TN_A_GEN_WAV_NORM, "bias"));
+                    v.output_norm_w = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT_NORM, "weight"));
+                    v.output_norm_b = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT_NORM, "bias"));
+                    v.output_w      = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT, "weight"));
+                    v.output_b      = get_tensor(string_format(TN_A_GEN_WAV_OUTPUT, "bias"));
+                    v.blocks.resize(hparams.n_layer);
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & b = v.blocks[il];
+                        b.dwconv_w = get_tensor(string_format(TN_A_GEN_WAV_UP_DWCONV, il, "weight"));
+                        b.dwconv_b = get_tensor(string_format(TN_A_GEN_WAV_UP_DWCONV, il, "bias"));
+                        b.norm_w   = get_tensor(string_format(TN_A_GEN_WAV_UP_NORM, il, "weight"));
+                        b.norm_b   = get_tensor(string_format(TN_A_GEN_WAV_UP_NORM, il, "bias"));
+                        b.pw1_w    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW1, il, "weight"));
+                        b.pw1_b    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW1, il, "bias"));
+                        b.pw2_w    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW2, il, "weight"));
+                        b.pw2_b    = get_tensor(string_format(TN_A_GEN_WAV_UP_PW2, il, "bias"));
+                        b.gamma    = get_tensor(string_format(TN_A_GEN_WAV_UP_GAMMA, il));
+                    }
                 } break;
-            case PROJECTOR_TYPE_LFM2A:
-            case PROJECTOR_TYPE_D1OMNI_A:
+            case PROJECTOR_TYPE_VOXTRAL:
+            case PROJECTOR_TYPE_VOXTRAL_RT:
                 {
-                    for (int i : {0, 2, 3, 5, 6}) {
-                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
-                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
-                    }
-                    model.pre_encode_out_w    = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
-                    model.pre_encode_out_b    = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));
-
-                    model.mm_0_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "bias"));
+                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
+                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
+                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
+                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
+                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
+                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
+                } break;
+            case PROJECTOR_TYPE_MUSIC_FLAMINGO:
+                {
+                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
+                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
+                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
+                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
                     model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
                     model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
-                    model.mm_3_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "weight"));
-                    model.mm_3_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "bias"));
-
-                    // residual block after the projector: norm, down, up
-                    if (model.proj_type == PROJECTOR_TYPE_D1OMNI_A) {
-                        model.mm_4_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 4, "weight"));
-                        model.mm_4_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 4, "bias"));
-                        model.mm_5_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 5, "weight"));
-                        model.mm_5_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 5, "bias"));
-                        model.mm_6_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 6, "weight"));
-                        model.mm_6_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 6, "bias"));
-                    }
-
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-
-                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
-                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));
-                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
-                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
-                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
-                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"));
-                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
-                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"));
-
-                        layer.pos_bias_u = get_tensor(string_format(TN_POS_BIAS_U, prefix, il));
-                        layer.pos_bias_v = get_tensor(string_format(TN_POS_BIAS_V, prefix, il));
-
-                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
-                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));
-
-                        layer.linear_pos_w = get_tensor(string_format(TN_LINEAR_POS, prefix, il, "weight"));
-
-                        layer.conv_norm_w  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"));
-                        layer.conv_norm_b  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"));
-                        layer.conv_dw_w    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
-                        layer.conv_dw_b    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "bias"));
-                        layer.conv_pw1_w   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
-                        layer.conv_pw1_b   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"));
-                        layer.conv_pw2_w   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
-                        layer.conv_pw2_b   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"));
-                    }
+                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
+                    model.mm_2_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "bias"));
                 } break;
-            case PROJECTOR_TYPE_LFM3A:
+            case PROJECTOR_TYPE_INTERNVL:
                 {
-                    for (int i : {0, 2, 3, 5, 6}) {
-                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
-                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
-                    }
-                    model.pre_encode_out_w = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
-                    model.pre_encode_out_b = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));
-
-                    model.mm_0_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "bias"));
+                    model.mm_0_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "bias"));
+                    model.mm_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
+                    model.mm_1_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "bias"));
+                    model.mm_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
+                    model.mm_3_b = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "bias"));
+                } break;
+            case PROJECTOR_TYPE_NEMOTRON_V2_VL:
+            case PROJECTOR_TYPE_NEMOTRON_OMNI:
+                {
+                    model.mm_0_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
+                    model.mm_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
+                    model.mm_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
+                } break;
+            case PROJECTOR_TYPE_GLMA:
+                {
+                    model.conv1d_1_w = get_tensor(string_format(TN_CONV1D, 1, "weight"));
+                    model.conv1d_1_b = get_tensor(string_format(TN_CONV1D, 1, "bias"));
+                    model.conv1d_2_w = get_tensor(string_format(TN_CONV1D, 2, "weight"));
+                    model.conv1d_2_b = get_tensor(string_format(TN_CONV1D, 2, "bias"));
                     model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
                     model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
-                    model.mm_3_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "weight"));
-                    model.mm_3_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "bias"));
-
-                    if (!model.post_ln_w) {
-                        throw std::runtime_error(string_format("%s: missing tensor %s\n", __func__, string_format(TN_LN_POST, prefix, "weight").c_str()));
-                    }
-
-                    // each layer is either a short conv block or a complete attention block
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-                        layer.conv_pw1_w = get_tensor(string_format(TN_CONV_PW1, prefix, il, "weight"), false);
-                        layer.conv_dw_w  = get_tensor(string_format(TN_CONV_DW,  prefix, il, "weight"), false);
-                        layer.conv_pw2_w = get_tensor(string_format(TN_CONV_PW2, prefix, il, "weight"), false);
-                        const bool is_conv = layer.conv_pw1_w && layer.conv_dw_w && layer.conv_pw2_w;
-                        const bool is_attn = layer.q_w && layer.k_w && layer.v_w && layer.o_w && layer.q_norm && layer.k_norm;
-                        if (is_conv == is_attn) {
-                            throw std::runtime_error(string_format("%s: lfm3a layer %d is neither a complete conv nor a complete attention block\n", __func__, il));
-                        }
-                    }
+                    model.mm_2_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"));
+                    model.mm_2_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "bias"));
+                    model.mm_norm_pre_w = get_tensor(string_format(TN_MM_NORM_PRE, "weight"));
+                    model.mm_norm_pre_b = get_tensor(string_format(TN_MM_NORM_PRE, "bias"));
+                    model.mm_boi = get_tensor(string_format(TN_TOK_BOI));
+                    model.mm_eoi = get_tensor(string_format(TN_TOK_EOI));
                 } break;
-            case PROJECTOR_TYPE_PARAKEET:
+            case PROJECTOR_TYPE_LLAMA4:
                 {
-
-                    hparams.mel_filters = get_vector(TN_MEL_FILTERS);
-                    hparams.window      = get_vector(TN_WINDOW);
-
-                    // Subsampling layers (conv1d)
-                    for (int i : {0, 2, 3, 5, 6}) {
-                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
-                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
-                    }
-                    model.pre_encode_out_w = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
-                    model.pre_encode_out_b = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));
-
-                    // Projection layers
-                    model.mm_norm_pre_w = get_tensor(string_format(TN_MM_NORM_PRE, "weight"), false);
-                    model.mm_0_w        = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"), false);
-                    model.mm_1_w        = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"), false);
-
-                    // Encoder layers
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-
-                        // Attention (from shared above)
-
-                        // Relative position encoding
-                        layer.linear_pos_w = get_tensor(string_format(TN_LINEAR_POS, prefix, il, "weight"));
-                        layer.pos_bias_u   = get_tensor(string_format(TN_POS_BIAS_U, prefix, il));
-                        layer.pos_bias_v   = get_tensor(string_format(TN_POS_BIAS_V, prefix, il));
-
-                        // Convolution module
-                        layer.conv_pw1_w     = get_tensor(string_format(TN_CONV_PW1,       prefix, il, "weight"));
-                        layer.conv_pw1_b     = get_tensor(string_format(TN_CONV_PW1,       prefix, il, "bias"), false);
-                        layer.conv_dw_w      = get_tensor(string_format(TN_CONV_DW,        prefix, il, "weight"));
-                        layer.conv_dw_b      = get_tensor(string_format(TN_CONV_DW,        prefix, il, "bias"), false);
-                        layer.conv_norm_w    = get_tensor(string_format(TN_CONV_NORM,      prefix, il, "weight"));
-                        layer.conv_norm_b    = get_tensor(string_format(TN_CONV_NORM,      prefix, il, "bias"));
-                        layer.conv_norm_mean = get_tensor(string_format(TN_CONV_NORM_MEAN, prefix, il));
-                        layer.conv_norm_var  = get_tensor(string_format(TN_CONV_NORM_VAR,  prefix, il));
-                        layer.conv_pw2_w     = get_tensor(string_format(TN_CONV_PW2,       prefix, il, "weight"));
-                        layer.conv_pw2_b     = get_tensor(string_format(TN_CONV_PW2,       prefix, il, "bias"), false);
-
-                        // Feed-forward networks
-                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
-                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));
-
-                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
-                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
-                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
-                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"), false);
-                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
-                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"), false);
-
-                        // Layer norms
-                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
-                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));
-                    }
-
-                    model.mm_model_mlp_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
-                    model.mm_model_mlp_2_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
-                    model.mm_model_mlp_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
+                    model.mm_model_proj    = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
+                    model.mm_model_mlp_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
+                    model.mm_model_mlp_2_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 2, "weight"));
+                } break;
+            case PROJECTOR_TYPE_COGVLM:
+                {
+                    model.mm_model_proj     = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
+                    model.mm_post_fc_norm_w = get_tensor(string_format(TN_MM_POST_FC_NORM, "weight"));
+                    model.mm_post_fc_norm_b = get_tensor(string_format(TN_MM_POST_FC_NORM, "bias"));
+                    model.mm_h_to_4h_w      = get_tensor(string_format(TN_MM_H_TO_4H,      "weight"));
+                    model.mm_gate_w         = get_tensor(string_format(TN_MM_GATE,         "weight"));
+                    model.mm_4h_to_h_w      = get_tensor(string_format(TN_MM_4H_TO_H,      "weight"));
+                    model.mm_boi            = get_tensor(TN_TOK_BOI);
+                    model.mm_eoi            = get_tensor(TN_TOK_EOI);
                 } break;
-            case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+            case PROJECTOR_TYPE_HUNYUANVL:
                 {
-                    hparams.mel_filters = get_vector(TN_MEL_FILTERS);
-                    hparams.window      = get_vector(TN_WINDOW);
-
-                    // ASR expert: blocks, pre_ln and post_ln come from the generic loader
-                    model.inp_proj_w = get_tensor(string_format(TN_INP_PROJ, "weight"));
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-                        layer.q_norm_b = get_tensor(string_format(TN_ATTN_Q_NORM, prefix, il, "bias"));
-                        layer.k_norm_b = get_tensor(string_format(TN_ATTN_K_NORM, prefix, il, "bias"));
-                    }
-
-                    // speaker expert
-                    const char * sp = "a.spk";
-                    model.spk_inp_proj_w   = get_tensor(string_format(TN_A_SPK_INP_PROJ, "weight"));
-                    model.spk_pre_ln_w     = get_tensor(string_format(TN_LN_PRE,  sp, "weight"));
-                    model.spk_pre_ln_b     = get_tensor(string_format(TN_LN_PRE,  sp, "bias"));
-                    model.spk_post_ln_w    = get_tensor(string_format(TN_LN_POST, sp, "weight"));
-                    model.spk_post_ln_b    = get_tensor(string_format(TN_LN_POST, sp, "bias"));
-                    model.spk_layers.resize(hparams.spk_n_layer);
-                    for (int il = 0; il < hparams.spk_n_layer; ++il) {
-                        auto & layer = model.spk_layers[il];
-                        layer.ln_1_w    = get_tensor(string_format(TN_LN_1,        sp, il, "weight"));
-                        layer.ln_1_b    = get_tensor(string_format(TN_LN_1,        sp, il, "bias"));
-                        layer.qkv_w     = get_tensor(string_format(TN_ATTN_QKV,    sp, il, "weight"));
-                        layer.o_w       = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "weight"));
-                        layer.o_b       = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "bias"));
-                        layer.ln_2_w    = get_tensor(string_format(TN_LN_2,        sp, il, "weight"));
-                        layer.ln_2_b    = get_tensor(string_format(TN_LN_2,        sp, il, "bias"));
-                        layer.ff_up_w   = get_tensor(string_format(TN_FFN_UP,      sp, il, "weight"));
-                        layer.ff_up_b   = get_tensor(string_format(TN_FFN_UP,      sp, il, "bias"));
-                        layer.ff_down_w = get_tensor(string_format(TN_FFN_DOWN,    sp, il, "weight"));
-                        layer.ff_down_b = get_tensor(string_format(TN_FFN_DOWN,    sp, il, "bias"));
-                    }
-
-                    // Sortformer head, fusion
-                    model.spk_head_proj_w   = get_tensor(string_format(TN_A_SPK_HEAD, "proj",     "weight"));
-                    model.spk_head_proj_b   = get_tensor(string_format(TN_A_SPK_HEAD, "proj",     "bias"));
-                    model.spk_head_up_w     = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "weight"));
-                    model.spk_head_up_b     = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "bias"));
-                    model.spk_head_fc1_w    = get_tensor(string_format(TN_A_SPK_HEAD, "fc1",      "weight"));
-                    model.spk_head_fc1_b    = get_tensor(string_format(TN_A_SPK_HEAD, "fc1",      "bias"));
-                    model.spk_head_fc2_w    = get_tensor(string_format(TN_A_SPK_HEAD, "fc2",      "weight"));
-                    model.spk_head_fc2_b    = get_tensor(string_format(TN_A_SPK_HEAD, "fc2",      "bias"));
-                    model.fusion_asr_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "weight"));
-                    model.fusion_asr_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "bias"));
-                    model.fusion_spk_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "weight"));
-                    model.fusion_spk_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "bias"));
-                    model.fusion_spk_kernel = get_tensor(TN_A_FUSION_KERNEL);
-
-                    model.spk_sil_emb = get_vector(TN_A_SPK_SIL_EMB);
-
-                    // the head layouts and the widths the graph and the speaker state use must match the tensors
-                    hparams.spk_n_embd = model.spk_inp_proj_w->ne[1];
-                    const int64_t d_head     = hparams.n_head     > 0 ? hparams.n_embd     / hparams.n_head     : 0;
-                    const int64_t d_head_spk = hparams.spk_n_head > 0 ? hparams.spk_n_embd / hparams.spk_n_head : 0;
-                    const int64_t n_spk      = model.spk_head_fc2_w->ne[1];
-                    // the graph splits each fused QKV into three views of the layer width
-                    bool qkv_ok = true;
-                    for (const auto & layer : model.layers) {
-                        qkv_ok = qkv_ok && layer.qkv_w && layer.qkv_w->ne[0] == hparams.n_embd && layer.qkv_w->ne[1] == 3 * hparams.n_embd;
-                    }
-                    for (const auto & layer : model.spk_layers) {
-                        qkv_ok = qkv_ok && layer.qkv_w->ne[0] == hparams.spk_n_embd && layer.qkv_w->ne[1] == 3 * hparams.spk_n_embd;
-                    }
-                    if (!qkv_ok || model.inp_proj_w->ne[1] != hparams.n_embd || d_head <= 0 || d_head * hparams.n_head != hparams.n_embd ||
-                            (hparams.n_embd_head > 0 && hparams.n_embd_head != d_head) ||
-                            hparams.audio_rope_dims <= 0 || hparams.audio_rope_dims % 2 != 0 || hparams.audio_rope_dims > d_head ||
-                            d_head_spk <= 0 || d_head_spk % 2 != 0 || d_head_spk * hparams.spk_n_head != hparams.spk_n_embd ||
-                            (int64_t) model.spk_sil_emb.size() != hparams.spk_n_embd || n_spk <= 0 ||
-                            model.fusion_spk_kernel->ne[0] != n_spk || model.fusion_spk_kernel->ne[1] != hparams.n_embd ||
-                            hparams.mel_filters.size() != (size_t) hparams.n_mel_bins * (hparams.audio_n_fft / 2 + 1) ||
-                            hparams.window.size() != (size_t) hparams.audio_window_len) {
-                        throw std::runtime_error(string_format("%s: hparams do not match the tensor shapes\n", __func__));
-                    }
-
-                    // projection to the text model
-                    model.mm_fc_w = get_tensor(string_format(TN_MM_AUDIO_FC, "weight"));
-                    model.mm_fc_b = get_tensor(string_format(TN_MM_AUDIO_FC, "bias"));
+                    // proj.0 -> mm.0 (conv1), proj.2 -> mm.2 (conv2), mlp -> mm.model.fc (linear)
+                    model.mm_0_w            = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
+                    model.mm_0_b            = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
+                    model.mm_1_w            = get_tensor(string_format(TN_LLAVA_PROJ, 2, "weight"));
+                    model.mm_1_b            = get_tensor(string_format(TN_LLAVA_PROJ, 2, "bias"));
+                    model.mm_model_proj     = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
+                    model.mm_model_proj_b   = get_tensor(string_format(TN_MM_PROJECTOR, "bias"));
+                    model.mm_pre_norm_w     = get_tensor(string_format(TN_MM_PRE_NORM, "weight"));
+                    model.mm_post_norm_w    = get_tensor(string_format(TN_MM_POST_NORM, "weight"));
+                    model.mm_img_begin      = get_tensor(TN_MM_IMG_BEGIN);
+                    model.mm_img_end        = get_tensor(TN_MM_IMG_END);
+                    model.image_newline     = get_tensor(TN_IMAGE_NEWLINE);
+                    model.view_seperator    = get_tensor(TN_IMAGE_SEPERATOR, false);
                 } break;
-            case PROJECTOR_TYPE_GRANITE_SPEECH:
+            case PROJECTOR_TYPE_JANUS_PRO:
                 {
-                    model.inp_proj_w     = get_tensor(string_format(TN_INP_PROJ,    "weight"));
-                    model.inp_proj_b     = get_tensor(string_format(TN_INP_PROJ,    "bias"));
-                    model.ctc_out_w      = get_tensor(string_format(TN_CTC_OUT,     "weight"));
-                    model.ctc_out_b      = get_tensor(string_format(TN_CTC_OUT,     "bias"));
-                    model.ctc_out_mid_w  = get_tensor(string_format(TN_CTC_OUT_MID, "weight"));
-                    model.ctc_out_mid_b  = get_tensor(string_format(TN_CTC_OUT_MID, "bias"));
-
-                    // per-layer tensors not loaded by the generic loop above
-                    for (int il = 0; il < hparams.n_layer; ++il) {
-                        auto & layer = model.layers[il];
-
-                        layer.attn_rel_pos_emb = get_tensor(string_format(TN_ATTN_REL_POS_EMB, prefix, il));
-
-                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
-                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));
-
-                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
-                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
-                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
-                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"));
-                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
-                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"));
-
-                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
-                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));
-                        layer.conv_norm_w = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"));
-                        layer.conv_norm_b = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"));
-                        layer.conv_dw_w   = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
-                        layer.conv_pw1_w  = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
-                        layer.conv_pw1_b  = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"));
-                        layer.conv_pw2_w  = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
-                        layer.conv_pw2_b  = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"));
-                    }
-
-                    model.qf_proj_blocks.resize(1);
-                    auto & qf = model.qf_proj_blocks[0];
-                    qf.qf_proj_query    = get_tensor(string_format(TN_QF_PROJ_QUERY, prefix));
-                    qf.qf_proj_norm_w   = get_tensor(string_format(TN_QF_PROJ_NORM, prefix, "weight"));
-                    qf.qf_proj_norm_b   = get_tensor(string_format(TN_QF_PROJ_NORM, prefix, "bias"));
-                    qf.qf_proj_linear_w = get_tensor(string_format(TN_QF_PROJ_LINEAR, prefix, "weight"));
-                    qf.qf_proj_linear_b = get_tensor(string_format(TN_QF_PROJ_LINEAR, prefix, "bias"));
-
-                    const int n_proj_layers = 2;
-                    qf.qf_proj_layers.resize(n_proj_layers);
-                    for (int il = 0; il < n_proj_layers; ++il) {
-                        auto & pl = qf.qf_proj_layers[il];
-
-                        pl.q_w    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, il, "weight"));
-                        pl.q_b    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, il, "bias"));
-                        pl.k_w    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, il, "weight"));
-                        pl.k_b    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, il, "bias"));
-                        pl.v_w    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, il, "weight"));
-                        pl.v_b    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, il, "bias"));
-                        pl.o_w    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, il, "weight"));
-                        pl.o_b    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, il, "bias"));
-                        pl.ln_1_w = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, il, "weight"));
-                        pl.ln_1_b = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, il, "bias"));
-
-                        pl.cross_attn_q_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, il, "weight"));
-                        pl.cross_attn_q_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, il, "bias"));
-                        pl.cross_attn_k_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, il, "weight"));
-                        pl.cross_attn_k_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, il, "bias"));
-                        pl.cross_attn_v_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, il, "weight"));
-                        pl.cross_attn_v_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, il, "bias"));
-                        pl.cross_attn_o_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, il, "weight"));
-                        pl.cross_attn_o_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, il, "bias"));
-                        pl.cross_attn_norm_w = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, il, "weight"));
-                        pl.cross_attn_norm_b = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, il, "bias"));
-
-                        pl.ff_up_w   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, il, "weight"));
-                        pl.ff_up_b   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, il, "bias"));
-                        pl.ff_down_w = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, il, "weight"));
-                        pl.ff_down_b = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, il, "bias"));
-                        pl.ln_2_w    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, il, "weight"));
-                        pl.ln_2_b    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, il, "bias"));
-                    }
+                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
+                    model.mm_1_w = get_tensor(string_format(TN_LLAVA_PROJ, 1, "weight"));
+                    model.mm_1_b = get_tensor(string_format(TN_LLAVA_PROJ, 1, "bias"));
                 } break;
-            case PROJECTOR_TYPE_GRANITE4_VISION:
+            case PROJECTOR_TYPE_PHI4:
                 {
-                    // image_newline lives at the top-level.
-                    model.image_newline = get_tensor(TN_IMAGE_NEWLINE);
-
-                    // Load separate layerwise and spatial projector tensors
-                    const auto projector_count = hparams.feature_layers.size();
-                    model.qf_proj_blocks.resize(projector_count);
-                    for (size_t bid = 0; bid < projector_count; ++bid) {
-                        auto & b = model.qf_proj_blocks[bid];
-
-                        // non-layerwise tensors
-                        b.qf_proj_img_pos     = get_tensor(string_format(TN_MULTI_PROJ_IMG_POS,           bid));
-                        b.qf_proj_query       = get_tensor(string_format(TN_MULTI_PROJ_QUERY,     prefix, bid));
-                        b.qf_proj_linear_w    = get_tensor(string_format(TN_MULTI_PROJ_LINEAR,    prefix, bid, "weight"));
-                        b.qf_proj_linear_b    = get_tensor(string_format(TN_MULTI_PROJ_LINEAR,    prefix, bid, "bias"));
-                        b.qf_proj_norm_w      = get_tensor(string_format(TN_MULTI_PROJ_NORM,      prefix, bid, "weight"));
-                        b.qf_proj_norm_b      = get_tensor(string_format(TN_MULTI_PROJ_NORM,      prefix, bid, "bias"));
-                        b.qf_proj_post_norm_w = get_tensor(string_format(TN_MULTI_PROJ_POST_NORM, prefix, bid, "weight"));
-                        b.qf_proj_post_norm_b = get_tensor(string_format(TN_MULTI_PROJ_POST_NORM, prefix, bid, "bias"));
+                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
+                    model.mm_2_w = get_tensor(string_format(TN_LLAVA_PROJ, 2, "weight"));
+                    model.mm_2_b = get_tensor(string_format(TN_LLAVA_PROJ, 2, "bias"));
+                } break;
+            case PROJECTOR_TYPE_DEEPSEEKOCR:
+            case PROJECTOR_TYPE_DEEPSEEKOCR2:
+                {
+                    model.pos_embed          = get_tensor(string_format(TN_SAM_POS_EMBD,   "weight"));
+                    model.patch_embed_proj_w = get_tensor(string_format(TN_SAM_PATCH_EMBD, "weight"));
+                    model.patch_embed_proj_b = get_tensor(string_format(TN_SAM_PATCH_EMBD, "bias"));
+                    model.n_sam_layers = hparams.sam_n_layer;
+                    model.sam_layers.resize(model.n_sam_layers);
+                    for (int il = 0; il < model.n_sam_layers; ++il) {
+                        auto & layer    = model.sam_layers[il];
+                        layer.qkv_w     = get_tensor(string_format(TN_SAM_ATTN_QKV, il, "weight"));
+                        layer.qkv_b     = get_tensor(string_format(TN_SAM_ATTN_QKV, il, "bias"));
+                        layer.o_w       = get_tensor(string_format(TN_SAM_ATTN_OUT, il, "weight"));
+                        layer.o_b       = get_tensor(string_format(TN_SAM_ATTN_OUT, il, "bias"));
+                        layer.ln_1_w    = get_tensor(string_format(TN_SAM_PRE_NORM, il, "weight"));
+                        layer.ln_1_b    = get_tensor(string_format(TN_SAM_PRE_NORM, il, "bias"));
+                        layer.ln_2_w    = get_tensor(string_format(TN_SAM_POST_NORM, il, "weight"));
+                        layer.ln_2_b    = get_tensor(string_format(TN_SAM_POST_NORM, il, "bias"));
+                        layer.rel_pos_h = get_tensor(string_format(TN_SAM_ATTN_POS_H, il, "weight"));
+                        layer.rel_pos_w = get_tensor(string_format(TN_SAM_ATTN_POS_W, il, "weight"));
+                        layer.ff_up_w   = get_tensor(string_format(TN_SAM_FFN_UP, il, "weight"));
+                        layer.ff_up_b   = get_tensor(string_format(TN_SAM_FFN_UP, il, "bias"));
+                        layer.ff_down_w = get_tensor(string_format(TN_SAM_FFN_DOWN, il, "weight"));
+                        layer.ff_down_b = get_tensor(string_format(TN_SAM_FFN_DOWN, il, "bias"));
+                    }
+                    model.neck_0_w       = get_tensor(string_format(TN_SAM_NECK, 0, "weight"));
+                    model.neck_1_b       = get_tensor(string_format(TN_SAM_NECK, 1, "bias"));
+                    model.neck_1_w       = get_tensor(string_format(TN_SAM_NECK, 1, "weight"));
+                    model.neck_2_w       = get_tensor(string_format(TN_SAM_NECK, 2, "weight"));
+                    model.neck_3_b       = get_tensor(string_format(TN_SAM_NECK, 3, "bias"));
+                    model.neck_3_w       = get_tensor(string_format(TN_SAM_NECK, 3, "weight"));
+                    model.net_2          = get_tensor(string_format(TN_SAM_NET, 2, "weight"));
+                    model.net_3          = get_tensor(string_format(TN_SAM_NET, 3, "weight"));
+                    model.image_newline  = get_tensor(TN_IMAGE_NEWLINE, false);
+                    model.view_seperator = get_tensor(TN_IMAGE_SEPERATOR);
+                    model.mm_fc_w        = get_tensor(string_format(TN_MM_PROJECTOR, "weight"));
+                    model.mm_fc_b        = get_tensor(string_format(TN_MM_PROJECTOR, "bias"));
+                    model.resample_query_768  = get_tensor(string_format(TN_RESMPL_QUERY, 768, "weight"), false);
+                    model.resample_query_1024 = get_tensor(string_format(TN_RESMPL_QUERY, 1024, "weight"), false);
+                 } break;
+            case PROJECTOR_TYPE_GEMMA4A:
+                {
+                    for (int i = 0; i < 2; i++) {
+                        model.sscp_conv_w[i] = get_tensor(string_format(TN_A_CONV1D, i, "weight"));
+                        model.sscp_conv_b[i] = get_tensor(string_format(TN_A_CONV1D, i, "bias"), false);
+                        model.sscp_norm_w[i] = get_tensor(string_format(TN_A_CONV1D_NORM, i, "weight"), false);
+                    }
+                    model.sscp_inp_proj_w = get_tensor(string_format(TN_A_INP_PROJ, "weight"));
+                    model.sscp_inp_proj_b = get_tensor(string_format(TN_A_INP_PROJ, "bias"), false);
+                    model.audio_out_proj_w = get_tensor(string_format(TN_A_OUT_PROJ, "weight"), false);
+                    model.audio_out_proj_b = get_tensor(string_format(TN_A_OUT_PROJ, "bias"), false);
+                    // audio multimodal embedder (mm.a.* namespace, not mm.*)
+                    model.mm_soft_emb_norm_w = get_tensor(string_format(TN_A_MM_SOFT_EMB_N, "weight"), false);
+                    model.mm_input_proj_w    = get_tensor(string_format(TN_A_MM_INP_PROJ, "weight"), false);

-                        // laywerwise tensors
-                        // NOTE: If any model uses multi-layer qformers, this will need to change
-                        b.qf_proj_layers.resize(1);
-                        auto & pl = b.qf_proj_layers[0];
+                    // Per-layer tensors NOT loaded by the generic loop above
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];

-                        pl.q_w    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, bid, "weight"));
-                        pl.q_b    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, bid, "bias"));
-                        pl.k_w    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, bid, "weight"));
-                        pl.k_b    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, bid, "bias"));
-                        pl.v_w    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, bid, "weight"));
-                        pl.v_b    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, bid, "bias"));
-                        pl.o_w    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, bid, "weight"));
-                        pl.o_b    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, bid, "bias"));
-                        pl.ln_1_w = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, bid, "weight"));
-                        pl.ln_1_b = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, bid, "bias"));
+                        // Gemma4 audio conformer-specific tensors
+                        layer.ff_norm_w        = get_tensor(string_format(TN_FFN_NORM, prefix, il, "weight"));
+                        layer.attn_pre_norm_w  = get_tensor(string_format(TN_A_ATTN_PRE_NORM, prefix, il, "weight"), false);
+                        layer.per_dim_scale_w  = get_tensor(string_format(TN_A_PER_DIM_SCALE, prefix, il, "weight"), false);
+                        layer.per_dim_k_scale_w = get_tensor(string_format(TN_A_PER_DIM_K_SCALE, prefix, il, "weight"), false);
+                        layer.attn_k_rel_w     = get_tensor(string_format(TN_A_ATTN_K_REL, prefix, il, "weight"), false);

-                        pl.cross_attn_q_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, bid, "weight"));
-                        pl.cross_attn_q_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, bid, "bias"));
-                        pl.cross_attn_k_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, bid, "weight"));
-                        pl.cross_attn_k_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, bid, "bias"));
-                        pl.cross_attn_v_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, bid, "weight"));
-                        pl.cross_attn_v_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, bid, "bias"));
-                        pl.cross_attn_o_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, bid, "weight"));
-                        pl.cross_attn_o_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, bid, "bias"));
-                        pl.cross_attn_norm_w = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, bid, "weight"));
-                        pl.cross_attn_norm_b = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, bid, "bias"));
+                        // Convolution module
+                        // Note: conv_norm / norm_conv are swapped in GGUF due to
+                        // upstream tensor_mapping.py, so we load them in reverse order
+                        layer.norm_conv_w  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"), false);
+                        layer.norm_conv_b  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"), false);
+                        layer.conv_pw1_w   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
+                        layer.conv_pw1_b   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"), false);
+                        layer.conv_dw_w    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
+                        layer.conv_dw_b    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "bias"), false);
+                        layer.conv_norm_w  = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"), false);
+                        layer.conv_norm_b  = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"), false);
+                        layer.conv_pw2_w   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
+                        layer.conv_pw2_b   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"), false);

-                        pl.ff_up_w   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, bid, "weight"));
-                        pl.ff_up_b   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, bid, "bias"));
-                        pl.ff_down_w = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, bid, "weight"));
-                        pl.ff_down_b = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, bid, "bias"));
-                        pl.ln_2_w    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, bid, "weight"));
-                        pl.ln_2_b    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, bid, "bias"));
+                        // FFN2 (second half-step)
+                        layer.ff_norm_1_w      = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
+                        layer.ff_up_1_w        = get_tensor(string_format(TN_FFN_UP_1, prefix, il, "weight"));
+                        layer.ff_up_1_b        = get_tensor(string_format(TN_FFN_UP_1, prefix, il, "bias"), false);
+                        layer.ff_down_1_w      = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
+                        layer.ff_down_1_b      = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"), false);
+                        layer.ff_post_norm_1_w = get_tensor(string_format(TN_A_FFN_POST_NORM_1, prefix, il, "weight"), false);
                     }

+                    // Load clamp info for ClippableLinear AFTER all tensors are loaded
+                    for (auto * tensor : tensors_to_load) {
+                        std::string name = tensor->name;
+                        if (string_ends_with(name, ".weight")) {
+                            std::string name_inp_max = name;
+                            std::string name_inp_min = name;
+                            std::string name_out_max = name;
+                            std::string name_out_min = name;
+                            string_replace_all(name_inp_max, ".weight", ".input_max");
+                            string_replace_all(name_inp_min, ".weight", ".input_min");
+                            string_replace_all(name_out_max, ".weight", ".output_max");
+                            string_replace_all(name_out_min, ".weight", ".output_min");
+                            model.clamp_info_map[name] = {
+                                get_scalar(name_inp_max, FLT_MAX),
+                                get_scalar(name_inp_min, -FLT_MAX),
+                                get_scalar(name_out_max, FLT_MAX),
+                                get_scalar(name_out_min, -FLT_MAX)
+                            };
+                        }
+                    }
                 } break;
-            case PROJECTOR_TYPE_SENSENOVA_U1:
+            case PROJECTOR_TYPE_GEMMA4UA:
                 {
-                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
-                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
+                    model.mm_input_proj_w = get_tensor(string_format(TN_A_MM_INP_PROJ, "weight"));
                 } break;
-            default:
-                GGML_ASSERT(false && "unknown projector type");
-        }
-
-        // load data
-        {
-            std::vector<uint8_t> read_buf;
-
-            // start loading event
-            if (progress_callback){
-                progress_callback(0.0, progress_callback_user_data);
-            }
-
-            // compute total tensor data size for progress reporting
-            size_t total_data_size = 0;
-            for (auto & t : tensors_to_load) {
-                total_data_size += ggml_nbytes(t);
-            }
-
-            // alloc memory and offload data
-            ggml_backend_buffer_type_t buft = ggml_backend_get_default_buffer_type(ctx_clip.backend);
-            ctx_clip.buf.reset(ggml_backend_alloc_ctx_tensors_from_buft(ctx_clip.ctx_data.get(), buft));
-            ggml_backend_buffer_set_usage(ctx_clip.buf.get(), GGML_BACKEND_BUFFER_USAGE_WEIGHTS);
-            // read the weight from file
-            if (!ctx_clip.no_alloc) {
-                size_t data_loaded = 0;
-                for (auto & t : tensors_to_load) {
-                    ggml_tensor * cur = ggml_get_tensor(ctx_clip.ctx_data.get(), t->name);
-                    GGML_ASSERT(cur && "tensor not found in ctx_data");
-                    auto it_off = tensor_offset.find(t->name);
-                    GGML_ASSERT(it_off != tensor_offset.end() && "no offset for tensor");
-                    const size_t offset = it_off->second;
-                    fin.seekg(offset, std::ios::beg);
-                    if (!fin) {
-                        throw std::runtime_error(string_format("%s: failed to seek for tensor %s\n", __func__, t->name));
-                    }
-                    size_t num_bytes = ggml_nbytes(cur);
-                    if (ggml_backend_buft_is_host(buft)) {
-                        // for the CPU and Metal backend, we can read directly into the tensor
-                        fin.read(reinterpret_cast<char *>(cur->data), num_bytes);
-                    } else {
-                        // read into a temporary buffer first, then copy to device memory
-                        read_buf.resize(num_bytes);
-                        fin.read(reinterpret_cast<char *>(read_buf.data()), num_bytes);
-                        ggml_backend_tensor_set(cur, read_buf.data(), 0, num_bytes);
-                    }
-                    data_loaded += num_bytes;
-                    if (progress_callback && total_data_size > 0) {
-                        const float progress = (float)data_loaded / (float)total_data_size;
-                        if (!progress_callback(progress, progress_callback_user_data)) {
-                            throw std::runtime_error(string_format("%s: model loading cancelled by progress_callback\n", __func__));
-                        }
+            case PROJECTOR_TYPE_LFM2A:
+            case PROJECTOR_TYPE_D1OMNI_A:
+                {
+                    for (int i : {0, 2, 3, 5, 6}) {
+                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
+                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
                     }
-                }
-                LOG_DBG("%s: loaded %zu tensors from %s\n", __func__, tensors_to_load.size(), fname.c_str());
-            } else {
-                LOG_DBG("%s: no_alloc is set, skipping tensor data loading (%zu tensors)\n", __func__, tensors_to_load.size());
-            }
-            fin.close();
-        }
-
-    }
-
-    struct support_info_op {
-        ggml_tensor * op;
-
-        // true if the op runs on the accelerated ctx_clip.backend
-        bool is_accel = true;
-    };
-
-    struct support_info_graph {
-        // whether the clip_ctx.backend supports flash attention
-        bool fattn = true;
-        ggml_tensor * fattn_op = nullptr; // for debugging
-
-        std::vector<support_info_op> ops;
-    };
-
-    static clip_image_f32_batch get_dummy_batch(clip_ctx & ctx_clip) {
-        // create a fake batch
-        const auto & hparams = ctx_clip.model.hparams;
-        clip_image_f32_batch batch;
-        clip_image_f32 img;
-        if (ctx_clip.model.modality == CLIP_MODALITY_VISION) {
-            const int sz = hparams.warmup_image_size;
-            img.set_size({sz, sz}, false, false);
-            LOG_INF("%s: warmup with image size = %d x %d\n", __func__, sz, sz);
-        } else {
-            // GEMMA4UA uses n_mel_bins as a raw-waveform frame size (640), not a mel-bin count,
-            // so the [1, 256] bound only applies to FFT-based models.
-            const bool fft_based = ctx_clip.model.proj_type != PROJECTOR_TYPE_GEMMA4UA;
-            if (hparams.n_mel_bins <= 0 || (fft_based && hparams.n_mel_bins > 256)) {
-                throw std::runtime_error(string_format("%s: invalid n_mel_bins (%d), must be in [1, 256]\n", __func__, hparams.n_mel_bins));
-            }
-            img.set_size({hparams.warmup_audio_size, hparams.n_mel_bins}, false, false);
-            LOG_INF("%s: warmup with audio size = %d\n", __func__, hparams.warmup_audio_size);
-        }
-        batch.entries.push_back(img);
-        return batch;
-    }
-
-    static void init_ctx(clip_ctx & ctx_clip) {
-        ctx_clip.buf_compute_meta.resize(ctx_clip.max_nodes * ggml_tensor_overhead() + ggml_graph_overhead());
-
-        // check batching support
-        auto batch = get_dummy_batch(ctx_clip);
-        auto builder = clip_get_graph_builder(&ctx_clip, batch);
-        ctx_clip.support_batch = builder->support_batch();
-    }
-
-    static void warmup(clip_ctx & ctx_clip) {
-        auto batch = get_dummy_batch(ctx_clip);
-        warmup(ctx_clip, batch);
-    }
-
-    static void warmup(clip_ctx & ctx_clip, const clip_image_f32_batch & batch) {
-        support_info_graph info;
-
-        if (ctx_clip.flash_attn_type == CLIP_FLASH_ATTN_TYPE_AUTO) {
-            // try to enable flash attention to see if it's supported
-            ctx_clip.flash_attn_type = CLIP_FLASH_ATTN_TYPE_ENABLED;
-            info = reserve_compute_meta(ctx_clip, batch);
-            if (!info.fattn && info.fattn_op) {
-                auto op = info.fattn_op;
-                LOG_WRN("%s: *****************************************************************\n", __func__);
-                LOG_WRN("%s: WARNING: flash attention not supported by %s, memory usage will increase\n", __func__, ggml_backend_name(ctx_clip.backend));
-                LOG_WRN("%s: op params: \n", __func__);
-                static auto print_shape = [](const char * fn, const char * name, ggml_tensor * t) {
-                    LOG_WRN("%s:   %s: type = %s, ne = [%d %d %d %d], nb = [%d %d %d %d]\n", fn,
-                            name, ggml_type_name(t->type),
-                            t->ne[0], t->ne[1], t->ne[2], t->ne[3],
-                            t->nb[0], t->nb[1], t->nb[2], t->nb[3]);
-                };
-                print_shape(__func__, " dst", op);
-                print_shape(__func__, "src0", op->src[0]);
-                print_shape(__func__, "src1", op->src[1]);
-                print_shape(__func__, "src2", op->src[2]);
-                LOG_WRN("%s: please report this on github as an issue\n", __func__);
-                LOG_WRN("%s: *****************************************************************\n", __func__);
-                ctx_clip.flash_attn_type = CLIP_FLASH_ATTN_TYPE_DISABLED;
-                reserve_compute_meta(ctx_clip, batch);
-            }
-        } else {
-            info = reserve_compute_meta(ctx_clip, batch);
-            if (!info.fattn && ctx_clip.flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) {
-                LOG_WRN("%s: flash attention is not supported by the current backend; falling back to CPU (performance will be degraded)\n", __func__);
-            }
-        }
-
-        ctx_clip.is_allocated = true; // mark buffers as allocated
-
-        LOG_INF("%s: flash attention is %s\n", __func__,
-            (ctx_clip.flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) ? "enabled" : "disabled");
-
-        // print ops that are not supported by the GPU backend (if there is one)
-        if (ctx_clip.backend && ctx_clip.backend != ctx_clip.backend_cpu) {
-            std::vector<support_info_op> unsupported_ops;
-            for (const auto & op : info.ops) {
-                if (!op.is_accel) {
-                    unsupported_ops.push_back(op);
-                }
-            }
-            if (!unsupported_ops.empty()) {
-                LOG_WRN("%s: *****************************************************************\n", __func__);
-                LOG_WRN("%s: WARNING: the CLIP graph uses unsupported operators by the backend\n", __func__);
-                LOG_WRN("%s:          the performance will be suboptimal                      \n", __func__);
-                LOG_WRN("%s:          list of unsupported ops (backend=%s):\n", __func__, ggml_backend_name(ctx_clip.backend));
-                for (const auto & op : unsupported_ops) {
-                    LOG_WRN("%s: %16s: type = %s, ne = [%d %d %d %d]\n", __func__,
-                            ggml_op_name(op.op->op),
-                            ggml_type_name(op.op->type),
-                            op.op->ne[0], op.op->ne[1], op.op->ne[2], op.op->ne[3]);
-                }
-                LOG_WRN("%s: flash attention is %s\n", __func__,
-                    (ctx_clip.flash_attn_type == CLIP_FLASH_ATTN_TYPE_ENABLED) ? "enabled" : "disabled");
-                LOG_WRN("%s: please report this on github as an issue\n", __func__);
-                LOG_WRN("%s: ref: https://github.com/ggml-org/llama.cpp/pull/16837#issuecomment-3461676118\n", __func__);
-                LOG_WRN("%s: *****************************************************************\n", __func__);
-            }
-        }
-    }
-
-    // only initialize backend buffers, but do not allocate them yet
-    static support_info_graph reserve_compute_meta(clip_ctx & ctx_clip, const clip_image_f32_batch & batch) {
-        ggml_cgraph * gf = clip_get_graph_builder(&ctx_clip, batch)->build();
-        ggml_backend_sched_reserve(ctx_clip.sched.get(), gf);
-
-        ctx_clip.mem_compute.clear();
-        for (size_t i = 0; i < ctx_clip.backend_ptrs.size(); ++i) {
-            ggml_backend_t backend = ctx_clip.backend_ptrs[i];
-            ggml_backend_buffer_type_t buft = ctx_clip.backend_buft[i];
-            size_t size = ggml_backend_sched_get_buffer_size(ctx_clip.sched.get(), backend);
-            if (size > 1) {
-                LOG_INF("%s: %10s compute buffer size = %8.2f MiB\n", __func__,
-                        ggml_backend_buft_name(buft),
-                        size / 1024.0 / 1024.0);
-            }
-            ctx_clip.mem_compute[ggml_backend_get_device(backend)] += size;
-        }
-
-        const int n_splits = ggml_backend_sched_get_n_splits(ctx_clip.sched.get());
-        const int n_nodes  = ggml_graph_n_nodes(gf);
-
-        LOG_INF("%s: graph splits = %d, nodes = %d\n", __func__,  n_splits, n_nodes);
-
-        support_info_graph res {
-            /*.fattn    = */ true,
-            /*.fattn_op = */ nullptr,
-            /*.ops      = */ {},
-        };
-
-        // check op support
-        for (int i = 0; i < ggml_graph_n_nodes(gf); i++) {
-            ggml_tensor * node = ggml_graph_node(gf, i);
-            res.ops.push_back({node, true});
-            if (!ggml_backend_supports_op(ctx_clip.backend, node)) {
-                res.ops.back().is_accel = false;
-                if (node->op == GGML_OP_FLASH_ATTN_EXT) {
-                    res.fattn    = false;
-                    res.fattn_op = node;
-                }
-            }
-        }
-
-        return res;
-    }
-
-    void get_bool(const std::string & key, bool & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        output = gguf_get_val_bool(ctx_gguf.get(), i);
-    }
-
-    void get_i32(const std::string & key, int & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        output = gguf_get_val_i32(ctx_gguf.get(), i);
-    }
+                    model.pre_encode_out_w    = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
+                    model.pre_encode_out_b    = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));

-    void get_u32(const std::string & key, int & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        const uint32_t val = gguf_get_val_u32(ctx_gguf.get(), i);
-        // sanity check
-        if (val > (uint32_t) INT32_MAX) {
-            throw std::runtime_error(string_format("%s: value %u for key '%s' exceeds INT32_MAX\n",
-                __func__, val, key.c_str()));
-        }
-        output = (int) val;
-    }
+                    model.mm_0_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "bias"));
+                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
+                    model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
+                    model.mm_3_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "weight"));
+                    model.mm_3_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "bias"));

-    void get_f32(const std::string & key, float & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        output = gguf_get_val_f32(ctx_gguf.get(), i);
-    }
+                    // residual block after the projector: norm, down, up
+                    if (model.proj_type == PROJECTOR_TYPE_D1OMNI_A) {
+                        model.mm_4_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 4, "weight"));
+                        model.mm_4_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 4, "bias"));
+                        model.mm_5_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 5, "weight"));
+                        model.mm_5_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 5, "bias"));
+                        model.mm_6_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 6, "weight"));
+                        model.mm_6_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 6, "bias"));
+                    }

-    void get_arr_f32(const std::string & key, std::vector<float> & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        if (gguf_get_kv_type(ctx_gguf.get(), i) != GGUF_TYPE_ARRAY) {
-            throw std::runtime_error(string_format("%s: key '%s' is not an array\n", __func__, key.c_str()));
-        }
-        const auto type = gguf_get_arr_type(ctx_gguf.get(), i);
-        if (type != GGUF_TYPE_FLOAT32) {
-            throw std::runtime_error(string_format("%s: array '%s' has type %d, expected %d (GGUF_TYPE_FLOAT32)\n", __func__, key.c_str(), type, GGUF_TYPE_FLOAT32));
-        }
-        const size_t n = gguf_get_arr_n(ctx_gguf.get(), i);
-        if (n > (size_t) std::numeric_limits<int>::max()) {
-            throw std::runtime_error(string_format("%s: array '%s' is too large (%zu elements)\n", __func__, key.c_str(), n));
-        }
-        output.resize(n);
-        const float * values = (const float *)gguf_get_arr_data(ctx_gguf.get(), i);
-        for (size_t j = 0; j < n; ++j) {
-            output[j] = values[j];
-        }
-    }
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];

-    void get_string(const std::string & key, std::string & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        output = std::string(gguf_get_val_str(ctx_gguf.get(), i));
-    }
+                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
+                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));
+                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
+                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
+                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
+                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"));
+                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
+                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"));

-    void get_arr_int(const std::string & key, std::vector<int> & output, bool required = true) const {
-        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
-        if (i < 0) {
-            if (required) {
-                throw std::runtime_error("Key not found: " + key);
-            }
-            return;
-        }
-        if (gguf_get_kv_type(ctx_gguf.get(), i) != GGUF_TYPE_ARRAY) {
-            throw std::runtime_error(string_format("%s: key '%s' is not an array\n", __func__, key.c_str()));
-        }
-        const auto type = gguf_get_arr_type(ctx_gguf.get(), i);
-        if (type != GGUF_TYPE_INT32) {
-            throw std::runtime_error(string_format("%s: array '%s' has type %d, expected %d (GGUF_TYPE_INT32)\n", __func__, key.c_str(), type, GGUF_TYPE_INT32));
-        }
-        const size_t n = gguf_get_arr_n(ctx_gguf.get(), i);
-        if (n > (size_t) std::numeric_limits<int>::max()) {
-            throw std::runtime_error(string_format("%s: array '%s' is too large (%zu elements)\n", __func__, key.c_str(), n));
-        }
-        output.resize(n);
-        const int32_t * values = (const int32_t *)gguf_get_arr_data(ctx_gguf.get(), i);
-        for (size_t j = 0; j < n; ++j) {
-            output[j] = values[j];
-        }
-    }
+                        layer.pos_bias_u = get_tensor(string_format(TN_POS_BIAS_U, prefix, il));
+                        layer.pos_bias_v = get_tensor(string_format(TN_POS_BIAS_V, prefix, il));

-    static void set_llava_uhd_res_candidates(clip_model & model, const int max_patches_per_side) {
-        auto & hparams = model.hparams;
-        for (int x = 1; x <= max_patches_per_side; x++) {
-            for (int y = 1; y <= max_patches_per_side; y++) {
-                if (x == 1 && y == 1) {
-                    continue; // skip the first point
-                }
-                hparams.image_res_candidates.push_back(clip_image_size{
-                    x*hparams.image_size,
-                    y*hparams.image_size,
-                });
-            }
-        }
-    }
+                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
+                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));

-    static void set_internvl_dhr_res_candidates(clip_model & model) {
-        auto & hparams = model.hparams;
-        int min_num = hparams.preproc_min_tiles;
-        int max_num = hparams.preproc_max_tiles;
-        if (min_num < 1) {
-           return; // avoid  divide by 0
-        }
-        for (int a = min_num; a <= max_num; ++a) {
-            int b_lo = (min_num + a - 1) / a;
-            int b_hi = max_num / a;
-            b_lo = std::max(b_lo, min_num);
-            b_hi = std::min(b_hi, max_num);
-            for (int b = b_lo; b <= b_hi; ++b) {
-                hparams.image_res_candidates.push_back(clip_image_size {
-                    a*hparams.image_size,
-                    b*hparams.image_size,
-                });
-            }
-        }
-    }
-};
+                        layer.linear_pos_w = get_tensor(string_format(TN_LINEAR_POS, prefix, il, "weight"));

-struct clip_init_result clip_init(const char * fname, struct clip_context_params ctx_params) {
-    clip_ctx * ctx_vision = nullptr;
-    clip_ctx * ctx_audio = nullptr;
-    clip_ctx * ctx_gen_audio = nullptr;
-
-    std::shared_ptr<ggml_threadpool> threadpool;
-
-    try {
-        // no_alloc only measures memory, nothing is computed
-        threadpool = clip_threadpool_init(ctx_params.no_alloc ? 1 : ctx_params.n_threads);
-
-        clip_model_loader loader(fname,
-            /* skip_tensors */ false,
-            ctx_params.progress_callback,
-            ctx_params.progress_callback_user_data);
-        bool skip_audio = false;
-
-        if (loader.has_vision) {
-            ctx_vision = new clip_ctx(ctx_params, threadpool);
-            loader.load_hparams(ctx_vision->model, CLIP_MODALITY_VISION);
-            loader.load_tensors(*ctx_vision);
-            loader.init_ctx(*ctx_vision);
-            if (ctx_params.warmup) {
-                loader.warmup(*ctx_vision);
-            }
+                        layer.conv_norm_w  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"));
+                        layer.conv_norm_b  = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"));
+                        layer.conv_dw_w    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
+                        layer.conv_dw_b    = get_tensor(string_format(TN_CONV_DW,   prefix, il, "bias"));
+                        layer.conv_pw1_w   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
+                        layer.conv_pw1_b   = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"));
+                        layer.conv_pw2_w   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
+                        layer.conv_pw2_b   = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"));
+                    }
+                } break;
+            case PROJECTOR_TYPE_LFM3A:
+                {
+                    for (int i : {0, 2, 3, 5, 6}) {
+                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
+                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
+                    }
+                    model.pre_encode_out_w = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
+                    model.pre_encode_out_b = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));

-            // TODO: we don't support audio for Gemma 3N, but GGUF contains audio tensors
-            // we can remove this check when we implement audio support for Gemma 3N
-            skip_audio = ctx_vision->model.proj_type == PROJECTOR_TYPE_GEMMA3NV;
-        }
+                    model.mm_0_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 0, "bias"));
+                    model.mm_1_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"));
+                    model.mm_1_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "bias"));
+                    model.mm_3_w = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "weight"));
+                    model.mm_3_b = get_tensor(string_format(TN_MM_AUDIO_MLP, 3, "bias"));

-        if (loader.has_audio && !skip_audio) {
-            ctx_audio = new clip_ctx(ctx_params, threadpool);
-            loader.load_hparams(ctx_audio->model, CLIP_MODALITY_AUDIO);
-            loader.load_tensors(*ctx_audio);
-            loader.init_ctx(*ctx_audio);
-            if (ctx_params.warmup) {
-                loader.warmup(*ctx_audio);
-            }
-        }
+                    if (!model.post_ln_w) {
+                        throw std::runtime_error(string_format("%s: missing tensor %s\n", __func__, string_format(TN_LN_POST, prefix, "weight").c_str()));
+                    }

-        if (loader.has_gen_audio) {
-            ctx_gen_audio = new clip_ctx(ctx_params, threadpool);
-            loader.load_hparams(ctx_gen_audio->model, CLIP_MODALITY_GEN_AUDIO);
-            // TODO: CUDA has no flash attention kernel for the depthformer head dim 32, enable it once it does
-            if (ctx_gen_audio->model.proj_type == PROJECTOR_TYPE_LFM3A_GEN && ctx_gen_audio->flash_attn_type == CLIP_FLASH_ATTN_TYPE_AUTO) {
-                ctx_gen_audio->flash_attn_type = CLIP_FLASH_ATTN_TYPE_DISABLED;
-            }
-            loader.load_tensors(*ctx_gen_audio);
-            if (ctx_gen_audio->model.proj_type == PROJECTOR_TYPE_CHATTERBOX) {
-                // the classic cfm solver unrolls up to 10 steps x 2 cfg estimator evaluations in one graph
-                ctx_gen_audio->max_nodes = 65536;
-                ctx_gen_audio->sched.reset(
-                    ggml_backend_sched_new(ctx_gen_audio->backend_ptrs.data(), ctx_gen_audio->backend_buft.data(),
-                                           ctx_gen_audio->backend_ptrs.size(), ctx_gen_audio->max_nodes, false, true));
-                if (ctx_params.cb_eval != nullptr) {
-                    ggml_backend_sched_set_eval_callback(ctx_gen_audio->sched.get(), ctx_params.cb_eval, ctx_params.cb_eval_user_data);
-                }
-            }
-            // TODO: fix warmup
-            ctx_gen_audio->buf_compute_meta.resize(ctx_gen_audio->max_nodes * ggml_tensor_overhead() +
-                                                   ggml_graph_overhead_custom(ctx_gen_audio->max_nodes, false));
-        }
+                    // each layer is either a short conv block or a complete attention block
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];
+                        layer.conv_pw1_w = get_tensor(string_format(TN_CONV_PW1, prefix, il, "weight"), false);
+                        layer.conv_dw_w  = get_tensor(string_format(TN_CONV_DW,  prefix, il, "weight"), false);
+                        layer.conv_pw2_w = get_tensor(string_format(TN_CONV_PW2, prefix, il, "weight"), false);
+                        const bool is_conv = layer.conv_pw1_w && layer.conv_dw_w && layer.conv_pw2_w;
+                        const bool is_attn = layer.q_w && layer.k_w && layer.v_w && layer.o_w && layer.q_norm && layer.k_norm;
+                        if (is_conv == is_attn) {
+                            throw std::runtime_error(string_format("%s: lfm3a layer %d is neither a complete conv nor a complete attention block\n", __func__, il));
+                        }
+                    }
+                } break;
+            case PROJECTOR_TYPE_PARAKEET:
+                {

-    } catch (const std::exception & e) {
-        LOG_ERR("%s: failed to load model '%s': %s\n", __func__, fname, e.what());
+                    hparams.mel_filters = get_vector(TN_MEL_FILTERS);
+                    hparams.window      = get_vector(TN_WINDOW);

-        delete ctx_vision;
-        delete ctx_audio;
-        delete ctx_gen_audio;
+                    // Subsampling layers (conv1d)
+                    for (int i : {0, 2, 3, 5, 6}) {
+                        model.pre_encode_conv_X_w[i] = get_tensor(string_format(TN_CONV1D, i, "weight"));
+                        model.pre_encode_conv_X_b[i] = get_tensor(string_format(TN_CONV1D, i, "bias"));
+                    }
+                    model.pre_encode_out_w = get_tensor(string_format(TN_PRE_ENCODE_OUT, "weight"));
+                    model.pre_encode_out_b = get_tensor(string_format(TN_PRE_ENCODE_OUT, "bias"));

-        return {nullptr, nullptr, nullptr};
-    }
+                    // Projection layers
+                    model.mm_norm_pre_w = get_tensor(string_format(TN_MM_NORM_PRE, "weight"), false);
+                    model.mm_0_w        = get_tensor(string_format(TN_MM_AUDIO_MLP, 1, "weight"), false);
+                    model.mm_1_w        = get_tensor(string_format(TN_MM_AUDIO_MLP, 2, "weight"), false);

-    return {ctx_vision, ctx_audio, ctx_gen_audio};
-}
+                    // Encoder layers
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];

-struct clip_cap clip_get_cap(const char * fname) {
-    clip_cap res;
-    clip_model_loader loader(fname, /* skip_tensors= */ true);
-    res.has_vision    = loader.has_vision;
-    res.has_audio     = loader.has_audio;
-    res.has_gen_audio = loader.has_gen_audio;
-    return res;
-}
+                        // Attention (from shared above)

-int clip_get_image_max_tokens(const clip_ctx * ctx) {
-    const auto & hparams = ctx->model.hparams;
-    if (ctx->proj_type() == PROJECTOR_TYPE_DEEPSEEK4V || ctx->proj_type() == PROJECTOR_TYPE_DEEPSEEK41V) {
-        return hparams.dsv4_max_n_token;
-    }
-    if (hparams.image_max_pixels <= 0) {
-        return -1;
-    }
-    const int patch_area = hparams.patch_size * hparams.patch_size * hparams.n_merge * hparams.n_merge;
-    return hparams.image_max_pixels / patch_area;
-}
+                        // Relative position encoding
+                        layer.linear_pos_w = get_tensor(string_format(TN_LINEAR_POS, prefix, il, "weight"));
+                        layer.pos_bias_u   = get_tensor(string_format(TN_POS_BIAS_U, prefix, il));
+                        layer.pos_bias_v   = get_tensor(string_format(TN_POS_BIAS_V, prefix, il));

-void clip_free(clip_ctx * ctx) {
-    if (ctx == nullptr) {
-        return;
-    }
-    delete ctx;
-}
+                        // Convolution module
+                        layer.conv_pw1_w     = get_tensor(string_format(TN_CONV_PW1,       prefix, il, "weight"));
+                        layer.conv_pw1_b     = get_tensor(string_format(TN_CONV_PW1,       prefix, il, "bias"), false);
+                        layer.conv_dw_w      = get_tensor(string_format(TN_CONV_DW,        prefix, il, "weight"));
+                        layer.conv_dw_b      = get_tensor(string_format(TN_CONV_DW,        prefix, il, "bias"), false);
+                        layer.conv_norm_w    = get_tensor(string_format(TN_CONV_NORM,      prefix, il, "weight"));
+                        layer.conv_norm_b    = get_tensor(string_format(TN_CONV_NORM,      prefix, il, "bias"));
+                        layer.conv_norm_mean = get_tensor(string_format(TN_CONV_NORM_MEAN, prefix, il));
+                        layer.conv_norm_var  = get_tensor(string_format(TN_CONV_NORM_VAR,  prefix, il));
+                        layer.conv_pw2_w     = get_tensor(string_format(TN_CONV_PW2,       prefix, il, "weight"));
+                        layer.conv_pw2_b     = get_tensor(string_format(TN_CONV_PW2,       prefix, il, "bias"), false);

-const char * clip_patch_merge_type(const struct clip_ctx * ctx) {
-    return ctx->model.hparams.mm_patch_merge_type == PATCH_MERGE_SPATIAL_UNPAD ? "spatial_unpad" : "flat";
-}
+                        // Feed-forward networks
+                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
+                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));

-int clip_n_output_tokens_x(const clip_ctx * ctx, const clip_image_f32 * img) {
-    const auto & params = ctx->model.hparams;
-    const int n_total = clip_n_output_tokens(ctx, img);
-    const auto & proj = ctx->proj_type();
-    switch (proj) {
-        case PROJECTOR_TYPE_QWEN2VL:
-        case PROJECTOR_TYPE_QWEN25VL:
-        case PROJECTOR_TYPE_QWEN3VL:
-        case PROJECTOR_TYPE_MAGEVL:
-        case PROJECTOR_TYPE_SENSENOVA_U1:
-        case PROJECTOR_TYPE_LING3VL:
-        case PROJECTOR_TYPE_EXAONE4_5:
-        case PROJECTOR_TYPE_MIMOVL:
-        case PROJECTOR_TYPE_GLM4V:
-        case PROJECTOR_TYPE_GLM5V:
-        case PROJECTOR_TYPE_PADDLEOCR:
-        case PROJECTOR_TYPE_HUNYUANVL:
-        case PROJECTOR_TYPE_YOUTUVL:
-        case PROJECTOR_TYPE_MUSE_GLIMMER:
-        case PROJECTOR_TYPE_LFM3VL:
-            return (img->nx() / params.patch_size) / 2;
-        case PROJECTOR_TYPE_STEP3VL:
-        case PROJECTOR_TYPE_MINICPMV4_6:
-        case PROJECTOR_TYPE_MINICPMV4_7:
-        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
-            return img->nx() / (params.patch_size * params.n_merge);
-        case PROJECTOR_TYPE_DEEPSEEKOCR:
-        case PROJECTOR_TYPE_DEEPSEEKOCR2:
-            return (img->nx() / params.patch_size) / 4;
-        default:
-            break;
-    }
-    return n_total;
-}
+                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
+                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
+                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
+                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"), false);
+                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
+                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"), false);
+
+                        // Layer norms
+                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
+                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));
+                    }
+
+                    model.mm_model_mlp_1_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 0, "weight"));
+                    model.mm_model_mlp_2_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 1, "weight"));
+                    model.mm_model_mlp_3_w = get_tensor(string_format(TN_MVLM_PROJ_MLP, 3, "weight"));
+                } break;
+            case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+                {
+                    hparams.mel_filters = get_vector(TN_MEL_FILTERS);
+                    hparams.window      = get_vector(TN_WINDOW);

-int clip_n_output_tokens_y(const clip_ctx * ctx, const clip_image_f32 * img) {
-    const auto & params = ctx->model.hparams;
-    const auto & proj = ctx->proj_type();
-    switch (proj) {
-        case PROJECTOR_TYPE_QWEN2VL:
-        case PROJECTOR_TYPE_QWEN25VL:
-        case PROJECTOR_TYPE_QWEN3VL:
-        case PROJECTOR_TYPE_MAGEVL:
-        case PROJECTOR_TYPE_SENSENOVA_U1:
-        case PROJECTOR_TYPE_LING3VL:
-        case PROJECTOR_TYPE_EXAONE4_5:
-        case PROJECTOR_TYPE_MIMOVL:
-        case PROJECTOR_TYPE_GLM4V:
-        case PROJECTOR_TYPE_GLM5V:
-        case PROJECTOR_TYPE_PADDLEOCR:
-        case PROJECTOR_TYPE_HUNYUANVL:
-        case PROJECTOR_TYPE_YOUTUVL:
-        case PROJECTOR_TYPE_MUSE_GLIMMER:
-        case PROJECTOR_TYPE_LFM3VL:
-            return (img->ny() / params.patch_size) / 2;
-        case PROJECTOR_TYPE_STEP3VL:
-        case PROJECTOR_TYPE_MINICPMV4_6:
-        case PROJECTOR_TYPE_MINICPMV4_7:
-        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
-            return img->ny() / (params.patch_size * params.n_merge);
-        default:
-            break;
-    }
-    return 1;
-}
+                    // ASR expert: blocks, pre_ln and post_ln come from the generic loader
+                    model.inp_proj_w = get_tensor(string_format(TN_INP_PROJ, "weight"));
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];
+                        layer.q_norm_b = get_tensor(string_format(TN_ATTN_Q_NORM, prefix, il, "bias"));
+                        layer.k_norm_b = get_tensor(string_format(TN_ATTN_K_NORM, prefix, il, "bias"));
+                    }

-int clip_n_output_tokens(const clip_ctx * ctx, const clip_image_f32 * img) {
-    const auto & params = ctx->model.hparams;
+                    // speaker expert
+                    const char * sp = "a.spk";
+                    model.spk_inp_proj_w   = get_tensor(string_format(TN_A_SPK_INP_PROJ, "weight"));
+                    model.spk_pre_ln_w     = get_tensor(string_format(TN_LN_PRE,  sp, "weight"));
+                    model.spk_pre_ln_b     = get_tensor(string_format(TN_LN_PRE,  sp, "bias"));
+                    model.spk_post_ln_w    = get_tensor(string_format(TN_LN_POST, sp, "weight"));
+                    model.spk_post_ln_b    = get_tensor(string_format(TN_LN_POST, sp, "bias"));
+                    model.spk_layers.resize(hparams.spk_n_layer);
+                    for (int il = 0; il < hparams.spk_n_layer; ++il) {
+                        auto & layer = model.spk_layers[il];
+                        layer.ln_1_w    = get_tensor(string_format(TN_LN_1,        sp, il, "weight"));
+                        layer.ln_1_b    = get_tensor(string_format(TN_LN_1,        sp, il, "bias"));
+                        layer.qkv_w     = get_tensor(string_format(TN_ATTN_QKV,    sp, il, "weight"));
+                        layer.o_w       = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "weight"));
+                        layer.o_b       = get_tensor(string_format(TN_ATTN_OUTPUT, sp, il, "bias"));
+                        layer.ln_2_w    = get_tensor(string_format(TN_LN_2,        sp, il, "weight"));
+                        layer.ln_2_b    = get_tensor(string_format(TN_LN_2,        sp, il, "bias"));
+                        layer.ff_up_w   = get_tensor(string_format(TN_FFN_UP,      sp, il, "weight"));
+                        layer.ff_up_b   = get_tensor(string_format(TN_FFN_UP,      sp, il, "bias"));
+                        layer.ff_down_w = get_tensor(string_format(TN_FFN_DOWN,    sp, il, "weight"));
+                        layer.ff_down_b = get_tensor(string_format(TN_FFN_DOWN,    sp, il, "bias"));
+                    }

-    // for models with fixed size image, the input image is already pre-processed and resized to square
-    int patch_size = params.patch_size;
-    int n_patches = (img->nx() / patch_size) * (img->ny() / patch_size);
+                    // Sortformer head, fusion
+                    model.spk_head_proj_w   = get_tensor(string_format(TN_A_SPK_HEAD, "proj",     "weight"));
+                    model.spk_head_proj_b   = get_tensor(string_format(TN_A_SPK_HEAD, "proj",     "bias"));
+                    model.spk_head_up_w     = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "weight"));
+                    model.spk_head_up_b     = get_tensor(string_format(TN_A_SPK_HEAD, "upsample", "bias"));
+                    model.spk_head_fc1_w    = get_tensor(string_format(TN_A_SPK_HEAD, "fc1",      "weight"));
+                    model.spk_head_fc1_b    = get_tensor(string_format(TN_A_SPK_HEAD, "fc1",      "bias"));
+                    model.spk_head_fc2_w    = get_tensor(string_format(TN_A_SPK_HEAD, "fc2",      "weight"));
+                    model.spk_head_fc2_b    = get_tensor(string_format(TN_A_SPK_HEAD, "fc2",      "bias"));
+                    model.fusion_asr_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "weight"));
+                    model.fusion_asr_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "asr_norm", "bias"));
+                    model.fusion_spk_norm_w = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "weight"));
+                    model.fusion_spk_norm_b = get_tensor(string_format(TN_A_FUSION_NORM, "spk_norm", "bias"));
+                    model.fusion_spk_kernel = get_tensor(TN_A_FUSION_KERNEL);

-    projector_type proj = ctx->proj_type();
+                    model.spk_sil_emb = get_vector(TN_A_SPK_SIL_EMB);

-    switch (proj) {
-        case PROJECTOR_TYPE_MLP:
-        case PROJECTOR_TYPE_MLP_NORM:
-        case PROJECTOR_TYPE_JANUS_PRO:
-        case PROJECTOR_TYPE_PHI4:
-            {
-                // do nothing
-            } break;
-        case PROJECTOR_TYPE_YASA2:
-            {
-                n_patches = 64; // adaptive average pooling to 8x8 tokens
-            } break;
-        case PROJECTOR_TYPE_LDP:
-        case PROJECTOR_TYPE_LDPV2:
-        case PROJECTOR_TYPE_GLM_EDGE:
-            {
-                n_patches /= 4;
-                if (ctx->model.mm_boi) {
-                    n_patches += 2; // for BOI and EOI token embeddings
-                }
-            } break;
-        case PROJECTOR_TYPE_MINICPMV:
-            {
-                // Use actual config value if available, otherwise fall back to hardcoded values
-                if (params.minicpmv_query_num > 0) {
-                    n_patches = params.minicpmv_query_num;
-                } else {
-                    // Fallback to hardcoded values for legacy models
-                    if (params.minicpmv_version == 2) {
-                        n_patches = 96;
-                    } else if (params.minicpmv_version == 3) {
-                        n_patches = 64;
-                    } else if (params.minicpmv_version == 4) {
-                        n_patches = 64;
-                    } else if (params.minicpmv_version == 5) {
-                        // MiniCPM-V 4.0
-                        n_patches = 64;
-                    } else if (params.minicpmv_version == 6) {
-                        // MiniCPM-V 4.5
-                        n_patches = 64;
-                    } else if (params.minicpmv_version == 100045) {
-                        // MiniCPM-o 4.5
-                        n_patches = 64;
-                    } else {
-                        GGML_ABORT("Unknown minicpmv version");
+                    // the head layouts and the widths the graph and the speaker state use must match the tensors
+                    hparams.spk_n_embd = model.spk_inp_proj_w->ne[1];
+                    const int64_t d_head     = hparams.n_head     > 0 ? hparams.n_embd     / hparams.n_head     : 0;
+                    const int64_t d_head_spk = hparams.spk_n_head > 0 ? hparams.spk_n_embd / hparams.spk_n_head : 0;
+                    const int64_t n_spk      = model.spk_head_fc2_w->ne[1];
+                    // the graph splits each fused QKV into three views of the layer width
+                    bool qkv_ok = true;
+                    for (const auto & layer : model.layers) {
+                        qkv_ok = qkv_ok && layer.qkv_w && layer.qkv_w->ne[0] == hparams.n_embd && layer.qkv_w->ne[1] == 3 * hparams.n_embd;
                     }
-                }
-            } break;
-        case PROJECTOR_TYPE_MINICPMV4_6:
-        case PROJECTOR_TYPE_MINICPMV4_7:
-        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
-            {
-                n_patches /= params.n_merge * params.n_merge;
-            } break;
-        case PROJECTOR_TYPE_QWEN2VL:
-        case PROJECTOR_TYPE_QWEN25VL:
-        case PROJECTOR_TYPE_QWEN3VL:
-        case PROJECTOR_TYPE_MAGEVL:
-        case PROJECTOR_TYPE_SENSENOVA_U1:
-        case PROJECTOR_TYPE_LING3VL:
-        case PROJECTOR_TYPE_EXAONE4_5:
-        case PROJECTOR_TYPE_MIMOVL:
-        case PROJECTOR_TYPE_MINIMAX_M3:
-        case PROJECTOR_TYPE_GLM4V:
-        case PROJECTOR_TYPE_GLM5V:
-        case PROJECTOR_TYPE_YOUTUVL:
-        case PROJECTOR_TYPE_MUSE_GLIMMER:
-        case PROJECTOR_TYPE_LFM3VL:
-            {
-                // dynamic size (2 conv, so double patch size)
-                int x_patch = img->nx() / (params.patch_size * 2);
-                int y_patch = img->ny() / (params.patch_size * 2);
-                n_patches = x_patch * y_patch;
-            } break;
-        case PROJECTOR_TYPE_STEP3VL:
-            {
-                int x_patch = img->nx() / (params.patch_size * params.n_merge);
-                int y_patch = img->ny() / (params.patch_size * params.n_merge);
-                n_patches = x_patch * y_patch;
-            } break;
-        case PROJECTOR_TYPE_GEMMA3:
-        case PROJECTOR_TYPE_GEMMA4V:
-        case PROJECTOR_TYPE_GEMMA4UV:
-        case PROJECTOR_TYPE_IDEFICS3:
-        case PROJECTOR_TYPE_COHERE2V:
-        case PROJECTOR_TYPE_INTERNVL:
-        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
-        case PROJECTOR_TYPE_NEMOTRON_OMNI:
-        case PROJECTOR_TYPE_LLAMA4:
-            {
-                // both X and Y are downscaled by the scale factor
-                int scale_factor = ctx->model.hparams.n_merge;
-                n_patches /= (scale_factor * scale_factor);
-            } break;
-        case PROJECTOR_TYPE_GEMMA3NV:
-            {
-                // MobileNetV5 MSFA adapter always outputs fixed 16x16 resolution
-                // regardless of input size (see architecture description)
-                n_patches = ctx->model.hparams.image_size / ctx->model.hparams.patch_size;
-            } break;
-        case PROJECTOR_TYPE_LFM2:
-        case PROJECTOR_TYPE_D1OMNI_V:
-        case PROJECTOR_TYPE_KIMIVL:
-        case PROJECTOR_TYPE_KIMIK25:
-            {
-                // dynamic size
-                int out_patch_size = params.patch_size * ctx->model.hparams.n_merge;
-                int x_patch = CLIP_ALIGN(img->nx(), out_patch_size) / out_patch_size;
-                int y_patch = CLIP_ALIGN(img->ny(), out_patch_size) / out_patch_size;
-                n_patches = x_patch * y_patch;
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEK4V:
-            {
-                const int out_patch_size = params.patch_size * params.n_merge;
-                const int n_llm_w = CLIP_ALIGN(img->nx(), out_patch_size) / out_patch_size;
-                const int n_llm_h = CLIP_ALIGN(img->ny(), out_patch_size) / out_patch_size;
-                n_patches = dsv4_get_block_layout(n_llm_w, n_llm_h, img->lead_pad).n_out;
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEK41V:
-            {
-                const int out_patch_size = params.patch_size * params.n_merge;
-                const int n_llm_w = CLIP_ALIGN(img->nx(), out_patch_size) / out_patch_size;
-                const int n_llm_h = CLIP_ALIGN(img->ny(), out_patch_size) / out_patch_size;
-                n_patches = dsv41_n_block_tokens(n_llm_w, n_llm_h);
-            } break;
-        case PROJECTOR_TYPE_PADDLEOCR:
-        case PROJECTOR_TYPE_DOTS_OCR:
-        case PROJECTOR_TYPE_DOTS3NOTE_V:
-            {
-                // dynamic size
-                int n_merge = ctx->model.hparams.n_merge;
-                int stride = n_merge * n_merge;
-                n_patches = CLIP_ALIGN(n_patches, stride) / stride;
-            } break;
-        case PROJECTOR_TYPE_DOTS3NOTE_A:
-            {
-                // 3x stride-2 conv2d over mel frames
-                n_patches = (img->nx() + 7) / 8;
-            } break;
-        case PROJECTOR_TYPE_PIXTRAL:
-        case PROJECTOR_TYPE_LIGHTONOCR:
-            {
-                // dynamic size
-                int n_merge = ctx->model.hparams.n_merge;
-                int n_patches_x = img->nx() / patch_size / n_merge;
-                int n_patches_y = img->ny() / patch_size / n_merge;
-                if (ctx->model.token_embd_img_break) {
-                    n_patches = n_patches_y * n_patches_x + n_patches_y - 1; // + one [IMG_BREAK] per row, except the last row
-                } else {
-                    n_patches = n_patches_y * n_patches_x;
-                }
-            } break;
-        case PROJECTOR_TYPE_VOXTRAL_RT:
-            {
-                // 2 context frames, conv stride 2, then stacked frames
-                n_patches = (img->nx() - 2) / 2 / ctx->model.hparams.proj_stack_factor;
-            } break;
-        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
-            {
-                const int64_t fold = ctx->model.hparams.audio_samples_per_token();
-                n_patches = (int) ((img->nx() + fold - 1) / fold);
-            } break;
-        case PROJECTOR_TYPE_VOXTRAL:
-        case PROJECTOR_TYPE_ULTRAVOX:
-        case PROJECTOR_TYPE_QWEN2A:
-        case PROJECTOR_TYPE_MERALION:
-        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
-            {
-                n_patches = img->nx();
+                    for (const auto & layer : model.spk_layers) {
+                        qkv_ok = qkv_ok && layer.qkv_w->ne[0] == hparams.spk_n_embd && layer.qkv_w->ne[1] == 3 * hparams.spk_n_embd;
+                    }
+                    if (!qkv_ok || model.inp_proj_w->ne[1] != hparams.n_embd || d_head <= 0 || d_head * hparams.n_head != hparams.n_embd ||
+                            (hparams.n_embd_head > 0 && hparams.n_embd_head != d_head) ||
+                            hparams.audio_rope_dims <= 0 || hparams.audio_rope_dims % 2 != 0 || hparams.audio_rope_dims > d_head ||
+                            d_head_spk <= 0 || d_head_spk % 2 != 0 || d_head_spk * hparams.spk_n_head != hparams.spk_n_embd ||
+                            (int64_t) model.spk_sil_emb.size() != hparams.spk_n_embd || n_spk <= 0 ||
+                            model.fusion_spk_kernel->ne[0] != n_spk || model.fusion_spk_kernel->ne[1] != hparams.n_embd ||
+                            hparams.mel_filters.size() != (size_t) hparams.n_mel_bins * (hparams.audio_n_fft / 2 + 1) ||
+                            hparams.window.size() != (size_t) hparams.audio_window_len) {
+                        throw std::runtime_error(string_format("%s: hparams do not match the tensor shapes\n", __func__));
+                    }
+
+                    // projection to the text model
+                    model.mm_fc_w = get_tensor(string_format(TN_MM_AUDIO_FC, "weight"));
+                    model.mm_fc_b = get_tensor(string_format(TN_MM_AUDIO_FC, "bias"));
+                } break;
+            case PROJECTOR_TYPE_GRANITE_SPEECH:
+                {
+                    model.inp_proj_w     = get_tensor(string_format(TN_INP_PROJ,    "weight"));
+                    model.inp_proj_b     = get_tensor(string_format(TN_INP_PROJ,    "bias"));
+                    model.ctc_out_w      = get_tensor(string_format(TN_CTC_OUT,     "weight"));
+                    model.ctc_out_b      = get_tensor(string_format(TN_CTC_OUT,     "bias"));
+                    model.ctc_out_mid_w  = get_tensor(string_format(TN_CTC_OUT_MID, "weight"));
+                    model.ctc_out_mid_b  = get_tensor(string_format(TN_CTC_OUT_MID, "bias"));

-                const int proj_stack_factor = ctx->model.hparams.proj_stack_factor;
-                if (ctx->model.audio_has_stack_frames()) {
-                    GGML_ASSERT(proj_stack_factor > 0);
-                    const int n_len = CLIP_ALIGN(n_patches, proj_stack_factor);
-                    n_patches = n_len / proj_stack_factor;
-                }
+                    // per-layer tensors not loaded by the generic loop above
+                    for (int il = 0; il < hparams.n_layer; ++il) {
+                        auto & layer = model.layers[il];

-                // whisper downscales input token by half after conv1d
-                n_patches /= 2;
+                        layer.attn_rel_pos_emb = get_tensor(string_format(TN_ATTN_REL_POS_EMB, prefix, il));

-                if (ctx->model.audio_has_avgpool()) {
-                    // divide by 2 because of nn.AvgPool1d(2, stride=2)
-                    n_patches /= 2;
-                }
-            } break;
-        case PROJECTOR_TYPE_QWEN3A:
-            {
-                // chunk_size=100 frames --> 3x stride-2 conv2d --> 13 tokens per chunk
-                const int chunk_size       = 100;
-                const int tokens_per_chunk = 13;
-                n_patches = (img->nx() / chunk_size) * tokens_per_chunk;
-            } break;
-        case PROJECTOR_TYPE_GLMA:
-            {
-                n_patches = img->nx();
-                // whisper downscales input token by half after conv1d
-                n_patches /= 2;
-                // reshape by merge_factor
-                n_patches /= ctx->model.hparams.proj_stack_factor;
-                // for BOI and EOI token embeddings
-                n_patches += 2;
-            } break;
-        case PROJECTOR_TYPE_COGVLM:
-            {
-                n_patches += 2; // for BOI and EOI token embeddings
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEKOCR:
-        {
-            // SAM encoder applies two stride-2 convolutions (net_2 and net_3)
-            // that reduce spatial dimensions by 4x in each direction (16x total)
-            // E.g., 64x64 -> 16x16 patches
-            n_patches /= 16;
+                        layer.ff_norm_w   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "weight"));
+                        layer.ff_norm_b   = get_tensor(string_format(TN_FFN_NORM,   prefix, il, "bias"));

-            if (img->add_viewsep) {
-                // global view: one image-newline per token-row + trailing view separator
-                const int h = static_cast<int>(std::sqrt(static_cast<float>(n_patches)));
-                n_patches = h * (h + 1) + 1;
-            } else if (img->ny() >= img->nx() && img->ny() % img->nx() == 0) {
-                // tile row: one image-newline per token-row
-                const int grid_w = img->ny() / img->nx();
-                const int tile_patches = img->nx() / (patch_size * 4); // patches per tile side (SAM divides by 4)
-                const int h = tile_patches;
-                n_patches = (tile_patches * grid_w + 1) * h;
-            }
-        } break;
-        case PROJECTOR_TYPE_HUNYUANVL:
-            {
-                int merge = ctx->model.hparams.n_merge;
-                int ow = (img->nx() / patch_size) / merge;
-                int oh = (img->ny() / patch_size) / merge;
-                n_patches = (ow + 1) * oh + 2;
-            } break;
-        case PROJECTOR_TYPE_DEEPSEEKOCR2:
-        {
-            // 1024 global view -> 256 query tokens + 1 view separator = 257;
-            // 768 local tile   -> 144 query tokens, no separator.
-            n_patches /= 16;
-            if (img->add_viewsep) {
-                n_patches += 1; // view separator, appended only after the global view
-            }
-        } break;
-        case PROJECTOR_TYPE_LFM2A:
-        case PROJECTOR_TYPE_D1OMNI_A:
-        case PROJECTOR_TYPE_LFM3A:
-            {
-                n_patches = ((((img->nx() + 1) / 2) + 1) / 2 + 1) / 2;
-            } break;
-        case PROJECTOR_TYPE_GEMMA4A:
-            {
-                // Two Conv2D stride-2: O = floor((I + 2p - k) / s) + 1, p=1, k=3, s=2
-                // O = floor((I - 1) / 2) + 1
-                int n = img->nx();
-                for (int i = 0; i < 2; i++) {
-                    n = (n - 1) / 2 + 1;
-                }
-                n_patches = n;
-            } break;
-        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
-        case PROJECTOR_TYPE_PARAKEET:
-            {
-                n_patches = (img->nx() + (params.subsampling_factor - 1)) / params.subsampling_factor;
-            } break;
-        case PROJECTOR_TYPE_GEMMA4UA:
-            {
-                n_patches = img->nx();  // no downsampling: one token per raw waveform frame
-            } break;
-        case PROJECTOR_TYPE_MIMO_AUDIO:
-            {
-                // conv1(s=1) + conv2(s=2) -> RVQ-encoder downsample conv(k=2,s=2)
-                int n = img->nx();
-                n = (n - 1) / 2 + 1;         // conv1 + conv2
-                n = (n - 2) / 2 + 1;         // downsample conv
-                const int group_size = params.audio_local_group_size;
-                n_patches = (n + group_size - 1) / group_size;
-            } break;
-        case PROJECTOR_TYPE_GRANITE_SPEECH:
-            {
-                const int ws = ctx->model.hparams.audio_proj_window_size;
-                const int ds = ctx->model.hparams.audio_proj_downsample_rate;
-                n_patches = ((img->nx() + ws - 1) / ws) * (ws / ds);
-            } break;
-        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
-            {
-                // pooling gives one speaker embedding, whatever the clip length is
-                n_patches = 1;
-            } break;
-        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
-            {
-                // statistics pooling gives one speaker embedding, whatever the clip length is
-                n_patches = 1;
-            } break;
-        case PROJECTOR_TYPE_QWEN3TTS_GEN:
-        case PROJECTOR_TYPE_LFM3A_GEN:
-        case PROJECTOR_TYPE_DRAMA3_GEN:
-            {
-                // one hidden-state vector fed back to the talker per call
-                n_patches = 1;
-            } break;
-        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
-            {
-                // one conditioning row per 12.5Hz frame
-                const int hop = ctx->model.hparams.mimi_downsample * 120;
-                n_patches = img->nx() / hop;
-            } break;
-        case PROJECTOR_TYPE_POCKETTTS_GEN:
-            {
-                // one latent per call for GEN_CODE, GEN_WAV sizes its input from the caller
-                n_patches = 1;
-            } break;
-        case PROJECTOR_TYPE_GRANITE4_VISION:
-            {
-                // Per-tile output token count: each projector block outputs
-                // query_side^2 tokens per window x n^2 windows.
-                // For 384x384 input: n = 24/8 = 3, query_side = 4 -> 144.
-                const int window_side = ctx->model.hparams.downsample_window_side;
-                const int query_side  = ctx->model.hparams.downsample_query_side;
-                const int side        = img->nx() / params.patch_size;
-                const int n           = side / window_side;
-                const int out_side    = query_side * n;
-                n_patches             = out_side * out_side;
-                if (img->anyres.is_tiled()) {
-                    // overview tile, then the unpadded tile grid with one newline per row
-                    int off_x, off_y, w, h;
-                    clip_anyres_unpad(img->anyres.grid_x * out_side, img->anyres.grid_y * out_side,
-                                      img->anyres.orig_nx, img->anyres.orig_ny, off_x, off_y, w, h);
-                    n_patches += h * (w + 1);
-                }
-            } break;
-        default:
-            GGML_ABORT("unsupported projector type");
-    }
+                        layer.ff_norm_1_w = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "weight"));
+                        layer.ff_norm_1_b = get_tensor(string_format(TN_FFN_NORM_1, prefix, il, "bias"));
+                        layer.ff_up_1_w   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "weight"));
+                        layer.ff_up_1_b   = get_tensor(string_format(TN_FFN_UP_1,   prefix, il, "bias"));
+                        layer.ff_down_1_w = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "weight"));
+                        layer.ff_down_1_b = get_tensor(string_format(TN_FFN_DOWN_1, prefix, il, "bias"));

-    n_patches += (int) clip_suffix_rows(img->suffix_type).size();
+                        layer.norm_conv_w = get_tensor(string_format(TN_NORM_CONV, prefix, il, "weight"));
+                        layer.norm_conv_b = get_tensor(string_format(TN_NORM_CONV, prefix, il, "bias"));
+                        layer.conv_norm_w = get_tensor(string_format(TN_CONV_NORM, prefix, il, "weight"));
+                        layer.conv_norm_b = get_tensor(string_format(TN_CONV_NORM, prefix, il, "bias"));
+                        layer.conv_dw_w   = get_tensor(string_format(TN_CONV_DW,   prefix, il, "weight"));
+                        layer.conv_pw1_w  = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "weight"));
+                        layer.conv_pw1_b  = get_tensor(string_format(TN_CONV_PW1,  prefix, il, "bias"));
+                        layer.conv_pw2_w  = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "weight"));
+                        layer.conv_pw2_b  = get_tensor(string_format(TN_CONV_PW2,  prefix, il, "bias"));
+                    }

-    return n_patches;
-}
+                    model.qf_proj_blocks.resize(1);
+                    auto & qf = model.qf_proj_blocks[0];
+                    qf.qf_proj_query    = get_tensor(string_format(TN_QF_PROJ_QUERY, prefix));
+                    qf.qf_proj_norm_w   = get_tensor(string_format(TN_QF_PROJ_NORM, prefix, "weight"));
+                    qf.qf_proj_norm_b   = get_tensor(string_format(TN_QF_PROJ_NORM, prefix, "bias"));
+                    qf.qf_proj_linear_w = get_tensor(string_format(TN_QF_PROJ_LINEAR, prefix, "weight"));
+                    qf.qf_proj_linear_b = get_tensor(string_format(TN_QF_PROJ_LINEAR, prefix, "bias"));

-ggml_backend_sched_t clip_get_sched(const struct clip_ctx * ctx) {
-    return ctx->sched.get();
-}
+                    const int n_proj_layers = 2;
+                    qf.qf_proj_layers.resize(n_proj_layers);
+                    for (int il = 0; il < n_proj_layers; ++il) {
+                        auto & pl = qf.qf_proj_layers[il];

-// TODO: remove this and use ggml_mul_mat with GGML_PREC_F32 once all backends honor it for F32 x F32
-// ggml_mul_mat for 2D F32 tensors, always in F32 on CPU: some GPU backends round the inputs to F16 or TF32, which loses the quiet bins of a spectrum
-static void mtmd_mul_mat_prec_op(ggml_tensor * dst, int ith, int nth, void * /*userdata*/) {
-    const ggml_tensor * a = dst->src[0];
-    const ggml_tensor * b = dst->src[1];
-    const int64_t n_k = a->ne[0];
-
-    for (int64_t n = ith; n < b->ne[1]; n += nth) {
-        const float * b_row = (const float *) ((const char *) b->data + n * b->nb[1]);
-        float * dst_row = (float *) ((char *) dst->data + n * dst->nb[1]);
-        for (int64_t m = 0; m < a->ne[1]; m++) {
-            const float * a_row = (const float *) ((const char *) a->data + m * a->nb[1]);
-            // independent partial sums, so that the compiler can vectorize the loop
-            float acc[16] = {};
-            int64_t k = 0;
-            for (; k + 16 <= n_k; k += 16) {
-                for (int l = 0; l < 16; l++) {
-                    acc[l] += a_row[k + l] * b_row[k + l];
-                }
-            }
-            float sum = 0.0f;
-            for (; k < n_k; k++) {
-                sum += a_row[k] * b_row[k];
-            }
-            for (int l = 0; l < 16; l++) {
-                sum += acc[l];
-            }
-            dst_row[m] = sum;
-        }
-    }
-}
+                        pl.q_w    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, il, "weight"));
+                        pl.q_b    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, il, "bias"));
+                        pl.k_w    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, il, "weight"));
+                        pl.k_b    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, il, "bias"));
+                        pl.v_w    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, il, "weight"));
+                        pl.v_b    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, il, "bias"));
+                        pl.o_w    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, il, "weight"));
+                        pl.o_b    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, il, "bias"));
+                        pl.ln_1_w = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, il, "weight"));
+                        pl.ln_1_b = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, il, "bias"));

-static ggml_tensor * mtmd_mul_mat_prec(ggml_context * ctx, ggml_tensor * a, ggml_tensor * b) {
-    GGML_ASSERT(a->type == GGML_TYPE_F32 && b->type == GGML_TYPE_F32);
-    GGML_ASSERT(ggml_n_dims(a) <= 2 && ggml_n_dims(b) <= 2 && a->ne[0] == b->ne[0]);
-    GGML_ASSERT(a->nb[0] == sizeof(float) && b->nb[0] == sizeof(float));
-    ggml_tensor * args[] = { a, b };
-    return ggml_custom_4d(ctx, GGML_TYPE_F32, a->ne[1], b->ne[1], 1, 1, args, 2, mtmd_mul_mat_prec_op, GGML_N_TASKS_MAX, nullptr);
-}
+                        pl.cross_attn_q_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, il, "weight"));
+                        pl.cross_attn_q_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, il, "bias"));
+                        pl.cross_attn_k_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, il, "weight"));
+                        pl.cross_attn_k_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, il, "bias"));
+                        pl.cross_attn_v_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, il, "weight"));
+                        pl.cross_attn_v_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, il, "bias"));
+                        pl.cross_attn_o_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, il, "weight"));
+                        pl.cross_attn_o_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, il, "bias"));
+                        pl.cross_attn_norm_w = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, il, "weight"));
+                        pl.cross_attn_norm_b = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, il, "bias"));

-bool clip_audio_log_mel(const mtmd_audio_mel_config & config, const float * samples, const float * window, int64_t n_frames, float * out) {
-    ggml_backend_sched_t sched = config.sched;
-    ggml_backend_t backend_cpu = ggml_backend_sched_get_backend(sched, ggml_backend_sched_get_n_backends(sched) - 1);
-    clip_backend_cpu_set_n_threads(backend_cpu, std::max(1, config.n_threads));
-
-    const auto &  cache      = *config.cache;
-    const auto &  filters    = cache.filters.data;
-    const int64_t n_fft_bins = config.n_fft_bins;
-    const int64_t frame_size = (n_fft_bins - 1) * 2;
-    const int64_t n_mel      = config.n_mel;
-    GGML_ASSERT(filters.size() == (size_t) (n_mel * n_fft_bins));
-    GGML_ASSERT(cache.cos_vals.size() == (size_t) frame_size && cache.sin_vals.size() == (size_t) frame_size);
-
-    // DFT matrix with the window folded in, the first n_fft_bins rows give the real part
-    std::vector<float> dft((size_t) 2 * n_fft_bins * frame_size);
-    for (int64_t k = 0; k < n_fft_bins; k++) {
-        for (int64_t j = 0; j < frame_size; j++) {
-            const int64_t q = (j * k) % frame_size;
-            dft[k * frame_size + j]                = window[j] * cache.cos_vals[q];
-            dft[(k + n_fft_bins) * frame_size + j] = window[j] * cache.sin_vals[q];
-        }
-    }
+                        pl.ff_up_w   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, il, "weight"));
+                        pl.ff_up_b   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, il, "bias"));
+                        pl.ff_down_w = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, il, "weight"));
+                        pl.ff_down_b = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, il, "bias"));
+                        pl.ln_2_w    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, il, "weight"));
+                        pl.ln_2_b    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, il, "bias"));
+                    }
+                } break;
+            case PROJECTOR_TYPE_GRANITE4_VISION:
+                {
+                    // image_newline lives at the top-level.
+                    model.image_newline = get_tensor(TN_IMAGE_NEWLINE);
+
+                    // Load separate layerwise and spatial projector tensors
+                    const auto projector_count = hparams.feature_layers.size();
+                    model.qf_proj_blocks.resize(projector_count);
+                    for (size_t bid = 0; bid < projector_count; ++bid) {
+                        auto & b = model.qf_proj_blocks[bid];
+
+                        // non-layerwise tensors
+                        b.qf_proj_img_pos     = get_tensor(string_format(TN_MULTI_PROJ_IMG_POS,           bid));
+                        b.qf_proj_query       = get_tensor(string_format(TN_MULTI_PROJ_QUERY,     prefix, bid));
+                        b.qf_proj_linear_w    = get_tensor(string_format(TN_MULTI_PROJ_LINEAR,    prefix, bid, "weight"));
+                        b.qf_proj_linear_b    = get_tensor(string_format(TN_MULTI_PROJ_LINEAR,    prefix, bid, "bias"));
+                        b.qf_proj_norm_w      = get_tensor(string_format(TN_MULTI_PROJ_NORM,      prefix, bid, "weight"));
+                        b.qf_proj_norm_b      = get_tensor(string_format(TN_MULTI_PROJ_NORM,      prefix, bid, "bias"));
+                        b.qf_proj_post_norm_w = get_tensor(string_format(TN_MULTI_PROJ_POST_NORM, prefix, bid, "weight"));
+                        b.qf_proj_post_norm_b = get_tensor(string_format(TN_MULTI_PROJ_POST_NORM, prefix, bid, "bias"));
+
+                        // laywerwise tensors
+                        // NOTE: If any model uses multi-layer qformers, this will need to change
+                        b.qf_proj_layers.resize(1);
+                        auto & pl = b.qf_proj_layers[0];

-    // the spectrum is several times larger than the result, so long audio is processed in blocks
-    const int64_t n_frames_max = 8192;
+                        pl.q_w    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, bid, "weight"));
+                        pl.q_b    = get_tensor(string_format(TN_QF_SELF_ATTN_Q, prefix, bid, "bias"));
+                        pl.k_w    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, bid, "weight"));
+                        pl.k_b    = get_tensor(string_format(TN_QF_SELF_ATTN_K, prefix, bid, "bias"));
+                        pl.v_w    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, bid, "weight"));
+                        pl.v_b    = get_tensor(string_format(TN_QF_SELF_ATTN_V, prefix, bid, "bias"));
+                        pl.o_w    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, bid, "weight"));
+                        pl.o_b    = get_tensor(string_format(TN_QF_SELF_ATTN_O, prefix, bid, "bias"));
+                        pl.ln_1_w = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, bid, "weight"));
+                        pl.ln_1_b = get_tensor(string_format(TN_QF_SELF_ATTN_N, prefix, bid, "bias"));

-    for (int64_t off = 0; off < n_frames; off += n_frames_max) {
-        const int64_t n_cur = std::min(n_frames_max, n_frames - off);
+                        pl.cross_attn_q_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, bid, "weight"));
+                        pl.cross_attn_q_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_Q, prefix, bid, "bias"));
+                        pl.cross_attn_k_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, bid, "weight"));
+                        pl.cross_attn_k_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_K, prefix, bid, "bias"));
+                        pl.cross_attn_v_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, bid, "weight"));
+                        pl.cross_attn_v_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_V, prefix, bid, "bias"));
+                        pl.cross_attn_o_w    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, bid, "weight"));
+                        pl.cross_attn_o_b    = get_tensor(string_format(TN_QF_CROSS_ATTN_O, prefix, bid, "bias"));
+                        pl.cross_attn_norm_w = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, bid, "weight"));
+                        pl.cross_attn_norm_b = get_tensor(string_format(TN_QF_CROSS_ATTN_N, prefix, bid, "bias"));

-        ggml_init_params params = {
-            /*.mem_size   =*/ ggml_tensor_overhead() * 32 + ggml_graph_overhead(),
-            /*.mem_buffer =*/ nullptr,
-            /*.no_alloc   =*/ true,
-        };
-        ggml_context_ptr ctx_ptr(ggml_init(params));
-        ggml_context * ctx0 = ctx_ptr.get();
-
-        ggml_tensor * inp_samples = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, (n_cur - 1) * config.hop_length + frame_size);
-        ggml_tensor * inp_dft     = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, frame_size, 2 * n_fft_bins);
-        ggml_tensor * inp_filters = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_fft_bins, n_mel);
-        ggml_set_input(inp_samples);
-        ggml_set_input(inp_dft);
-        ggml_set_input(inp_filters);
-
-        ggml_tensor * cur = ggml_cont(ctx0, ggml_view_2d(ctx0, inp_samples, frame_size, n_cur, config.hop_length * ggml_element_size(inp_samples), 0));
-        cur = mtmd_mul_mat_prec(ctx0, inp_dft, cur); // [2 * n_fft_bins, n_cur]
-
-        cur = ggml_sqr(ctx0, cur);
-        cur = ggml_add(ctx0,
-                ggml_view_2d(ctx0, cur, n_fft_bins, n_cur, cur->nb[1], 0),
-                ggml_view_2d(ctx0, cur, n_fft_bins, n_cur, cur->nb[1], n_fft_bins * ggml_element_size(cur)));
-        if (config.use_magnitude) {
-            cur = ggml_sqrt(ctx0, cur);
-        }
+                        pl.ff_up_w   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, bid, "weight"));
+                        pl.ff_up_b   = get_tensor(string_format(TN_QF_FFN_UP,   prefix, bid, "bias"));
+                        pl.ff_down_w = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, bid, "weight"));
+                        pl.ff_down_b = get_tensor(string_format(TN_QF_FFN_DOWN, prefix, bid, "bias"));
+                        pl.ln_2_w    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, bid, "weight"));
+                        pl.ln_2_b    = get_tensor(string_format(TN_QF_FFN_NORM, prefix, bid, "bias"));
+                    }

-        cur = mtmd_mul_mat_prec(ctx0, cur, inp_filters); // [n_cur, n_mel]
-        cur = config.mel_floor_add
-            ? ggml_scale_bias(ctx0, cur, 1.0f, config.mel_floor)
-            : ggml_clamp(ctx0, cur, config.mel_floor, FLT_MAX);
-        cur = ggml_log(ctx0, cur);
-        if (!config.use_natural_log) {
-            cur = ggml_scale(ctx0, cur, 1.0f / logf(10.0f));
+                } break;
+            case PROJECTOR_TYPE_SENSENOVA_U1:
+                {
+                    model.mm_0_w = get_tensor(string_format(TN_LLAVA_PROJ, 0, "weight"));
+                    model.mm_0_b = get_tensor(string_format(TN_LLAVA_PROJ, 0, "bias"));
+                } break;
+            default:
+                GGML_ASSERT(false && "unknown projector type");
         }
-        ggml_set_output(cur);

-        ggml_cgraph * gf = ggml_new_graph(ctx0);
-        ggml_build_forward_expand(gf, cur);
+        // load data
+        {
+            std::vector<uint8_t> read_buf;

-        ggml_backend_sched_reset(sched);
-        if (!ggml_backend_sched_alloc_graph(sched, gf)) {
-            LOG_ERR("%s: failed to allocate compute graph\n", __func__);
-            return false;
-        }
+            // start loading event
+            if (progress_callback){
+                progress_callback(0.0, progress_callback_user_data);
+            }

-        ggml_backend_tensor_set(inp_samples, samples + off * config.hop_length, 0, ggml_nbytes(inp_samples));
-        ggml_backend_tensor_set(inp_dft,     dft.data(),                        0, ggml_nbytes(inp_dft));
-        ggml_backend_tensor_set(inp_filters, filters.data(),                    0, ggml_nbytes(inp_filters));
+            // compute total tensor data size for progress reporting
+            size_t total_data_size = 0;
+            for (auto & t : tensors_to_load) {
+                total_data_size += ggml_nbytes(t);
+            }

-        auto status = ggml_backend_sched_graph_compute(sched, gf);
-        if (status != GGML_STATUS_SUCCESS) {
-            LOG_ERR("%s: ggml_backend_sched_graph_compute failed with error %d\n", __func__, status);
-            return false;
+            // alloc memory and offload data
+            model.buf.reset(ggml_backend_alloc_ctx_tensors_from_buft(model.ctx_data.get(), buft));
+            ggml_backend_buffer_set_usage(model.buf.get(), GGML_BACKEND_BUFFER_USAGE_WEIGHTS);
+            // read the weight from file
+            if (!no_alloc) {
+                size_t data_loaded = 0;
+                for (auto & t : tensors_to_load) {
+                    ggml_tensor * cur = ggml_get_tensor(model.ctx_data.get(), t->name);
+                    GGML_ASSERT(cur && "tensor not found in ctx_data");
+                    auto it_off = tensor_offset.find(t->name);
+                    GGML_ASSERT(it_off != tensor_offset.end() && "no offset for tensor");
+                    const size_t offset = it_off->second;
+                    fin.seekg(offset, std::ios::beg);
+                    if (!fin) {
+                        throw std::runtime_error(string_format("%s: failed to seek for tensor %s\n", __func__, t->name));
+                    }
+                    size_t num_bytes = ggml_nbytes(cur);
+                    if (ggml_backend_buft_is_host(buft)) {
+                        // for the CPU and Metal backend, we can read directly into the tensor
+                        fin.read(reinterpret_cast<char *>(cur->data), num_bytes);
+                    } else {
+                        // read into a temporary buffer first, then copy to device memory
+                        read_buf.resize(num_bytes);
+                        fin.read(reinterpret_cast<char *>(read_buf.data()), num_bytes);
+                        ggml_backend_tensor_set(cur, read_buf.data(), 0, num_bytes);
+                    }
+                    data_loaded += num_bytes;
+                    if (progress_callback && total_data_size > 0) {
+                        const float progress = (float)data_loaded / (float)total_data_size;
+                        if (!progress_callback(progress, progress_callback_user_data)) {
+                            throw std::runtime_error(string_format("%s: model loading cancelled by progress_callback\n", __func__));
+                        }
+                    }
+                }
+                LOG_DBG("%s: loaded %zu tensors from %s\n", __func__, tensors_to_load.size(), fname.c_str());
+            } else {
+                LOG_DBG("%s: no_alloc is set, skipping tensor data loading (%zu tensors)\n", __func__, tensors_to_load.size());
+            }
+            fin.close();
         }

-        for (int64_t j = 0; j < n_mel; j++) {
-            ggml_backend_tensor_get(cur, out + j * n_frames + off, j * cur->nb[1], n_cur * ggml_element_size(cur));
-        }
     }

-    return true;
-}
-
-bool clip_image_encode(struct clip_ctx * ctx, int n_threads, const clip_image_f32 * img, std::vector<float> & out_vec) {
-    clip_image_f32_batch imgs;
-    clip_image_f32 img_copy = *img;
-    imgs.entries.push_back(std::move(img_copy));
-
-    return clip_image_batch_encode(ctx, n_threads, &imgs, out_vec);
-}
-
-bool clip_image_batch_encode(clip_ctx * ctx, int n_threads, const clip_image_f32_batch * imgs_c_ptr, std::vector<float> & out_batch_embd) {
-    clip_encode_params params;
-    params.imgs = imgs_c_ptr;
-    params.n_threads = n_threads;
-    params.out_embd = &out_batch_embd;
-
-    if (ctx->proj_type() == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
-        return clip_graph_nemotron_transcribe::encode_windows(ctx, ctx->model, &params);
+    void get_bool(const std::string & key, bool & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        output = gguf_get_val_bool(ctx_gguf.get(), i);
     }
-    return clip_encode(ctx, &params);
-}

-// persisted state slots of the gen-audio decoders and the streaming audio encoders
-static std::vector<c2w_state_slot> list_gen_state_slots(const clip_hparams & hparams, const clip_model & model) {
-    switch (model.proj_type) {
-        case PROJECTOR_TYPE_QWEN3TTS_GEN:  return list_c2w_state_slots(hparams, model);
-        case PROJECTOR_TYPE_DRAMA3_GEN:    return list_drama3_state_slots(hparams, model);
-        case PROJECTOR_TYPE_POCKETTTS_GEN: return list_pockettts_state_slots(hparams, model);
-        case PROJECTOR_TYPE_LFM3A_GEN:     return list_lfm3a_gen_state_slots(hparams, model);
-        default:                           return {};
+    void get_i32(const std::string & key, int & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        output = gguf_get_val_i32(ctx_gguf.get(), i);
     }
-}

-// state_out can have another shape than its slot, only the byte size must match
-static void copy_gen_state_out(ggml_tensor * src, ggml_tensor * dst) {
-    GGML_ASSERT(ggml_nbytes(src) == ggml_nbytes(dst));
-    if (src->type == dst->type && ggml_are_same_shape(src, dst) && ggml_is_contiguous(src)) {
-        ggml_backend_tensor_copy(src, dst);
-        return;
+    void get_u32(const std::string & key, int & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        const uint32_t val = gguf_get_val_u32(ctx_gguf.get(), i);
+        // sanity check
+        if (val > (uint32_t) INT32_MAX) {
+            throw std::runtime_error(string_format("%s: value %u for key '%s' exceeds INT32_MAX\n",
+                __func__, val, key.c_str()));
+        }
+        output = (int) val;
     }
-    std::vector<uint8_t> tmp(ggml_nbytes(src));
-    ggml_backend_tensor_get(src, tmp.data(), 0, tmp.size());
-    ggml_backend_tensor_set(dst, tmp.data(), 0, tmp.size());
-}

-clip_gen_state * clip_gen_state_init(clip_ctx * ctx, uint32_t seed) {
-    auto st = std::make_unique<clip_gen_state>();
-    st->rng.seed(seed == UINT32_MAX ? std::random_device{}() : seed);
-
-    const auto slots = list_gen_state_slots(ctx->model.hparams, ctx->model);
-    if (slots.empty()) {
-        return st.release();
+    void get_f32(const std::string & key, float & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        output = gguf_get_val_f32(ctx_gguf.get(), i);
     }

-    ggml_init_params params = {
-        /*.mem_size   =*/ ggml_tensor_overhead() * slots.size(),
-        /*.mem_buffer =*/ nullptr,
-        /*.no_alloc   =*/ true,
-    };
-    st->ctx_data.reset(ggml_init(params));
-    for (const auto & slot : slots) {
-        ggml_tensor * t = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F32, slot.ne0, slot.ne1);
-        ggml_format_name(t, "gen_state_%s", slot.name.c_str());
-        st->slots.push_back(t);
-    }
-    st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), ctx->backend));
-    if (!st->buf) {
-        LOG_ERR("%s: failed to allocate the gen state\n", __func__);
-        return nullptr;
+    void get_arr_f32(const std::string & key, std::vector<float> & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        if (gguf_get_kv_type(ctx_gguf.get(), i) != GGUF_TYPE_ARRAY) {
+            throw std::runtime_error(string_format("%s: key '%s' is not an array\n", __func__, key.c_str()));
+        }
+        const auto type = gguf_get_arr_type(ctx_gguf.get(), i);
+        if (type != GGUF_TYPE_FLOAT32) {
+            throw std::runtime_error(string_format("%s: array '%s' has type %d, expected %d (GGUF_TYPE_FLOAT32)\n", __func__, key.c_str(), type, GGUF_TYPE_FLOAT32));
+        }
+        const size_t n = gguf_get_arr_n(ctx_gguf.get(), i);
+        if (n > (size_t) std::numeric_limits<int>::max()) {
+            throw std::runtime_error(string_format("%s: array '%s' is too large (%zu elements)\n", __func__, key.c_str(), n));
+        }
+        output.resize(n);
+        const float * values = (const float *)gguf_get_arr_data(ctx_gguf.get(), i);
+        for (size_t j = 0; j < n; ++j) {
+            output[j] = values[j];
+        }
     }
-    // a zero state is the cold start
-    ggml_backend_buffer_clear(st->buf.get(), 0);
-
-    return st.release();
-}
-
-void clip_gen_state_free(clip_gen_state * state) {
-    delete state;
-}

-void clip_gen_state_reset(clip_gen_state * state) {
-    if (state->buf) {
-        ggml_backend_buffer_clear(state->buf.get(), 0);
+    void get_string(const std::string & key, std::string & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
+        }
+        output = std::string(gguf_get_val_str(ctx_gguf.get(), i));
     }
-}
-
-const std::map<std::string, clip_gen_voice> & clip_gen_get_voices(const clip_ctx * ctx) {
-    return ctx->model.gen_voices;
-}

-clip_enc_state * clip_enc_state_init(clip_ctx * ctx, int32_t n_frames_max) {
-    const auto & hparams = ctx->model.hparams;
-    if (ctx->proj_type() == PROJECTOR_TYPE_LFM3A) {
-        // downsampler context, short-conv taps and attention window, see list_lfm3a_state_slots()
-        const auto slots = list_lfm3a_state_slots(hparams, ctx->model);
-        auto st = std::make_unique<clip_enc_state>();
-        ggml_init_params params = {
-            /*.mem_size   =*/ ggml_tensor_overhead() * slots.size(),
-            /*.mem_buffer =*/ nullptr,
-            /*.no_alloc   =*/ true,
-        };
-        st->ctx_data.reset(ggml_init(params));
-        for (const auto & slot : slots) {
-            ggml_tensor * t = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F32, slot.ne0, slot.ne1);
-            ggml_format_name(t, "enc_state_%s", slot.name.c_str());
-            st->slots.push_back(t);
+    void get_arr_int(const std::string & key, std::vector<int> & output, bool required = true) const {
+        const int i = gguf_find_key(ctx_gguf.get(), key.c_str());
+        if (i < 0) {
+            if (required) {
+                throw std::runtime_error("Key not found: " + key);
+            }
+            return;
         }
-        st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), ctx->backend));
-        if (!st->buf) {
-            LOG_ERR("%s: failed to allocate the encoder state\n", __func__);
-            return nullptr;
+        if (gguf_get_kv_type(ctx_gguf.get(), i) != GGUF_TYPE_ARRAY) {
+            throw std::runtime_error(string_format("%s: key '%s' is not an array\n", __func__, key.c_str()));
+        }
+        const auto type = gguf_get_arr_type(ctx_gguf.get(), i);
+        if (type != GGUF_TYPE_INT32) {
+            throw std::runtime_error(string_format("%s: array '%s' has type %d, expected %d (GGUF_TYPE_INT32)\n", __func__, key.c_str(), type, GGUF_TYPE_INT32));
+        }
+        const size_t n = gguf_get_arr_n(ctx_gguf.get(), i);
+        if (n > (size_t) std::numeric_limits<int>::max()) {
+            throw std::runtime_error(string_format("%s: array '%s' is too large (%zu elements)\n", __func__, key.c_str(), n));
+        }
+        output.resize(n);
+        const int32_t * values = (const int32_t *)gguf_get_arr_data(ctx_gguf.get(), i);
+        for (size_t j = 0; j < n; ++j) {
+            output[j] = values[j];
         }
-        // a zero state is the cold start
-        ggml_backend_buffer_clear(st->buf.get(), 0);
-        return st.release();
-    }
-    if (ctx->proj_type() != PROJECTOR_TYPE_VOXTRAL_RT || hparams.attn_window_size <= 0) {
-        LOG_ERR("%s: the encoder does not support streaming input\n", __func__);
-        return nullptr;
-    }
-    if (n_frames_max <= 0) {
-        LOG_ERR("%s: invalid n_frames_max %d\n", __func__, n_frames_max);
-        return nullptr;
     }

-    const int n_layer   = hparams.n_layer;
-    const int d_head    = hparams.n_embd_head > 0 ? hparams.n_embd_head : hparams.n_embd / hparams.n_head;
-    const int n_embd_kv = hparams.n_head_kv * d_head;
-
-    auto st = std::make_unique<clip_enc_state>();
-    st->n_ctx = hparams.attn_window_size + n_frames_max;
-    st->slot_pos.assign(st->n_ctx, -1);
-
-    ggml_init_params params = {
-        /*.mem_size   =*/ ggml_tensor_overhead() * 2 * n_layer,
-        /*.mem_buffer =*/ nullptr,
-        /*.no_alloc   =*/ true,
-    };
-    st->ctx_data.reset(ggml_init(params));
-    for (int il = 0; il < n_layer; il++) {
-        ggml_tensor * k = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F16, n_embd_kv, st->n_ctx);
-        ggml_tensor * v = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F16, n_embd_kv, st->n_ctx);
-        ggml_format_name(k, "enc_state_k_%d", il);
-        ggml_format_name(v, "enc_state_v_%d", il);
-        st->k.push_back(k);
-        st->v.push_back(v);
-    }
-    st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), ctx->backend));
-    if (!st->buf) {
-        LOG_ERR("%s: failed to allocate the encoder state\n", __func__);
-        return nullptr;
+    static void set_llava_uhd_res_candidates(mtmd_model & model, const int max_patches_per_side) {
+        auto & hparams = model.hparams;
+        for (int x = 1; x <= max_patches_per_side; x++) {
+            for (int y = 1; y <= max_patches_per_side; y++) {
+                if (x == 1 && y == 1) {
+                    continue; // skip the first point
+                }
+                hparams.image_res_candidates.push_back(mtmd_image_size{
+                    x*hparams.image_size,
+                    y*hparams.image_size,
+                });
+            }
+        }
     }
-    // empty slots are masked out, but they must not hold NaN
-    ggml_backend_buffer_clear(st->buf.get(), 0);
-
-    LOG_INF("%s: encoder state: %d slots, %.2f MiB\n", __func__, st->n_ctx, ggml_backend_buffer_get_size(st->buf.get()) / 1024.0 / 1024.0);
-
-    return st.release();
-}
-
-void clip_enc_state_free(clip_enc_state * state) {
-    delete state;
-}

-size_t clip_cbx_read_tensor(struct clip_ctx * ctx, const char * name, float * out, size_t n_max) {
-    const auto & tensors = ctx->model.cbx_tensors;
-    auto it = tensors.find(name);
-    if (it == tensors.end() || !it->second) {
-        return 0;
-    }
-    ggml_tensor * t = it->second;
-    const size_t n = (size_t) ggml_nelements(t);
-    if (!out) {
-        return n;
-    }
-    if (n_max < n) {
-        return 0;
-    }
-    if (t->type == GGML_TYPE_F32) {
-        ggml_backend_tensor_get(t, out, 0, n * sizeof(float));
-    } else if (t->type == GGML_TYPE_F16) {
-        std::vector<ggml_fp16_t> tmp(n);
-        ggml_backend_tensor_get(t, tmp.data(), 0, n * sizeof(ggml_fp16_t));
-        for (size_t i = 0; i < n; i++) out[i] = ggml_fp16_to_fp32(tmp[i]);
-    } else {
-        return 0;
+    static void set_internvl_dhr_res_candidates(mtmd_model & model) {
+        auto & hparams = model.hparams;
+        int min_num = hparams.preproc_min_tiles;
+        int max_num = hparams.preproc_max_tiles;
+        if (min_num < 1) {
+           return; // avoid  divide by 0
+        }
+        for (int a = min_num; a <= max_num; ++a) {
+            int b_lo = (min_num + a - 1) / a;
+            int b_hi = max_num / a;
+            b_lo = std::max(b_lo, min_num);
+            b_hi = std::min(b_hi, max_num);
+            for (int b = b_lo; b <= b_hi; ++b) {
+                hparams.image_res_candidates.push_back(mtmd_image_size {
+                    a*hparams.image_size,
+                    b*hparams.image_size,
+                });
+            }
+        }
     }
-    return n;
-}
+};

-bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
-    const clip_image_f32_batch & imgs = *params->imgs;
-    int n_batch_cur = imgs.entries.size();
+//
+// loading
+//

-    // [QWEN_VIDEO] for video models, the batch dimension is used as temporal dimension for merged frames
-    if (!ctx->support_batch && n_batch_cur > clip_model_n_temporal_merge(ctx)) {
-        LOG_ERR("%s: batch size %d exceeds maximum supported batch/temporal-merge size %d\n", __func__, n_batch_cur, clip_model_n_temporal_merge(ctx));
-        return false;
-    }
+mtmd_models mtmd_model_load(const char * fname, const mtmd_model_params & params, ggml_backend_buffer_type_t buft) {
+    mtmd_model_loader loader(fname,
+        /* skip_tensors */ false,
+        params.progress_callback,
+        params.progress_callback_user_data);
+
+    auto load = [&](mtmd_modality modality) {
+        auto model = std::make_unique<mtmd_model>();
+        model->hparams.custom_image_min_tokens = params.image_min_tokens;
+        model->hparams.custom_image_max_tokens = params.image_max_tokens;
+        loader.load_hparams(*model, modality);
+        loader.load_tensors(*model, buft, params.no_alloc);
+        return model;
+    };

-    // if buffers are not allocated, we need to do a warmup run to allocate them
-    if (!ctx->is_allocated) {
-        clip_model_loader::warmup(*ctx, *params->imgs);
+    mtmd_models res;
+    if (loader.has_vision) {
+        res.v = load(MTMD_MODALITY_VISION);
     }

-    const bool needs_gen_state = params->gen_process == CLIP_GEN_PROCESS_GEN_CODE ||
-                                 params->gen_process == CLIP_GEN_PROCESS_GEN_WAV ||
-                                 params->gen_process == CLIP_GEN_PROCESS_TTS;
-    if (needs_gen_state && params->gen_state == nullptr) {
-        LOG_ERR("%s: audio gen requires a gen state\n", __func__);
-        return false;
-    }
-    if (ctx->proj_type() == PROJECTOR_TYPE_DRAMA3_GEN && params->gen_process == CLIP_GEN_PROCESS_GEN_CODE) {
-        float pos = 0.0f;
-        ggml_backend_tensor_get(params->gen_state->slots[0], &pos, 0, sizeof(float));
-        if ((int) pos >= clip_graph_drama3_gen::n_pos_max) {
-            LOG_ERR("%s: the codebook tower holds at most %d rows\n", __func__, clip_graph_drama3_gen::n_pos_max);
-            return false;
-        }
+    // TODO: we don't support audio for Gemma 3N, but GGUF contains audio tensors
+    // we can remove this check when we implement audio support for Gemma 3N
+    const bool skip_audio = res.v && res.v->proj_type == PROJECTOR_TYPE_GEMMA3NV;
+    if (loader.has_audio && !skip_audio) {
+        res.a = load(MTMD_MODALITY_AUDIO);
     }

-    // build the inference graph
-    ggml_backend_sched_reset(ctx->sched.get());
-    ggml_cgraph * gf = clip_get_graph_builder(ctx, imgs, params)->build();
-
-    // the last node is the embedding tensor, code2wav has no out_embd
-    // read it before scheduling: backends may reorder the nodes of graphs with several outputs (Metal)
-    ggml_tensor * embeddings = params->out_embd ? ggml_graph_node(gf, -1) : nullptr;
-
-    if (!ggml_backend_sched_alloc_graph(ctx->sched.get(), gf)) {
-        LOG_ERR("%s: failed to allocate compute graph\n", __func__);
-        return false;
+    if (loader.has_gen_audio) {
+        res.gen_a = load(MTMD_MODALITY_GEN_AUDIO);
     }

-    // set inputs
-    const auto & model   = ctx->model;
-    const auto & hparams = model.hparams;
-
-    const int image_size_width  = imgs.entries[0].nx();
-    const int image_size_height = imgs.entries[0].ny();
-
-    const int patch_size    = hparams.patch_size;
-    const int num_patches   = ((image_size_width / patch_size) * (image_size_height / patch_size));
-    const int n_pos = num_patches + (model.class_embedding ? 1 : 0);
-    const int pos_w = image_size_width  / patch_size;
-    const int pos_h = image_size_height / patch_size;
-
+    return res;
+}

-    auto get_inp_tensor = [&gf](const char * name) {
-        ggml_tensor * inp = ggml_graph_get_tensor(gf, name);
-        if (inp == nullptr) {
-            GGML_ABORT("Failed to get tensor %s", name);
-        }
-        if (!(inp->flags & GGML_TENSOR_FLAG_INPUT)) {
-            GGML_ABORT("Tensor %s is not an input tensor", name);
-        }
-        return inp;
-    };
+mtmd_model_caps mtmd_model_get_caps(const char * fname) {
+    mtmd_model_caps res;
+    mtmd_model_loader loader(fname, /* skip_tensors= */ true);
+    res.has_vision    = loader.has_vision;
+    res.has_audio     = loader.has_audio;
+    res.has_gen_audio = loader.has_gen_audio;
+    return res;
+}

-    auto set_input_f32 = [&get_inp_tensor](const char * name, const std::vector<float> & values) {
-        ggml_tensor * cur = get_inp_tensor(name);
-        GGML_ASSERT(cur->type == GGML_TYPE_F32);
-        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
-        ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
-    };
+//
+// mtmd_model
+//

-    // mask from build_inp_attn_mask(), f16 if flash attn is enabled
-    auto set_input_attn_mask = [&get_inp_tensor](const std::vector<float> & values, int idx = 0) {
-        ggml_tensor * cur = get_inp_tensor(get_attn_mask_name(idx).c_str());
-        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
-        if (cur->type == GGML_TYPE_F16) {
-            std::vector<ggml_fp16_t> values_f16(values.size());
-            ggml_fp32_to_fp16_row(values.data(), values_f16.data(), values.size());
-            ggml_backend_tensor_set(cur, values_f16.data(), 0, ggml_nbytes(cur));
-        } else {
-            GGML_ASSERT(cur->type == GGML_TYPE_F32);
-            ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
-        }
-    };
+int mtmd_model::image_max_tokens() const {
+    if (proj_type == PROJECTOR_TYPE_DEEPSEEK4V || proj_type == PROJECTOR_TYPE_DEEPSEEK41V) {
+        return hparams.dsv4_max_n_token;
+    }
+    if (hparams.image_max_pixels <= 0) {
+        return -1;
+    }
+    const int patch_area = hparams.patch_size * hparams.patch_size * hparams.n_merge * hparams.n_merge;
+    return hparams.image_max_pixels / patch_area;
+}

-    auto set_input_i32 = [&get_inp_tensor](const char * name, std::vector<int32_t> & values) {
-        ggml_tensor * cur = get_inp_tensor(name);
-        GGML_ASSERT(cur->type == GGML_TYPE_I32);
-        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
-        ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
-    };
+int mtmd_model::n_output_tokens_x(const mtmd_image_f32 * img) const {
+    const auto & params = hparams;
+    const int n_total = n_output_tokens(img);
+    const auto & proj = proj_type;
+    switch (proj) {
+        case PROJECTOR_TYPE_QWEN2VL:
+        case PROJECTOR_TYPE_QWEN25VL:
+        case PROJECTOR_TYPE_QWEN3VL:
+        case PROJECTOR_TYPE_MAGEVL:
+        case PROJECTOR_TYPE_SENSENOVA_U1:
+        case PROJECTOR_TYPE_LING3VL:
+        case PROJECTOR_TYPE_EXAONE4_5:
+        case PROJECTOR_TYPE_MIMOVL:
+        case PROJECTOR_TYPE_GLM4V:
+        case PROJECTOR_TYPE_GLM5V:
+        case PROJECTOR_TYPE_PADDLEOCR:
+        case PROJECTOR_TYPE_HUNYUANVL:
+        case PROJECTOR_TYPE_YOUTUVL:
+        case PROJECTOR_TYPE_MUSE_GLIMMER:
+        case PROJECTOR_TYPE_LFM3VL:
+            return (img->nx() / params.patch_size) / 2;
+        case PROJECTOR_TYPE_STEP3VL:
+        case PROJECTOR_TYPE_MINICPMV4_6:
+        case PROJECTOR_TYPE_MINICPMV4_7:
+        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
+            return img->nx() / (params.patch_size * params.n_merge);
+        case PROJECTOR_TYPE_DEEPSEEKOCR:
+        case PROJECTOR_TYPE_DEEPSEEKOCR2:
+            return (img->nx() / params.patch_size) / 4;
+        default:
+            break;
+    }
+    return n_total;
+}

-    // state of the previous call, zero on a cold start
-    auto set_gen_state_in = [&]() {
-        const auto & slots = params->gen_state->slots;
-        size_t i = 0;
-        for (const auto & slot : list_gen_state_slots(hparams, model)) {
-            GGML_ASSERT(i < slots.size());
-            if (slot.in_place) {
-                i++;
-                continue;
-            }
-            ggml_backend_tensor_copy(slots[i++], get_inp_tensor(("state_in_" + slot.name).c_str()));
-        }
-    };
+int mtmd_model::n_output_tokens_y(const mtmd_image_f32 * img) const {
+    const auto & params = hparams;
+    const auto & proj = proj_type;
+    switch (proj) {
+        case PROJECTOR_TYPE_QWEN2VL:
+        case PROJECTOR_TYPE_QWEN25VL:
+        case PROJECTOR_TYPE_QWEN3VL:
+        case PROJECTOR_TYPE_MAGEVL:
+        case PROJECTOR_TYPE_SENSENOVA_U1:
+        case PROJECTOR_TYPE_LING3VL:
+        case PROJECTOR_TYPE_EXAONE4_5:
+        case PROJECTOR_TYPE_MIMOVL:
+        case PROJECTOR_TYPE_GLM4V:
+        case PROJECTOR_TYPE_GLM5V:
+        case PROJECTOR_TYPE_PADDLEOCR:
+        case PROJECTOR_TYPE_HUNYUANVL:
+        case PROJECTOR_TYPE_YOUTUVL:
+        case PROJECTOR_TYPE_MUSE_GLIMMER:
+        case PROJECTOR_TYPE_LFM3VL:
+            return (img->ny() / params.patch_size) / 2;
+        case PROJECTOR_TYPE_STEP3VL:
+        case PROJECTOR_TYPE_MINICPMV4_6:
+        case PROJECTOR_TYPE_MINICPMV4_7:
+        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
+            return img->ny() / (params.patch_size * params.n_merge);
+        default:
+            break;
+    }
+    return 1;
+}

-    // lfm3a encoder and codec: causal sliding window over [cached window | this call]
-    auto set_lfm3a_window_inputs = [&](int base, int n_pos, int window) {
-        std::vector<int32_t> positions(n_pos);
-        for (int i = 0; i < n_pos; i++) {
-            positions[i] = base + i;
-        }
-        set_input_i32("positions", positions);
-
-        // key j holds position base - prefix + j
-        const int prefix = window - 1;
-        const int n_kv   = prefix + n_pos;
-        std::vector<float> mask((size_t) n_kv * n_pos, -INFINITY);
-        for (int q = 0; q < n_pos; q++) {
-            for (int j = std::max(0, q + prefix - window + 1); j <= q + prefix; j++) {
-                if (base - prefix + j >= 0) {
-                    mask[(size_t) q * n_kv + j] = 0.0f;
-                }
-            }
-        }
-        set_input_attn_mask(mask);
-    };
+int mtmd_model::n_output_tokens(const mtmd_image_f32 * img) const {
+    const auto & params = hparams;

-    // rope positions and attention mask of the mimi transformers (pocket-tts).
-    // the mask is causal with a sliding window, see _build_attention_mask() in the reference
-    auto set_pockettts_tfm_inputs = [&]() {
-        const int64_t n_pos = ggml_nelements(get_inp_tensor("inp_pos"));
-        GGML_ASSERT(n_pos > 0);
-        std::vector<int32_t> positions((size_t) n_pos);
-        for (int64_t i = 0; i < n_pos; i++) {
-            positions[(size_t) i] = (int32_t) i;
-        }
-        set_input_i32("inp_pos", positions);
-
-        // the preprocessor truncates the waveform to keep this mask bounded
-        const int64_t max_pos = (int64_t) clip_hparams::pockettts_max_spk_seconds * hparams.audio_sample_rate / 120;
-        GGML_ASSERT(n_pos <= max_pos && "pocket-tts speaker reference too long for a dense mask");
-
-        const int64_t context = hparams.mimi_tfm_context;
-        std::vector<float> mask((size_t) n_pos * n_pos, -INFINITY);
-        for (int64_t q = 0; q < n_pos; q++) {
-            for (int64_t k = 0; k < n_pos; k++) {
-                const int64_t delta = q - k;
-                if (delta >= 0 && delta < context) {
-                    mask[(size_t) q * n_pos + k] = 0.0f;
-                }
-            }
-        }
-        set_input_attn_mask(mask);
-    };
+    // for models with fixed size image, the input image is already pre-processed and resized to square
+    int patch_size = params.patch_size;
+    int n_patches = (img->nx() / patch_size) * (img->ny() / patch_size);

-    // set input pixel values
-    if (!imgs.is_audio) {
-        size_t nelem = 0;
-        for (const auto & img : imgs.entries) {
-            nelem += img.nx() * img.ny() * 3;
-        }
-        std::vector<float> inp_raw(nelem);
-
-        // layout of data (note: the channel dim is unrolled to better visualize the layout):
-        //
-        // ┌──W──┐
-        // │     H │  channel = R
-        // ├─────┤ │
-        // │     H │  channel = G
-        // ├─────┤ │
-        // │     H │  channel = B
-        // └─────┘ │
-        //   ──────┘ x B
-
-        // IMPORTANT: [QWEN_VIDEO] the batch dim is currently used for temporal dim in Qwen-VL models
-        // All entries must have the same spatial size (enforced by can_batch_with() during merging)
-        {
-            const int nx = imgs.entries[0].nx();
-            const int ny = imgs.entries[0].ny();
-            const int n  = nx * ny;
-
-            for (int b = 0; b < n_batch_cur; b++) {
-                LOG_DBG("%s: copying image %d/%d to input buffer (nx=%d, ny=%d)\n", __func__, b+1, n_batch_cur, nx, ny);
-                const auto & buf = imgs.entries[b].get_ro_buf();
-                float * batch_entry = inp_raw.data() + b * (3*n);
-                for (int y = 0; y < ny; y++) {
-                    for (int x = 0; x < nx; x++) {
-                        size_t base_src = 3*(y * nx + x);
-                        size_t base_dst =    y * nx + x;
-                        batch_entry[      base_dst] = buf[base_src    ];
-                        batch_entry[1*n + base_dst] = buf[base_src + 1];
-                        batch_entry[2*n + base_dst] = buf[base_src + 2];
-                    }
-                }
-            }
-        }
-        set_input_f32("inp_raw", inp_raw);
-
-    } else if (params->gen_process != CLIP_GEN_PROCESS_GEN_WAV &&
-               params->gen_process != CLIP_GEN_PROCESS_TTS &&
-               params->gen_process != CLIP_GEN_PROCESS_TTS_VOCODE &&
-               ggml_graph_get_tensor(gf, "inp_raw") != nullptr) {
-        // audio input. GEN_WAV and the chatterbox TTS stages are not here: they take codes or feats, set in the switch below.
-        // a GEN_CODE graph may have no hidden-state input (lfm3a_gen feedback embedding)
-        GGML_ASSERT(imgs.entries.size() == 1);
-
-        const auto & mel_inp = imgs.entries[0];
-        const auto & buf = mel_inp.get_ro_buf();
-        const int n_step = mel_inp.nx();
-        const int n_mel  = mel_inp.ny();
-        GGML_ASSERT((size_t)n_step * n_mel == buf.size());
-
-        set_input_f32("inp_raw", buf);
-    }
+    projector_type proj = proj_type;

-    // set input per projector
-    switch (ctx->model.proj_type) {
-        case PROJECTOR_TYPE_VOXTRAL_RT:
+    switch (proj) {
+        case PROJECTOR_TYPE_MLP:
+        case PROJECTOR_TYPE_MLP_NORM:
+        case PROJECTOR_TYPE_JANUS_PRO:
+        case PROJECTOR_TYPE_PHI4:
             {
-                clip_enc_state * st = params->enc_state;
-                if (!st) {
-                    LOG_ERR("%s: this encoder needs a clip_enc_state\n", __func__);
-                    return false;
-                }
-                const int n_window = hparams.attn_window_size;
-                const int n_new    = (imgs.entries[0].nx() - 2) / 2;
-                GGML_ASSERT(n_new <= st->n_ctx - n_window);
-
-                std::vector<int32_t> pos(n_new);
-                std::vector<int64_t> idx(n_new);
-                std::vector<int32_t> slot_pos = st->slot_pos; // including the frames written by this call
-                for (int i = 0; i < n_new; i++) {
-                    pos[i] = st->n_past + i;
-                    idx[i] = pos[i] % st->n_ctx;
-                    slot_pos[idx[i]] = pos[i];
-                }
-                set_input_i32("inp_pos", pos);
-                ggml_backend_tensor_set(get_inp_tensor("inp_kv_idx"), idx.data(), 0, idx.size() * sizeof(int64_t));
-
-                // each frame sees itself and the n_window - 1 frames before it
-                std::vector<float> mask((size_t) st->n_ctx * n_new, -INFINITY);
-                for (int i = 0; i < n_new; i++) {
-                    for (int s = 0; s < st->n_ctx; s++) {
-                        const int32_t p = slot_pos[s];
-                        if (p >= 0 && p <= pos[i] && p > pos[i] - n_window) {
-                            mask[(size_t) i * st->n_ctx + s] = 0.0f;
-                        }
-                    }
-                }
-                set_input_attn_mask(mask);
-
-                std::vector<float> tail = st->stem_tail;
-                tail.resize(hparams.n_embd, 0.0f);
-                set_input_f32("inp_stem_tail", tail);
+                // do nothing
             } break;
-        case PROJECTOR_TYPE_MUSE_GLIMMER:
+        case PROJECTOR_TYPE_YASA2:
             {
-                const int grid_w = pos_w;            // image_size_width  / patch_size
-                const int grid_h = pos_h;            // image_size_height / patch_size
-                const int n_tok  = grid_w * grid_h;
-                const int pgrid  = (int) std::sqrt((double) ctx->model.position_embeddings->ne[1]); // 32
-                const int f      = hparams.n_merge;  // downsample 2
-
-                // pixel patchify runs inside the graph via build_inp() (ggml_conv_2d);
-                // pos-emb bilinear interp via resize_position_embeddings().
-
-                // --- sparse window grouping (pgrid x pgrid windows) ---
-                const int win = pgrid;
-                const int nwin_h = (grid_h + win - 1) / win;
-                const int nwin_w = (grid_w + win - 1) / win;
-                std::vector<int32_t> sp_perm; sp_perm.reserve(n_tok);
-                std::vector<int>     sp_slens;
-                for (int wy = 0; wy < nwin_h; wy++) {
-                    for (int wx = 0; wx < nwin_w; wx++) {
-                        int cnt = 0;
-                        for (int hh = 0; hh < win; hh++) {
-                            for (int ww = 0; ww < win; ww++) {
-                                const int gy = wy * win + hh;
-                                const int gx = wx * win + ww;
-                                if (gy < grid_h && gx < grid_w) { sp_perm.push_back(gy * grid_w + gx); cnt++; }
-                            }
-                        }
-                        if (cnt > 0) sp_slens.push_back(cnt);
-                    }
-                }
-                std::vector<int32_t> rpos_w(n_tok), rpos_h(n_tok), inv_perm(n_tok);
-                for (int i = 0; i < n_tok; i++) {
-                    const int orig = sp_perm[i];
-                    rpos_w[i] = (orig % grid_w) + 1; // 1-indexed
-                    rpos_h[i] = (orig / grid_w) + 1;
-                    inv_perm[orig] = i;
-                }
-                set_input_i32("muse_glimmer_sp_perm",  sp_perm);
-                set_input_i32("muse_glimmer_inv_perm", inv_perm);
-                set_input_i32("muse_glimmer_pos_w",    rpos_w);
-                set_input_i32("muse_glimmer_pos_h",    rpos_h);
-
-                // block-diagonal window mask (permuted order)
-                std::vector<float> sp_mask((size_t) n_tok * n_tok, -INFINITY);
-                {
-                    int off = 0;
-                    for (int s : sp_slens) {
-                        for (int a = 0; a < s; a++)
-                            for (int b = 0; b < s; b++)
-                                sp_mask[(size_t) (off + a) * n_tok + (off + b)] = 0.0f;
-                        off += s;
-                    }
+                n_patches = 64; // adaptive average pooling to 8x8 tokens
+            } break;
+        case PROJECTOR_TYPE_LDP:
+        case PROJECTOR_TYPE_LDPV2:
+        case PROJECTOR_TYPE_GLM_EDGE:
+            {
+                n_patches /= 4;
+                if (mm_boi) {
+                    n_patches += 2; // for BOI and EOI token embeddings
                 }
-                set_input_attn_mask(sp_mask);
-
-                // pixel-shuffle gather (original order): f*f spatial neighbours grouped
-                std::vector<int32_t> dsp; dsp.reserve(n_tok);
-                for (int oy = 0; oy < grid_h / f; oy++)
-                    for (int ox = 0; ox < grid_w / f; ox++)
-                        for (int ry = 0; ry < f; ry++)
-                            for (int rx = 0; rx < f; rx++)
-                                dsp.push_back((oy * f + ry) * grid_w + (ox * f + rx));
-                set_input_i32("muse_glimmer_ds_perm", dsp);
             } break;
         case PROJECTOR_TYPE_MINICPMV:
             {
-                // inspired from siglip:
-                //    -> https://huggingface.co/HuggingFaceM4/siglip-so400m-14-980-flash-attn2-navit
-                //    -> https://huggingface.co/HuggingFaceM4/siglip-so400m-14-980-flash-attn2-navit/blob/d66538faeba44480d0bfaa42145eef26f9423199/modeling_siglip.py#L316
-                std::vector<int32_t> positions(pos_h * pos_w);
-                // note: sized by the actual patch counts; a tall/wide image produces more
-                // than 1024 patches per side and a fixed [1024] array would be overrun
-                std::vector<int> bucket_coords_h(pos_h);
-                std::vector<int> bucket_coords_w(pos_w);
-                for (int i = 0; i < pos_h; i++){
-                    bucket_coords_h[i] = std::floor(70.0*i/pos_h);
-                }
-                for (int i = 0; i < pos_w; i++){
-                    bucket_coords_w[i] = std::floor(70.0*i/pos_w);
-                }
-                for (int i = 0, id = 0; i < pos_h; i++){
-                    for (int j = 0; j < pos_w; j++){
-                        positions[id++] = bucket_coords_h[i]*70 + bucket_coords_w[j];
+                // Use actual config value if available, otherwise fall back to hardcoded values
+                if (params.minicpmv_query_num > 0) {
+                    n_patches = params.minicpmv_query_num;
+                } else {
+                    // Fallback to hardcoded values for legacy models
+                    if (params.minicpmv_version == 2) {
+                        n_patches = 96;
+                    } else if (params.minicpmv_version == 3) {
+                        n_patches = 64;
+                    } else if (params.minicpmv_version == 4) {
+                        n_patches = 64;
+                    } else if (params.minicpmv_version == 5) {
+                        // MiniCPM-V 4.0
+                        n_patches = 64;
+                    } else if (params.minicpmv_version == 6) {
+                        // MiniCPM-V 4.5
+                        n_patches = 64;
+                    } else if (params.minicpmv_version == 100045) {
+                        // MiniCPM-o 4.5
+                        n_patches = 64;
+                    } else {
+                        GGML_ABORT("Unknown minicpmv version");
                     }
                 }
-                set_input_i32("positions", positions);
-
-                // inputs for resampler projector
-                // set the 2D positions (using float for sinusoidal embedding)
-                int n_patches_per_col = image_size_width / patch_size;
-                std::vector<float> pos_data(n_pos);
-                // dimension H
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = static_cast<float>(i / n_patches_per_col);
-                }
-                set_input_f32("pos_h", pos_data);
-                // dimension W
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = static_cast<float>(i % n_patches_per_col);
-                }
-                set_input_f32("pos_w", pos_data);
-                // base frequency omega
-                const float base_freq   = 10000.0f;
-                const int   n_embd_proj = clip_n_mmproj_embd(ctx);
-                std::vector<float> omega(n_embd_proj / 4);
-                for (int i = 0; i < n_embd_proj / 4; ++i) {
-                    omega[i] = 1.0f / std::pow(base_freq, static_cast<float>(i) / (n_embd_proj / 4));
-                }
-                set_input_f32("omega", omega);
             } break;
         case PROJECTOR_TYPE_MINICPMV4_6:
         case PROJECTOR_TYPE_MINICPMV4_7:
         case PROJECTOR_TYPE_MINICPM_OCR_1_0:
             {
-                const bool is_4x = hparams.n_merge == 2;
-
-                // SigLIP position buckets (same as resampler path)
-                std::vector<int32_t> positions(pos_h * pos_w);
-                std::vector<int> bucket_coords_h(pos_h);
-                std::vector<int> bucket_coords_w(pos_w);
-                for (int i = 0; i < pos_h; i++){
-                    bucket_coords_h[i] = std::floor(70.0*i/pos_h);
-                }
-                for (int i = 0; i < pos_w; i++){
-                    bucket_coords_w[i] = std::floor(70.0*i/pos_w);
-                }
-                for (int i = 0, id = 0; i < pos_h; i++){
-                    for (int j = 0; j < pos_w; j++){
-                        positions[id++] = bucket_coords_h[i]*70 + bucket_coords_w[j];
-                    }
-                }
-                set_input_i32("positions", positions);
-
-                const int half_h = pos_h / 2;
-                const int half_w = pos_w / 2;
-
-                auto make_ds_idx = [](int off_r, int off_c, int ds_h, int ds_w, int stride_w) {
-                    std::vector<int32_t> idx(ds_h * ds_w);
-                    for (int i = 0; i < ds_h; i++) {
-                        for (int j = 0; j < ds_w; j++) {
-                            idx[i * ds_w + j] = (2*i + off_r) * stride_w + (2*j + off_c);
-                        }
-                    }
-                    return idx;
-                };
-
-                if (!is_4x) {
-                    // window reorder indices for 2x2 windows
-                    std::vector<int32_t> window_idx(n_pos);
-                    std::vector<int32_t> inv_window_idx(n_pos);
-                    {
-                        int k = 0;
-                        for (int wi = 0; wi < half_h; wi++) {
-                            for (int wj = 0; wj < half_w; wj++) {
-                                window_idx[k++] = (2*wi    ) * pos_w + (2*wj    );
-                                window_idx[k++] = (2*wi    ) * pos_w + (2*wj + 1);
-                                window_idx[k++] = (2*wi + 1) * pos_w + (2*wj    );
-                                window_idx[k++] = (2*wi + 1) * pos_w + (2*wj + 1);
-                            }
-                        }
-                        for (int i = 0; i < n_pos; i++) {
-                            inv_window_idx[window_idx[i]] = i;
-                        }
-                    }
-                    set_input_i32("vit_merger_window_idx",     window_idx);
-                    set_input_i32("vit_merger_inv_window_idx", inv_window_idx);
-
-                    // block-diagonal attention mask: tokens in the same 4-token
-                    // window attend to each other (mask = 0), all other positions
-                    // are masked out (-inf). matches the window-major reorder above.
-                    std::vector<float> window_mask_data(n_pos * n_pos, std::numeric_limits<float>::lowest());
-                    for (int wi = 0; wi < n_pos / 4; wi++) {
-                        for (int i = 0; i < 4; i++) {
-                            for (int j = 0; j < 4; j++) {
-                                window_mask_data[(wi*4 + i) * n_pos + (wi*4 + j)] = 0.0f;
-                            }
-                        }
-                    }
-                    set_input_attn_mask(window_mask_data);
-
-                    // ViT merger 2x2 downsample indices
-                    auto vit_merger_ds_0 = make_ds_idx(0, 0, half_h, half_w, pos_w);
-                    auto vit_merger_ds_1 = make_ds_idx(0, 1, half_h, half_w, pos_w);
-                    auto vit_merger_ds_2 = make_ds_idx(1, 0, half_h, half_w, pos_w);
-                    auto vit_merger_ds_3 = make_ds_idx(1, 1, half_h, half_w, pos_w);
-                    set_input_i32("vit_merger_ds_idx_0", vit_merger_ds_0);
-                    set_input_i32("vit_merger_ds_idx_1", vit_merger_ds_1);
-                    set_input_i32("vit_merger_ds_idx_2", vit_merger_ds_2);
-                    set_input_i32("vit_merger_ds_idx_3", vit_merger_ds_3);
-                }
-
-                const int merger_h = is_4x ? pos_h : half_h;
-                const int merger_w = is_4x ? pos_w : half_w;
-                auto m_ds_0 = make_ds_idx(0, 0, merger_h / 2, merger_w / 2, merger_w);
-                auto m_ds_1 = make_ds_idx(0, 1, merger_h / 2, merger_w / 2, merger_w);
-                auto m_ds_2 = make_ds_idx(1, 0, merger_h / 2, merger_w / 2, merger_w);
-                auto m_ds_3 = make_ds_idx(1, 1, merger_h / 2, merger_w / 2, merger_w);
-                set_input_i32("merger_ds_idx_0", m_ds_0);
-                set_input_i32("merger_ds_idx_1", m_ds_1);
-                set_input_i32("merger_ds_idx_2", m_ds_2);
-                set_input_i32("merger_ds_idx_3", m_ds_3);
-            } break;
-        case PROJECTOR_TYPE_LFM3VL:
-            {
-                // patches in raster-scan order (no interleave before encoder layers)
-                const int pw = image_size_width  / patch_size;
-                const int ph = image_size_height / patch_size;
-                std::vector<int> positions(n_pos * 4);
-                int ptr = 0;
-                for (int y = 0; y < ph; y++) {
-                    for (int x = 0; x < pw; x++) {
-                        positions[                  ptr] = y;
-                        positions[    num_patches + ptr] = x;
-                        positions[2 * num_patches + ptr] = y;
-                        positions[3 * num_patches + ptr] = x;
-                        ptr++;
-                    }
-                }
-                set_input_i32("positions", positions);
-            } break;
-        case PROJECTOR_TYPE_MAGEVL:
-            {
-                // t, h, w in 2x2 block order, t is always 0 for an image
-                const int merge_ratio = hparams.n_merge;
-                const int pw = image_size_width  / patch_size / merge_ratio;
-                const int ph = image_size_height / patch_size / merge_ratio;
-                std::vector<int> positions(n_pos * 4, 0);
-                int ptr = 0;
-                for (int ty = 0; ty < ph; ty++) {
-                    for (int tx = 0; tx < pw; tx++) {
-                        for (int dy = 0; dy < merge_ratio; dy++) {
-                            for (int dx = 0; dx < merge_ratio; dx++) {
-                                positions[    num_patches + ptr] = ty * merge_ratio + dy;
-                                positions[2 * num_patches + ptr] = tx * merge_ratio + dx;
-                                ptr++;
-                            }
-                        }
-                    }
-                }
-                set_input_i32("positions", positions);
+                n_patches /= params.n_merge * params.n_merge;
             } break;
         case PROJECTOR_TYPE_QWEN2VL:
+        case PROJECTOR_TYPE_QWEN25VL:
         case PROJECTOR_TYPE_QWEN3VL:
+        case PROJECTOR_TYPE_MAGEVL:
+        case PROJECTOR_TYPE_SENSENOVA_U1:
         case PROJECTOR_TYPE_LING3VL:
+        case PROJECTOR_TYPE_EXAONE4_5:
+        case PROJECTOR_TYPE_MIMOVL:
+        case PROJECTOR_TYPE_MINIMAX_M3:
         case PROJECTOR_TYPE_GLM4V:
         case PROJECTOR_TYPE_GLM5V:
+        case PROJECTOR_TYPE_YOUTUVL:
+        case PROJECTOR_TYPE_MUSE_GLIMMER:
+        case PROJECTOR_TYPE_LFM3VL:
             {
-                const int merge_ratio = hparams.n_merge;
-                const int pw = image_size_width  / patch_size / merge_ratio;
-                const int ph = image_size_height / patch_size / merge_ratio;
-                std::vector<int> positions(n_pos * 4);
-                int ptr = 0;
-                for (int ty = 0; ty < ph; ty++) {
-                    for (int tx = 0; tx < pw; tx++) {
-                        for (int dy = 0; dy < merge_ratio; dy++) {
-                            for (int dx = 0; dx < merge_ratio; dx++) {
-                                const int y = ty * merge_ratio + dy;
-                                const int x = tx * merge_ratio + dx;
-                                positions[                  ptr] = y;
-                                positions[    num_patches + ptr] = x;
-                                positions[2 * num_patches + ptr] = y;
-                                positions[3 * num_patches + ptr] = x;
-                                ptr++;
-                            }
-                        }
-                    }
-                }
-
-                set_input_i32("positions", positions);
+                // dynamic size (2 conv, so double patch size)
+                int x_patch = img->nx() / (params.patch_size * 2);
+                int y_patch = img->ny() / (params.patch_size * 2);
+                n_patches = x_patch * y_patch;
             } break;
         case PROJECTOR_TYPE_STEP3VL:
             {
-                std::vector<int32_t> pos_data(n_pos);
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i / pos_w;
-                }
-                set_input_i32("pos_h", pos_data);
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i % pos_w;
-                }
-                set_input_i32("pos_w", pos_data);
-            } break;
-        case PROJECTOR_TYPE_PADDLEOCR:
-            {
-                const int merge_ratio = hparams.n_merge;
-                const int pw = image_size_width  / patch_size / merge_ratio;
-                const int ph = image_size_height / patch_size / merge_ratio;
-                std::vector<int> positions(n_pos * 4);
-                int ptr = 0;
-                // NOTE: same as Qwen-VL, but x and y are swapped
-                for (int ty = 0; ty < ph; ty++) {
-                    for (int dy = 0; dy < merge_ratio; dy++) {
-                        for (int tx = 0; tx < pw; tx++) {
-                            for (int dx = 0; dx < merge_ratio; dx++) {
-                                const int y = ty * merge_ratio + dy;
-                                const int x = tx * merge_ratio + dx;
-                                positions[                  ptr] = y;
-                                positions[    num_patches + ptr] = x;
-                                positions[2 * num_patches + ptr] = y;
-                                positions[3 * num_patches + ptr] = x;
-                                ptr++;
-                            }
-                        }
-                    }
-                }
-
-                set_input_i32("positions", positions);
-            } break;
-        case PROJECTOR_TYPE_MINIMAX_M3:
-            {
-                const int n_merge = hparams.n_merge;
-                const int gh = image_size_height / patch_size;
-                const int gw = image_size_width  / patch_size;
-                std::vector<int32_t> pos_h, pos_w;
-                pos_h.reserve(gh * gw);
-                pos_w.reserve(gh * gw);
-                for (int bh = 0; bh < gh / n_merge; bh++)
-                for (int bw = 0; bw < gw / n_merge; bw++)
-                for (int mh = 0; mh < n_merge; mh++)
-                for (int mw = 0; mw < n_merge; mw++) {
-                    pos_h.push_back(bh * n_merge + mh);
-                    pos_w.push_back(bw * n_merge + mw);
-                }
-                set_input_i32("minimax_pos_h", pos_h);
-                set_input_i32("minimax_pos_w", pos_w);
-            } break;
-        case PROJECTOR_TYPE_DOTS_OCR:
-        case PROJECTOR_TYPE_DOTS3NOTE_V:
-            {
-                const int pw = image_size_width / patch_size;
-                const int ph = image_size_height / patch_size;
-                const int n_pos = ph * pw;
-                std::vector<int> positions(n_pos * 4);
-                int ptr = 0;
-
-                // flat layout: [h, w, h, w] for each patch
-                // patches are in raster order (matching conv2d output)
-                for (int y = 0; y < ph; y++) {
-                    for (int x = 0; x < pw; x++) {
-                        positions[          ptr] = y;
-                        positions[  n_pos + ptr] = x;
-                        positions[2*n_pos + ptr] = y;
-                        positions[3*n_pos + ptr] = x;
-                        ptr++;
-                    }
-                }
-
-                set_input_i32("positions", positions);
+                int x_patch = img->nx() / (params.patch_size * params.n_merge);
+                int y_patch = img->ny() / (params.patch_size * params.n_merge);
+                n_patches = x_patch * y_patch;
             } break;
-        case PROJECTOR_TYPE_QWEN25VL:
-        case PROJECTOR_TYPE_EXAONE4_5:
-        case PROJECTOR_TYPE_YOUTUVL:
+        case PROJECTOR_TYPE_GEMMA3:
+        case PROJECTOR_TYPE_GEMMA4V:
+        case PROJECTOR_TYPE_GEMMA4UV:
+        case PROJECTOR_TYPE_IDEFICS3:
+        case PROJECTOR_TYPE_COHERE2V:
+        case PROJECTOR_TYPE_INTERNVL:
+        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
+        case PROJECTOR_TYPE_NEMOTRON_OMNI:
+        case PROJECTOR_TYPE_LLAMA4:
             {
-                // pw * ph = number of tokens output by ViT after apply patch merger
-                // ipw * ipw = number of vision token been processed inside ViT
-                const bool use_window_attn =
-                    (ctx->model.proj_type == PROJECTOR_TYPE_QWEN25VL || ctx->model.proj_type == PROJECTOR_TYPE_EXAONE4_5)
-                        ? hparams.n_wa_pattern > 0
-                        : !hparams.wa_layer_indexes.empty();
-                const int merge_ratio = 2;
-                const int pw  = image_size_width  / patch_size / merge_ratio;
-                const int ph  = image_size_height / patch_size / merge_ratio;
-                const int ipw = image_size_width  / patch_size;
-                const int iph = image_size_height / patch_size;
-
-                std::vector<int> idx    (ph * pw);
-                std::vector<int> inv_idx(ph * pw);
-
-                if (use_window_attn) {
-                    const int attn_window_size = hparams.attn_window_size > 0 ? hparams.attn_window_size : 112;
-                    const int grid_window = attn_window_size / patch_size / merge_ratio;
-                    int dst = 0;
-                    // [num_vision_tokens, num_vision_tokens] attention mask tensor
-                    std::vector<float> mask(pow(ipw * iph, 2), std::numeric_limits<float>::lowest());
-                    int mask_row = 0;
-
-                    for (int y = 0; y < ph; y += grid_window) {
-                        for (int x = 0; x < pw; x += grid_window) {
-                            const int win_h = std::min(grid_window, ph - y);
-                            const int win_w = std::min(grid_window, pw - x);
-                            const int dst_0 = dst;
-                            // group all tokens belong to the same window togather (to a continue range)
-                            for (int dy = 0; dy < win_h; dy++) {
-                                for (int dx = 0; dx < win_w; dx++) {
-                                    const int src = (y + dy) * pw + (x + dx);
-                                    GGML_ASSERT(src < (int)idx.size());
-                                    GGML_ASSERT(dst < (int)inv_idx.size());
-                                    idx    [src] = dst;
-                                    inv_idx[dst] = src;
-                                    dst++;
-                                }
-                            }
-
-                            for (int r=0; r < win_h * win_w * merge_ratio * merge_ratio; r++) {
-                                int row_offset = mask_row * (ipw * iph);
-                                std::fill(
-                                    mask.begin() + row_offset + (dst_0 * merge_ratio * merge_ratio),
-                                    mask.begin() + row_offset + (dst   * merge_ratio * merge_ratio),
-                                    0.0);
-                                mask_row++;
-                            }
-                        }
-                    }
-
-                    set_input_i32("window_idx",     idx);
-                    set_input_i32("inv_window_idx", inv_idx);
-                    set_input_attn_mask(mask);
-                } else {
-                    for (int i = 0; i < ph * pw; i++) {
-                        idx[i] = i;
-                    }
-                }
-
-                const int mpow = merge_ratio * merge_ratio;
-                std::vector<int> positions(n_pos * 4);
-
-                int ptr = 0;
-                for (int ty = 0; ty < ph; ty++) {
-                    for (int tx = 0; tx < pw; tx++) {
-                        for (int dy = 0; dy < merge_ratio; dy++) {
-                            for (int dx = 0; dx < merge_ratio; dx++) {
-                                auto remap = idx[ptr / mpow];
-                                remap = (remap * mpow) + (ptr % mpow);
-
-                                const int y = ty * merge_ratio + dy;
-                                const int x = tx * merge_ratio + dx;
-                                positions[                  remap] = y;
-                                positions[    num_patches + remap] = x;
-                                positions[2 * num_patches + remap] = y;
-                                positions[3 * num_patches + remap] = x;
-                                ptr++;
-                            }
-                        }
-                    }
-                }
-
-                set_input_i32("positions", positions);
+                // both X and Y are downscaled by the scale factor
+                int scale_factor = hparams.n_merge;
+                n_patches /= (scale_factor * scale_factor);
             } break;
-        case PROJECTOR_TYPE_MIMOVL:
+        case PROJECTOR_TYPE_GEMMA3NV:
             {
-                const int merge      = hparams.n_merge;        // 2
-                const int merge_unit = merge * merge;          // 4
-                const int patch      = hparams.patch_size;     // 16
-                const int H          = image_size_height / patch;
-                const int W          = image_size_width  / patch;
-                const int n_pos_full = H * W;
-                const int llm_h      = H / merge;
-                const int llm_w      = W / merge;
-                const int n_units    = llm_h * llm_w;          // n_pos / merge_unit
-
-                // Row-major merge-tile-ordered (h, w) positions
-                std::vector<int32_t> pos_h_row(n_pos_full);
-                std::vector<int32_t> pos_w_row(n_pos_full);
-                {
-                    int idx = 0;
-                    for (int ty = 0; ty < llm_h; ty++) {
-                        for (int tx = 0; tx < llm_w; tx++) {
-                            for (int dy = 0; dy < merge; dy++) {
-                                for (int dx = 0; dx < merge; dx++) {
-                                    pos_h_row[idx] = ty * merge + dy;
-                                    pos_w_row[idx] = tx * merge + dx;
-                                    idx++;
-                                }
-                            }
-                        }
-                    }
-                }
-
-                // Col-major merge-unit permutation
-                std::vector<float> idx_col(n_units);
-                for (int r = 0; r < llm_h; r++) {
-                    for (int c = 0; c < llm_w; c++) {
-                        int u_row = r * llm_w + c;
-                        int u_col = c * llm_h + r;
-                        idx_col[u_col] = (float) u_row;
-                    }
-                }
-
-                // Col-mode positions: permute pos_*_row by idx_col
-                std::vector<int32_t> pos_h_col(n_pos_full);
-                std::vector<int32_t> pos_w_col(n_pos_full);
-                for (int u = 0; u < n_units; u++) {
-                    int src = (int) idx_col[u];
-                    for (int k = 0; k < merge_unit; k++) {
-                        pos_h_col[u * merge_unit + k] = pos_h_row[src * merge_unit + k];
-                        pos_w_col[u * merge_unit + k] = pos_w_row[src * merge_unit + k];
-                    }
-                }
-
-                // Pack into ggml_rope_multi VISION-mode layout. The non-CPU kernels
-                // only read slots 0 and 1, so pack h in slot 0, w in slot 1:
-                //   positions[0..n_pos)         = h
-                //   positions[n_pos..2*n_pos)   = w
-                //   positions[2*n_pos..3*n_pos) = 0
-                //   positions[3*n_pos..4*n_pos) = 0
-                std::vector<int32_t> positions_row(static_cast<size_t>(n_pos_full) * 4, 0);
-                std::vector<int32_t> positions_col(static_cast<size_t>(n_pos_full) * 4, 0);
-                for (int i = 0; i < n_pos_full; i++) {
-                    positions_row[0 * n_pos_full + i] = pos_h_row[i];
-                    positions_row[1 * n_pos_full + i] = pos_w_row[i];
-                    positions_col[0 * n_pos_full + i] = pos_h_col[i];
-                    positions_col[1 * n_pos_full + i] = pos_w_col[i];
-                }
-
-                // Banded 1D sliding-window mask
-                const int window = hparams.attn_window_size;
-                GGML_ASSERT(window > 0);
-                std::vector<float> mask(static_cast<size_t>(n_pos_full) * n_pos_full, std::numeric_limits<float>::lowest());
-                for (int q = 0; q < n_pos_full; q++) {
-                    int lo = std::max(0, q - window);
-                    int hi = std::min(n_pos_full - 1, q + window);
-                    for (int k = lo; k <= hi; k++) {
-                        mask[static_cast<size_t>(q) * n_pos_full + k] = 0.0f;
-                    }
-                }
-
-                set_input_i32("mimovl_positions_row", positions_row);
-                set_input_i32("mimovl_positions_col", positions_col);
-                set_input_f32("mimovl_idx_col",       idx_col);
-                set_input_attn_mask(mask);
+                // MobileNetV5 MSFA adapter always outputs fixed 16x16 resolution
+                // regardless of input size (see architecture description)
+                n_patches = hparams.image_size / hparams.patch_size;
             } break;
-        case PROJECTOR_TYPE_PIXTRAL:
+        case PROJECTOR_TYPE_LFM2:
+        case PROJECTOR_TYPE_D1OMNI_V:
         case PROJECTOR_TYPE_KIMIVL:
         case PROJECTOR_TYPE_KIMIK25:
-        case PROJECTOR_TYPE_LIGHTONOCR:
             {
-                // set the 2D positions
-                int n_patches_per_col = image_size_width / patch_size;
-                std::vector<int> pos_data(n_pos);
-                // dimension H
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i / n_patches_per_col;
-                }
-                set_input_i32("pos_h", pos_data);
-                // dimension W
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i % n_patches_per_col;
-                }
-                set_input_i32("pos_w", pos_data);
-            } break;
-        case PROJECTOR_TYPE_SENSENOVA_U1:
-            {
-                const int n_patches_per_row = image_size_width / patch_size;
-                std::vector<int> pos_data(n_pos);
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i / n_patches_per_row;
-                }
-                set_input_i32("pos_y", pos_data);
-                for (int i = 0; i < n_pos; i++) {
-                    pos_data[i] = i % n_patches_per_row;
-                }
-                set_input_i32("pos_x", pos_data);
+                // dynamic size
+                int out_patch_size = params.patch_size * hparams.n_merge;
+                int x_patch = MTMD_ALIGN(img->nx(), out_patch_size) / out_patch_size;
+                int y_patch = MTMD_ALIGN(img->ny(), out_patch_size) / out_patch_size;
+                n_patches = x_patch * y_patch;
             } break;
         case PROJECTOR_TYPE_DEEPSEEK4V:
             {
-                // set the 2D positions (mrope layout, only the first 2 channels are used)
-                int n_patches_per_row = image_size_width / patch_size;
-                std::vector<int32_t> positions(n_pos * 4, 0);
-                for (int i = 0; i < n_pos; i++) {
-                    positions[i]         = i / n_patches_per_row; // row
-                    positions[n_pos + i] = i % n_patches_per_row; // col
-                }
-                set_input_i32("positions", positions);
-
-                // token block layout index (see clip_graph_deepseek4v::build)
-                // rows [0, n_grid) are the aligner output, the sentinels follow
-                const int n_merge = hparams.n_merge;
-                const int n_llm_w = CLIP_ALIGN(pos_w, n_merge) / n_merge;
-                const int n_llm_h = CLIP_ALIGN(pos_h, n_merge) / n_merge;
-                const int n_grid  = n_llm_w * n_llm_h;
-                const int idx_start   = n_grid;
-                const int idx_end     = n_grid + 1;
-                const int idx_newline = n_grid + 2;
-                const int idx_pad     = n_grid + 3;
-
-                const int lead_pad = imgs.entries[0].lead_pad;
-                const auto bl = dsv4_get_block_layout(n_llm_w, n_llm_h, lead_pad);
-
-                std::vector<int32_t> idx;
-                idx.reserve(bl.n_out);
-                for (int i = 0; i < lead_pad; i++) {
-                    idx.push_back(idx_pad);
-                }
-                idx.push_back(idx_start);
-                // pairs of adjacent rows are interleaved column-wise ("N-layout")
-                // ref: build_image_block in inference/image_processor.py
-                for (int t = 0; t < bl.rows * bl.row_len; t++) {
-                    const int g   = t / (2 * bl.row_len);
-                    const int rem = t % (2 * bl.row_len);
-                    const int c   = rem / 2; // column
-                    const int r   = 2 * g + rem % 2; // row
-                    if (r >= n_llm_h) {
-                        idx.push_back(idx_pad);
-                    } else if (c == n_llm_w) {
-                        idx.push_back(idx_newline);
-                    } else {
-                        idx.push_back(r * n_llm_w + c);
-                    }
-                }
-                for (int i = 0; i < bl.pad_last; i++) {
-                    idx.push_back(idx_pad);
-                }
-                idx.push_back(idx_end);
-                set_input_i32("layout_idx", idx);
+                const int out_patch_size = params.patch_size * params.n_merge;
+                const int n_llm_w = MTMD_ALIGN(img->nx(), out_patch_size) / out_patch_size;
+                const int n_llm_h = MTMD_ALIGN(img->ny(), out_patch_size) / out_patch_size;
+                n_patches = dsv4_get_block_layout(n_llm_w, n_llm_h, img->lead_pad).n_out;
             } break;
         case PROJECTOR_TYPE_DEEPSEEK41V:
             {
-                // mrope layout, only the first 2 channels are used
-                const int n_patches_per_row = image_size_width / patch_size;
-                std::vector<int32_t> positions(n_pos * 4, 0);
-                for (int i = 0; i < n_pos; i++) {
-                    positions[i]         = i / n_patches_per_row;
-                    positions[n_pos + i] = i % n_patches_per_row;
-                }
-                set_input_i32("positions", positions);
-
-                // reading-order block: rows [0, n_grid) are the aligner output, then start / end / newline
-                const int n_merge = hparams.n_merge;
-                const int n_llm_w = CLIP_ALIGN(pos_w, n_merge) / n_merge;
-                const int n_llm_h = CLIP_ALIGN(pos_h, n_merge) / n_merge;
-                const int n_grid  = n_llm_w * n_llm_h;
-                std::vector<int32_t> idx;
-                idx.reserve(dsv41_n_block_tokens(n_llm_w, n_llm_h));
-                idx.push_back(n_grid);         // IMAGE_START
-                for (int r = 0; r < n_llm_h; r++) {
-                    for (int c = 0; c < n_llm_w; c++) {
-                        idx.push_back(r * n_llm_w + c);
-                    }
-                    idx.push_back(n_grid + 2); // IMAGE_NEW_LINE
-                }
-                idx.push_back(n_grid + 1);     // IMAGE_END
-                set_input_i32("layout_idx", idx);
+                const int out_patch_size = params.patch_size * params.n_merge;
+                const int n_llm_w = MTMD_ALIGN(img->nx(), out_patch_size) / out_patch_size;
+                const int n_llm_h = MTMD_ALIGN(img->ny(), out_patch_size) / out_patch_size;
+                n_patches = dsv41_n_block_tokens(n_llm_w, n_llm_h);
             } break;
-        case PROJECTOR_TYPE_GLM_EDGE:
-        {
-            // llava and other models
-            std::vector<int32_t> positions(n_pos);
-            for (int i = 0; i < n_pos; i++) {
-                positions[i] = i;
-            }
-            set_input_i32("positions", positions);
-        } break;
-        case PROJECTOR_TYPE_MLP:
-        case PROJECTOR_TYPE_MLP_NORM:
-        case PROJECTOR_TYPE_LDP:
-        case PROJECTOR_TYPE_LDPV2:
+        case PROJECTOR_TYPE_PADDLEOCR:
+        case PROJECTOR_TYPE_DOTS_OCR:
+        case PROJECTOR_TYPE_DOTS3NOTE_V:
             {
-                // llava and other models
-                std::vector<int32_t> positions(n_pos);
-                for (int i = 0; i < n_pos; i++) {
-                    positions[i] = i;
-                }
-                set_input_i32("positions", positions);
-
-                // The patches vector is used to get rows to index into the embeds with;
-                // we should skip dim 0 only if we have CLS to avoid going out of bounds
-                // when retrieving the rows.
-                int patch_offset = model.class_embedding ? 1 : 0;
-                std::vector<int32_t> patches(num_patches);
-                for (int i = 0; i < num_patches; i++) {
-                    patches[i] = i + patch_offset;
-                }
-                set_input_i32("patches", patches);
+                // dynamic size
+                int n_merge = hparams.n_merge;
+                int stride = n_merge * n_merge;
+                n_patches = MTMD_ALIGN(n_patches, stride) / stride;
             } break;
-        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
+        case PROJECTOR_TYPE_DOTS3NOTE_A:
             {
-                set_pockettts_tfm_inputs();
+                // 3x stride-2 conv2d over mel frames
+                n_patches = (img->nx() + 7) / 8;
             } break;
-        case PROJECTOR_TYPE_POCKETTTS_GEN:
+        case PROJECTOR_TYPE_PIXTRAL:
+        case PROJECTOR_TYPE_LIGHTONOCR:
             {
-                if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-                    GGML_ASSERT(params->feats != nullptr);
-                    set_input_f32("inp_feats", *params->feats);
-                    // positions and mask are derived in-graph from the persisted counter
-                    set_gen_state_in();
+                // dynamic size
+                int n_merge = hparams.n_merge;
+                int n_patches_x = img->nx() / patch_size / n_merge;
+                int n_patches_y = img->ny() / patch_size / n_merge;
+                if (token_embd_img_break) {
+                    n_patches = n_patches_y * n_patches_x + n_patches_y - 1; // + one [IMG_BREAK] per row, except the last row
                 } else {
-                    // flow matching starts from gaussian noise, std = sqrt(temp)
-                    ggml_tensor * t = get_inp_tensor("inp_noise");
-                    // Config.default_temperature, for a caller that does not set one
-                    const float temp = params->temp > 0.0f ? params->temp : 0.7f;
-                    std::normal_distribution<float> dist(0.0f, std::sqrt(temp));
-                    std::vector<float> noise(ggml_nelements(t));
-                    for (auto & v : noise) {
-                        v = dist(params->gen_state->rng);
-                    }
-                    set_input_f32("inp_noise", noise);
+                    n_patches = n_patches_y * n_patches_x;
                 }
             } break;
-        case PROJECTOR_TYPE_SOPRANO:
+        case PROJECTOR_TYPE_VOXTRAL_RT:
             {
-                set_input_f32("inp_feats", *params->feats);
+                // 2 context frames, conv stride 2, then stacked frames
+                n_patches = (img->nx() - 2) / 2 / hparams.proj_stack_factor;
             } break;
-        case PROJECTOR_TYPE_GEMMA4V:
-        case PROJECTOR_TYPE_GEMMA4UV:
+        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
             {
-                // set (col, row) patch positions for learned positional embedding
-                const int n_cols = image_size_width  / patch_size;
-                std::vector<int> pos_x(num_patches), pos_y(num_patches);
-                for (int i = 0; i < num_patches; i++) {
-                    pos_x[i] = i % n_cols;
-                    pos_y[i] = i / n_cols;
-                }
-                set_input_i32("pos_x", pos_x);
-                set_input_i32("pos_y", pos_y);
+                const int64_t fold = hparams.audio_samples_per_token();
+                n_patches = (int) ((img->nx() + fold - 1) / fold);
             } break;
-        case PROJECTOR_TYPE_DEEPSEEKOCR:
-        case PROJECTOR_TYPE_DEEPSEEKOCR2:
+        case PROJECTOR_TYPE_VOXTRAL:
+        case PROJECTOR_TYPE_ULTRAVOX:
+        case PROJECTOR_TYPE_QWEN2A:
+        case PROJECTOR_TYPE_MERALION:
+        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
             {
-                GGML_ASSERT(
-                    (pos_w == pos_h) // overview image
-                    || (pos_h >= pos_w && pos_h % pos_w == 0) // tile images
-                );
-
-                const int window = hparams.attn_window_size;
-                const int pos = pos_w;
-                std::vector<int32_t> rel_pos_indices_local(window * window);
-                std::vector<int32_t> rel_pos_indices_global(pos * pos);
-
-                for (int q = 0; q < window; q++) {
-                    for (int k = 0; k < window; k++) {
-                        rel_pos_indices_local[q * window + k] = q - k + window - 1;
-                    }
-                }
+                n_patches = img->nx();

-                for (int q = 0; q < pos; q++) {
-                    for (int k = 0; k < pos; k++) {
-                        rel_pos_indices_global[q * pos + k] = q - k + pos - 1;
-                    }
+                const int proj_stack_factor = hparams.proj_stack_factor;
+                if (audio_has_stack_frames()) {
+                    GGML_ASSERT(proj_stack_factor > 0);
+                    const int n_len = MTMD_ALIGN(n_patches, proj_stack_factor);
+                    n_patches = n_len / proj_stack_factor;
                 }

-                set_input_i32("rel_pos_indices_local", rel_pos_indices_local);
-                set_input_i32("rel_pos_indices_global", rel_pos_indices_global);
-
-                if (ctx->proj_type() == PROJECTOR_TYPE_DEEPSEEKOCR2) {
-
-                    // qwen2 encoder attention mask
-
-                    // num_image_tokens = num_patches / 16
-                    //   256 for 1024 global view
-                    //   144 for 768 tile views
-                    const int   num_image_tokens = num_patches / 16;
-                    const int   seq_len          = num_image_tokens * 2;
-                    std::vector qwen2_mask(static_cast<size_t>(seq_len) * seq_len, 0.0f);
-
-                    // attention mask layout
-                    //  +--------------+---------------+
-                    //  |    all 0     |   all -inf    |
-                    //  +--------------+---------------+
-                    //  |    all 0     |  lower tri 0  |
-                    //  +--------------+---------------+
-                    for (int i = 0; i < seq_len; i++) {
-                        for (int j = 0; j < seq_len; j++) {
-                            const bool zero = i < num_image_tokens ?
-                                                     j < num_image_tokens :
-                                                     j < num_image_tokens || j <= i;
-                            qwen2_mask[static_cast<size_t>(i) * seq_len + j] = zero ? 0.0f : -1e9f;
-                        }
-                    }
-                    set_input_attn_mask(qwen2_mask);
-                }
-            } break;
-        case PROJECTOR_TYPE_CHATTERBOX:
-            {
-                if (params->gen_process == CLIP_GEN_PROCESS_TTS_VOCODE) {
-                    set_input_f32("inp_mel",   *params->mel_in);
-                    set_input_f32("inp_sstft", *params->sstft_in);
-                    break;
-                }
-                if (params->gen_process == CLIP_GEN_PROCESS_TOKENIZE) {
-                    const int T1 = (imgs.entries[0].nx() - 1) / 2 + 1;
-                    const int T2 = (T1 - 1) / 2 + 1;
-                    std::vector<int32_t> pos(T2);
-                    for (int i = 0; i < T2; i++) {
-                        pos[(size_t) i] = i;
-                    }
-                    set_input_i32("inp_pos", pos);
-                    break;
-                }
-                if (params->gen_process != CLIP_GEN_PROCESS_TTS) {
-                    break;
-                }
-                const int n_gen = (int) params->codes->size();
-                GGML_ASSERT(params->ref);
-                const int n_prompt = (int) params->ref->tokens.size();
-                std::vector<int32_t> tokens = params->ref->tokens;
-                tokens.resize((size_t) n_prompt + n_gen);
-                memcpy(tokens.data() + n_prompt, params->codes->data(), (size_t) n_gen * sizeof(int32_t));
-                const int64_t n_codes = model.cbx.input_embedding_w->ne[1];
-                for (int32_t t : tokens) {
-                    if (t < 0 || t >= n_codes) {
-                        LOG_ERR("%s: speech token out of range (%d, codebook size %lld)\n", __func__, t, (long long) n_codes);
-                        return false;
-                    }
-                }
-                const int T1 = n_prompt + n_gen;
-                const int T2 = 2 * T1;
-                set_input_i32("inp_tokens", tokens);
-
-                // mel-rate reference conditioning of the voice
-                set_input_f32("inp_prompt_feat", params->ref->feat);
-                set_input_f32("inp_xvec", params->ref->spk);
-
-                // espnet relative positional encoding, entry k holds the sinusoid of relative position (T-1) - k
-                auto fill_pos = [&](const char * name, int T) {
-                    const int d = 512;
-                    std::vector<float> pos((size_t) (2 * T - 1) * d);
-                    for (int k = 0; k < 2 * T - 1; k++) {
-                        const double rel = (double) (T - 1 - k);
-                        for (int i = 0; i < d / 2; i++) {
-                            const double div = exp(-(double) (2 * i) * log(10000.0) / d);
-                            pos[(size_t) k * d + 2 * i    ] = (float) sin(rel * div);
-                            pos[(size_t) k * d + 2 * i + 1] = (float) cos(rel * div);
-                        }
-                    }
-                    set_input_f32(name, pos);
-                };
-                fill_pos("inp_pos1", T1);
-                fill_pos("inp_pos2", T2);
-
-                // noise and the time embeddings of the solver span points, matcha layout (sines then cosines, scale 1000)
-                std::vector<float> noise((size_t) 80 * T2);
-                std::normal_distribution<float> nd(0.0f, 1.0f);
-                for (auto & f : noise) f = nd(params->gen_state->rng);
-                set_input_f32("inp_noise", noise);
-
-                const bool meanflow = model.cbx.time_embed_mixer_w != nullptr;
-                const int n_steps = meanflow ? 2 : 10;
-                std::vector<float> temb((size_t) 320 * (n_steps + 1));
-                for (int s = 0; s <= n_steps; s++) {
-                    const double u = (double) s / n_steps;
-                    const double t = meanflow ? u : 1.0 - cos(u * M_PI / 2.0);
-                    for (int i = 0; i < 160; i++) {
-                        const double div = exp(-(double) i * log(10000.0) / 159.0);
-                        temb[(size_t) s * 320 + i      ] = (float) sin(1000.0 * t * div);
-                        temb[(size_t) s * 320 + i + 160] = (float) cos(1000.0 * t * div);
-                    }
+                // whisper downscales input token by half after conv1d
+                n_patches /= 2;
+
+                if (audio_has_avgpool()) {
+                    // divide by 2 because of nn.AvgPool1d(2, stride=2)
+                    n_patches /= 2;
                 }
-                set_input_f32("inp_temb", temb);
             } break;
-        case PROJECTOR_TYPE_GEMMA3:
-        case PROJECTOR_TYPE_GEMMA3NV:
-        case PROJECTOR_TYPE_IDEFICS3:
-        case PROJECTOR_TYPE_COHERE2V:
-        case PROJECTOR_TYPE_INTERNVL:
-        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
-        case PROJECTOR_TYPE_NEMOTRON_OMNI:
-        case PROJECTOR_TYPE_QWEN2A:
         case PROJECTOR_TYPE_QWEN3A:
-        case PROJECTOR_TYPE_GLMA:
-        case PROJECTOR_TYPE_ULTRAVOX:
-        case PROJECTOR_TYPE_LFM2:
-        case PROJECTOR_TYPE_D1OMNI_V:
-        case PROJECTOR_TYPE_VOXTRAL:
-        case PROJECTOR_TYPE_MERALION:
-        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
-        case PROJECTOR_TYPE_JANUS_PRO:
-        case PROJECTOR_TYPE_PHI4:
-        case PROJECTOR_TYPE_COGVLM:
-        case PROJECTOR_TYPE_YASA2:
-        case PROJECTOR_TYPE_GEMMA4UA:
-        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
-        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
-            {
-                // do nothing
-            } break;
-        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
             {
-                // the ceil-mode seg pooling divides by the full segment length, rescale the last partial segment
-                const int T1 = (imgs.entries[0].nx() - 1) / 2 + 1;
-                const int S  = (T1 + 99) / 100;
-                std::vector<float> segfix((size_t) S, 1.0f);
-                const int last = T1 - (S - 1) * 100;
-                segfix[(size_t) S - 1] = 100.0f / (float) last;
-                set_input_f32("inp_segfix", segfix);
+                // chunk_size=100 frames --> 3x stride-2 conv2d --> 13 tokens per chunk
+                const int chunk_size       = 100;
+                const int tokens_per_chunk = 13;
+                n_patches = (img->nx() / chunk_size) * tokens_per_chunk;
             } break;
-        case PROJECTOR_TYPE_LFM3A_GEN:
+        case PROJECTOR_TYPE_GLMA:
             {
-                const int64_t n_cb    = model.gen_code_embd_w->ne[2];
-                const int64_t n_vocab = model.gen_code_embd_w->ne[1];
-                if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-                    GGML_ASSERT(params->codes != nullptr);
-                    // codes index the FSQ table via ggml_get_rows, the end-of-audio code has no waveform
-                    const int64_t n_codes = model.c2w.quant_first_cb_w->ne[1];
-                    for (int32_t c : *params->codes) {
-                        if (c < 0 || c >= n_codes) {
-                            LOG_ERR("%s: code out of range (%d, codebook size %lld)\n", __func__, c, (long long) n_codes);
-                            return false;
-                        }
-                    }
-                    std::vector<int32_t> codes = *params->codes;
-                    set_input_i32("inp_codes", codes);
-                    set_gen_state_in();
-                    // the "pos" slot leads the state
-                    float base = 0.0f;
-                    ggml_backend_tensor_get(params->gen_state->slots[0], &base, 0, sizeof(float));
-                    set_lfm3a_window_inputs((int) base, (int) ggml_nelements(get_inp_tensor("positions")), hparams.wav_tfm_swa);
-                } else {
-                    // rows of the merged per-codebook tables; a masked codebook (-1) takes row 0, the mask drops it
-                    std::vector<int32_t> codes(n_cb, -1);
-                    if (params->codes) {
-                        GGML_ASSERT(params->codes->size() == (size_t) n_cb);
-                        codes = *params->codes;
-                    }
-                    std::vector<float> mask(n_cb, 0.0f);
-                    for (int64_t i = 0; i < n_cb; i++) {
-                        if (codes[i] >= n_vocab) {
-                            LOG_ERR("%s: code out of range (%d, vocab %lld)\n", __func__, codes[i], (long long) n_vocab);
-                            return false;
-                        }
-                        if (codes[i] < 0) {
-                            codes[i] = 0;
-                        } else {
-                            mask[i] = 1.0f;
-                        }
-                        codes[i] += (int32_t) (i * n_vocab);
-                    }
-                    set_input_i32("inp_codes", codes);
-                    if (ggml_graph_get_tensor(gf, "inp_mask") != nullptr) {
-                        set_input_f32("inp_mask", mask);
-                    }
-                }
+                n_patches = img->nx();
+                // whisper downscales input token by half after conv1d
+                n_patches /= 2;
+                // reshape by merge_factor
+                n_patches /= hparams.proj_stack_factor;
+                // for BOI and EOI token embeddings
+                n_patches += 2;
             } break;
-        case PROJECTOR_TYPE_DRAMA3_GEN:
+        case PROJECTOR_TYPE_COGVLM:
             {
-                const int64_t n_cb      = model.gen_code_embd_w->ne[2];
-                const int64_t n_vocab   = model.gen_code_embd_w->ne[1];
-                if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-                    // frame-major to group-major, clamp: codec residual codebooks are smaller than the tower vocab
-                    GGML_ASSERT(params->codes != nullptr && params->codes->size() % n_cb == 0);
-                    const int64_t n_frames = (int64_t) params->codes->size() / n_cb;
-                    const int64_t n_first  = model.c2w.quant_first_cb_w->ne[1];
-                    const int64_t n_rest   = model.c2w.quant_rest_cb_w->ne[1];
-                    std::vector<int32_t> codes(params->codes->size());
-                    for (int64_t f = 0; f < n_frames; f++) {
-                        for (int64_t g = 0; g < n_cb; g++) {
-                            const int64_t n = g == 0 ? n_first : n_rest;
-                            codes[g * n_frames + f] = std::clamp<int32_t>((*params->codes)[f * n_cb + g], 0, (int32_t) n - 1);
-                        }
-                    }
-                    set_input_i32("inp_codes", codes);
-                    set_gen_state_in();
-                } else {
-                    // code0 < 0: next row is text (prompt), the tower only fills its KV
-                    // codes: input frame codes for a speech row, empty for a text row
-                    const bool next_speech = params->code0 >= 0;
-                    const bool cur_speech  = params->codes && (int64_t) params->codes->size() == n_cb;
-                    if (params->code0 >= n_vocab) {
-                        LOG_ERR("%s: code0 out of range (%d, vocab %lld)\n", __func__, params->code0, (long long) n_vocab);
-                        return false;
-                    }
-                    std::vector<int32_t> code0 = { std::max(params->code0, 0) };
-                    set_input_i32("inp_code0", code0);
-
-                    std::vector<int32_t> extra(n_cb - 2);
-                    for (int64_t g = 2; g < n_cb; g++) {
-                        const int32_t c = cur_speech ? std::clamp<int32_t>((*params->codes)[g], 0, (int32_t) n_vocab - 1) : 0;
-                        extra[g - 2] = c + (int32_t) (g * n_vocab);
-                    }
-                    set_input_i32("inp_extra", extra);
-                    set_input_f32("inp_scale", { next_speech ? 1.0f : 0.0f, cur_speech ? 1.0f : 0.0f });
-
-                    float pos = 0.0f;
-                    ggml_backend_tensor_get(params->gen_state->slots[0], &pos, 0, sizeof(float));
-                    std::vector<int32_t> inp_pos = { (int32_t) pos };
-                    set_input_i32("inp_pos", inp_pos);
-
-                    std::uniform_real_distribution<float> dist(0.0f, 1.0f);
-                    std::vector<float> r(model.gen_code_head_w->ne[2]);
-                    for (auto & v : r) {
-                        v = dist(params->gen_state->rng);
-                    }
-                    set_input_f32("inp_rand", r);
-                }
+                n_patches += 2; // for BOI and EOI token embeddings
             } break;
-        case PROJECTOR_TYPE_QWEN3TTS_GEN:
-            {
-                if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-                    GGML_ASSERT(params->codes != nullptr);
-
-                    // frame-major input to group-major, rear-padded with code 0 up to one window
-                    const int64_t n_codes  = model.gen_code_head_w->ne[2] + 1;
-                    const int64_t n_frames_w = hparams.wav_tfm_swa;
-                    const int64_t n_frames   = (int64_t) params->codes->size() / n_codes;
-                    GGML_ASSERT(n_frames > 0 && n_frames <= n_frames_w);
-
-                    // codes are used as ggml_get_rows indices, so check them against the codebook vocab
-                    const int64_t vocab_first = model.c2w.quant_first_cb_w->ne[1];
-                    const int64_t vocab_rest  = model.c2w.quant_rest_cb_w->ne[1];
-                    for (int64_t f = 0; f < n_frames; f++) {
-                        for (int64_t g = 0; g < n_codes; g++) {
-                            const int32_t c = (*params->codes)[f * n_codes + g];
-                            const int64_t vocab = (g == 0) ? vocab_first : vocab_rest;
-                            if (c < 0 || (int64_t) c >= vocab) {
-                                LOG_ERR("%s: code out of range (frame %lld, group %lld, code %d, vocab %lld)\n",
-                                        __func__, (long long) f, (long long) g, c, (long long) vocab);
-                                return false;
-                            }
-                        }
-                    }
+        case PROJECTOR_TYPE_DEEPSEEKOCR:
+        {
+            // SAM encoder applies two stride-2 convolutions (net_2 and net_3)
+            // that reduce spatial dimensions by 4x in each direction (16x total)
+            // E.g., 64x64 -> 16x16 patches
+            n_patches /= 16;

-                    std::vector<int32_t> codes(n_frames_w * n_codes, 0);
-                    for (int64_t f = 0; f < n_frames; f++) {
-                        for (int64_t g = 0; g < n_codes; g++) {
-                            codes[g * n_frames_w + f] = (*params->codes)[f * n_codes + g];
-                        }
-                    }
-                    set_input_i32("inp_codes", codes);
-                    set_gen_state_in();
-                } else {
-                    // code0 indexes gen_code_out_embd_w via ggml_get_rows; bound it
-                    const int64_t vocab0 = model.gen_code_out_embd_w->ne[1];
-                    if (params->code0 < 0 || (int64_t) params->code0 >= vocab0) {
-                        LOG_ERR("%s: code0 out of range (%d, vocab %lld)\n", __func__, params->code0, (long long) vocab0);
-                        return false;
-                    }
-                    std::vector<int32_t> code0 = { params->code0 };
-                    set_input_i32("inp_code0", code0);
-
-                    // one uniform(0,1) draw per codebook, used by do_sampling()
-                    std::uniform_real_distribution<float> dist(0.0f, 1.0f);
-                    const int64_t n_acoustic = model.gen_code_head_w->ne[2];
-                    for (int64_t g = 0; g < n_acoustic; g++) {
-                        std::vector<float> r = { dist(params->gen_state->rng) };
-                        set_input_f32(("inp_rand_" + std::to_string(g)).c_str(), r);
-                    }
-                }
-            } break;
+            if (img->add_viewsep) {
+                // global view: one image-newline per token-row + trailing view separator
+                const int h = static_cast<int>(std::sqrt(static_cast<float>(n_patches)));
+                n_patches = h * (h + 1) + 1;
+            } else if (img->ny() >= img->nx() && img->ny() % img->nx() == 0) {
+                // tile row: one image-newline per token-row
+                const int grid_w = img->ny() / img->nx();
+                const int tile_patches = img->nx() / (patch_size * 4); // patches per tile side (SAM divides by 4)
+                const int h = tile_patches;
+                n_patches = (tile_patches * grid_w + 1) * h;
+            }
+        } break;
         case PROJECTOR_TYPE_HUNYUANVL:
             {
-                // Compute the HunyuanVL 2D position embedding on CPU (with the
-                // custom sf=(target+0.1)/n_grid bilinear sampling that the
-                // reference implementation uses) and upload it to the graph
-                // input declared in clip_graph_hunyuanvl::build().
-                GGML_ASSERT(model.position_embeddings != nullptr);
-                ggml_tensor * src_t   = model.position_embeddings;
-                const int64_t n_embd  = src_t->ne[0];
-                const int64_t n_pos   = src_t->ne[1];            // = n_grid * n_grid
-                const int     n_grid  = (int)std::lround(std::sqrt((double)n_pos));
-                GGML_ASSERT((int64_t)n_grid * n_grid == n_pos);
-                const int     out_w   = pos_w;                    // pw
-                const int     out_h   = pos_h;                    // ph
-
-                // Pull weight to host.
-                std::vector<float> src(n_embd * n_pos);
-                ggml_backend_tensor_get(src_t, src.data(), 0, ggml_nbytes(src_t));
-
-                // Output layout matches ggml_new_tensor_2d(F32, n_embd, out_h*out_w):
-                //   ne[0] = n_embd (fastest), ne[1] = out_h*out_w
-                //   dst[(y*out_w + x) * n_embd + c]
-                std::vector<float> dst((size_t)n_embd * out_h * out_w);
-
-                const float sx = (float)(out_w + 0.1f) / (float)n_grid;
-                const float sy = (float)(out_h + 0.1f) / (float)n_grid;
-
-                for (int y = 0; y < out_h; ++y) {
-                    // Match ggml_compute_forward_upscale_f32 pixel-center
-                    // convention (align_corners=False): src_y = (y+0.5)/sy - 0.5.
-                    const float fy = ((float)y + 0.5f) / sy - 0.5f;
-                    int y0 = (int)std::floor(fy);
-                    int y1 = y0 + 1;
-                    y0 = std::clamp(y0, 0, n_grid - 1);
-                    y1 = std::clamp(y1, 0, n_grid - 1);
-                    float wy1 = std::clamp(fy - (float)y0, 0.0f, 1.0f);
-                    const float wy0 = 1.0f - wy1;
-                    for (int x = 0; x < out_w; ++x) {
-                        const float fx = ((float)x + 0.5f) / sx - 0.5f;
-                        int x0 = (int)std::floor(fx);
-                        int x1 = x0 + 1;
-                        x0 = std::clamp(x0, 0, n_grid - 1);
-                        x1 = std::clamp(x1, 0, n_grid - 1);
-                        float wx1 = std::clamp(fx - (float)x0, 0.0f, 1.0f);
-                        const float wx0 = 1.0f - wx1;
-
-                        const float w00 = wy0 * wx0;
-                        const float w01 = wy0 * wx1;
-                        const float w10 = wy1 * wx0;
-                        const float w11 = wy1 * wx1;
-
-                        const float * s00 = &src[((size_t)y0 * n_grid + x0) * n_embd];
-                        const float * s01 = &src[((size_t)y0 * n_grid + x1) * n_embd];
-                        const float * s10 = &src[((size_t)y1 * n_grid + x0) * n_embd];
-                        const float * s11 = &src[((size_t)y1 * n_grid + x1) * n_embd];
-                        float * d         = &dst[((size_t)y * out_w + x) * n_embd];
-                        for (int c = 0; c < n_embd; ++c) {
-                            d[c] = w00 * s00[c] + w01 * s01[c] + w10 * s10[c] + w11 * s11[c];
-                        }
-                    }
-                }
-
-                set_input_f32("hunyuanvl_pos_embd", dst);
+                int merge = hparams.n_merge;
+                int ow = (img->nx() / patch_size) / merge;
+                int oh = (img->ny() / patch_size) / merge;
+                n_patches = (ow + 1) * oh + 2;
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEKOCR2:
+        {
+            // 1024 global view -> 256 query tokens + 1 view separator = 257;
+            // 768 local tile   -> 144 query tokens, no separator.
+            n_patches /= 16;
+            if (img->add_viewsep) {
+                n_patches += 1; // view separator, appended only after the global view
+            }
+        } break;
+        case PROJECTOR_TYPE_LFM2A:
+        case PROJECTOR_TYPE_D1OMNI_A:
+        case PROJECTOR_TYPE_LFM3A:
+            {
+                n_patches = ((((img->nx() + 1) / 2) + 1) / 2 + 1) / 2;
             } break;
-        case PROJECTOR_TYPE_LLAMA4:
+        case PROJECTOR_TYPE_GEMMA4A:
             {
-                // set the 2D positions
-                int n_patches_per_col = image_size_width / patch_size;
-                std::vector<int> pos_data(num_patches + 1, 0); // +1 for the [CLS] token
-                // last pos is always kept 0, it's for CLS
-                // dimension H
-                for (int i = 0; i < num_patches; i++) {
-                    pos_data[i] = (i / n_patches_per_col) + 1;
-                }
-                set_input_i32("pos_h", pos_data);
-                // dimension W
-                for (int i = 0; i < num_patches; i++) {
-                    pos_data[i] = (i % n_patches_per_col) + 1;
+                // Two Conv2D stride-2: O = floor((I + 2p - k) / s) + 1, p=1, k=3, s=2
+                // O = floor((I - 1) / 2) + 1
+                int n = img->nx();
+                for (int i = 0; i < 2; i++) {
+                    n = (n - 1) / 2 + 1;
                 }
-                set_input_i32("pos_w", pos_data);
+                n_patches = n;
             } break;
-        case PROJECTOR_TYPE_DOTS3NOTE_A:
+        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+        case PROJECTOR_TYPE_PARAKEET:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                const int n_pos = (imgs.entries.front().nx() + 7) / 8; // 3x stride-2 conv2d
-                std::vector<int32_t> positions(n_pos);
-                for (int i = 0; i < n_pos; i++) {
-                    positions[i] = i;
-                }
-                set_input_i32("positions", positions);
+                n_patches = (img->nx() + (params.subsampling_factor - 1)) / params.subsampling_factor;
             } break;
-        case PROJECTOR_TYPE_GEMMA4A:
+        case PROJECTOR_TYPE_GEMMA4UA:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                const auto & img0 = imgs.entries.front();
-                // Compute n_pos matching SSCP output: two stride-2 convs
-                int n_pos = img0.nx();
-                for (int i = 0; i < 2; i++) { n_pos = (n_pos - 1) / 2 + 1; }
-
-                // Chunked local attention: blocked causal mask and RPE
-                const int chunk_size   = 12;
-                const int max_past     = 12;
-                const int context_size = chunk_size + max_past;
-                const int num_blocks   = (n_pos + chunk_size - 1) / chunk_size;
-
-                // Blocked causal attention mask: [context_size, chunk_size, num_blocks]
-                {
-                    std::vector<float> mask(context_size * chunk_size * num_blocks, -1e9f);
-                    for (int b = 0; b < num_blocks; b++) {
-                        for (int q = 0; q < chunk_size; q++) {
-                            int gq = b * chunk_size + q;
-                            for (int k = 0; k < context_size; k++) {
-                                int gk = b * chunk_size - max_past + k;
-                                if (gq < n_pos && gk >= 0 && gk < n_pos && gk <= gq && (gq - gk) < max_past) {
-                                    mask[k + q * context_size + b * context_size * chunk_size] = 0.0f;
-                                }
-                            }
-                        }
-                    }
-                    set_input_f32("kq_mask", mask);
-                }
-
-                // Sinusoidal RPE: 13 positions [12, 11, ..., 0]
-                {
-                    const int n_embd = ctx->model.hparams.n_embd;
-                    const int num_timescales = n_embd / 2;
-                    const float log_timescale_increment = logf(10000.0f) / std::max(num_timescales - 1, 1);
-                    const int rpe_len = max_past + 1;
-                    std::vector<float> pos_emb(n_embd * rpe_len, 0.0f);
-                    for (int p = 0; p < rpe_len; p++) {
-                        float position = (float)(max_past - p);
-                        for (int i = 0; i < num_timescales; i++) {
-                            float inv_ts = expf(-(float)i * log_timescale_increment);
-                            float scaled = position * inv_ts;
-                            pos_emb[p * n_embd + i]                 = sinf(scaled);
-                            pos_emb[p * n_embd + i + num_timescales] = cosf(scaled);
-                        }
-                    }
-                    set_input_f32("pos_emb", pos_emb);
-                }
+                n_patches = img->nx();  // no downsampling: one token per raw waveform frame
             } break;
         case PROJECTOR_TYPE_MIMO_AUDIO:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                const int n_frames = imgs.entries.front().nx();
-                const int n_pos    = (n_frames - 1) / 2 + 1; // matches conv1(s=1)+conv2(s=2) output length
-
-                std::vector<int32_t> positions(n_pos);
-                for (int i = 0; i < n_pos; i++) {
-                    positions[i] = i;
-                }
-                set_input_i32("mimo_audio_positions", positions);
-
-                const int window = hparams.attn_window_size;
-                GGML_ASSERT(window > 0);
-
-                const float neg_inf = std::numeric_limits<float>::lowest();
-                std::vector<float> full_mask((size_t) n_pos * n_pos);
-                std::vector<float> window_mask((size_t) n_pos * n_pos);
-                for (int q = 0; q < n_pos; q++) {
-                    for (int k = 0; k < n_pos; k++) {
-                        const bool causal_ok = k <= q;
-                        full_mask[(size_t) q * n_pos + k]   = causal_ok ? 0.0f : neg_inf;
-                        window_mask[(size_t) q * n_pos + k] = (causal_ok && (q - k) <= window) ? 0.0f : neg_inf;
-                    }
-                }
-                set_input_attn_mask(full_mask, 0);
-                set_input_attn_mask(window_mask, 1);
-
-                // input_local_transformer: block-diagonal mask + in-group positions
-                {
-                    const int n_pos_ds   = (n_pos - 2) / 2 + 1; // matches downsample conv (k=2,s=2,p=0)
-                    const int group_size = hparams.audio_local_group_size;
-                    GGML_ASSERT(group_size > 0);
-                    const int n_groups = (n_pos_ds + group_size - 1) / group_size;
-                    const int n_padded = n_groups * group_size;
-
-                    std::vector<int32_t> local_positions(n_padded);
-                    for (int i = 0; i < n_padded; i++) {
-                        local_positions[i] = i % group_size;
-                    }
-                    set_input_i32("mimo_audio_local_positions", local_positions);
-
-                    std::vector<float> local_mask((size_t) n_padded * n_padded);
-                    for (int q = 0; q < n_padded; q++) {
-                        for (int k = 0; k < n_padded; k++) {
-                            const bool same_group = (q / group_size) == (k / group_size);
-                            local_mask[(size_t) q * n_padded + k] = same_group ? 0.0f : neg_inf;
-                        }
-                    }
-                    set_input_attn_mask(local_mask, 2);
-                }
+                // conv1(s=1) + conv2(s=2) -> RVQ-encoder downsample conv(k=2,s=2)
+                int n = img->nx();
+                n = (n - 1) / 2 + 1;         // conv1 + conv2
+                n = (n - 2) / 2 + 1;         // downsample conv
+                const int group_size = params.audio_local_group_size;
+                n_patches = (n + group_size - 1) / group_size;
             } break;
-        case PROJECTOR_TYPE_LFM2A:
-        case PROJECTOR_TYPE_D1OMNI_A:
+        case PROJECTOR_TYPE_GRANITE_SPEECH:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                const auto n_frames = clip_n_output_tokens(ctx, &imgs.entries.front());
-
-                auto d_model = 512;
-                auto seq_len = n_frames * 2 - 1;
-                std::vector<float> pos_emb(d_model*seq_len);
-                std::vector<double> inv_freq(d_model / 2);
-                for (size_t i = 0; i < inv_freq.size(); ++i) {
-                    inv_freq[i] = std::exp(-(std::log(10000.0) / (float)d_model) * (2.0f * (float)(i)));
-                }
-                for (int64_t pos = 0; pos < seq_len; ++pos) {
-                    for (size_t i = 0; i < inv_freq.size(); ++i) {
-                        const float ang = (n_frames - pos - 1) * inv_freq[i];
-                        pos_emb[pos*d_model + 2*i + 0] = sinf(ang);  // even
-                        pos_emb[pos*d_model + 2*i + 1] = cosf(ang);  // odd
-                    }
-                }
-                set_input_f32("pos_emb", pos_emb);
+                const int ws = hparams.audio_proj_window_size;
+                const int ds = hparams.audio_proj_downsample_rate;
+                n_patches = ((img->nx() + ws - 1) / ws) * (ws / ds);
             } break;
-        case PROJECTOR_TYPE_LFM3A:
+        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                const int n_pos = clip_n_output_tokens(ctx, &imgs.entries.front());
-
-                // the "pos" slot leads the state; without a stream the state is zero, the clip is encoded from its start
-                int base = 0;
-                if (params->enc_state != nullptr) {
-                    const auto & slots = params->enc_state->slots;
-                    float base_f = 0.0f;
-                    ggml_backend_tensor_get(slots[0], &base_f, 0, sizeof(float));
-                    base = (int) base_f;
-                    size_t i = 0;
-                    for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
-                        ggml_backend_tensor_copy(slots[i++], get_inp_tensor(("state_in_" + slot.name).c_str()));
-                    }
-                } else {
-                    for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
-                        ggml_tensor * t = get_inp_tensor(("state_in_" + slot.name).c_str());
-                        ggml_backend_tensor_memset(t, 0, 0, ggml_nbytes(t));
-                    }
-                }
-
-                set_lfm3a_window_inputs(base, n_pos, hparams.attn_window_size);
+                // pooling gives one speaker embedding, whatever the clip length is
+                n_patches = 1;
             } break;
-        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                std::vector<int32_t> positions(ggml_nelements(get_inp_tensor("positions")));
-                for (size_t i = 0; i < positions.size(); i++) {
-                    positions[i] = (int32_t) i;
-                }
-                set_input_i32("positions", positions);
-                GGML_ASSERT(params->enc_state != nullptr);
-                set_input_f32("inp_norm", params->enc_state->mel_norm);
-                if (ggml_graph_get_tensor(gf, "spk_prev") != nullptr) {
-                    set_input_f32("spk_prev", params->enc_state->spk_prev);
-                }
+                // statistics pooling gives one speaker embedding, whatever the clip length is
+                n_patches = 1;
             } break;
-        case PROJECTOR_TYPE_PARAKEET:
+        case PROJECTOR_TYPE_QWEN3TTS_GEN:
+        case PROJECTOR_TYPE_LFM3A_GEN:
+        case PROJECTOR_TYPE_DRAMA3_GEN:
             {
-                GGML_ASSERT(imgs.entries.size() == 1);
-                struct ggml_tensor * attn_mask = ggml_graph_get_tensor(gf, "attn_mask");
-                const int n_q = attn_mask->ne[1];
-                const int n_k = attn_mask->ne[0];
-                const int n_frames = imgs.entries.front().nx();
-                const int n_tokens_real = (n_frames + hparams.subsampling_factor-1) / hparams.subsampling_factor;
-                const float mask_value  = -1e30f;
-
-                std::vector<float> mask_data(n_q * n_k);
-                if (n_k == n_q) {
-                    // full attention: mask keys that are padding
-                    for (int q = 0; q < n_q; ++q) {
-                        for (int k = 0; k < n_k; ++k) {
-                            mask_data[q * n_k + k] = (k >= n_tokens_real) ? mask_value : 0.0f;
-                        }
-                    }
-                } else {
-                    // local attention: mask keys outside the valid window
-                    const int att_left = n_k / 2;
-                    for (int q = 0; q < n_q; ++q) {
-                        for (int k = 0; k < n_k; ++k) {
-                            const int key = q - att_left + k;
-                            mask_data[q * n_k + k] = (key >= 0 && key < n_tokens_real) ? 0.0f : mask_value;
-                        }
-                    }
-                }
-                set_input_f32(attn_mask->name, mask_data);
-
-                // local attention skew mask: zeroes out the probs that were
-                // computed for keys outside the valid sliding window.
-                if (struct ggml_tensor * local_mask = ggml_graph_get_tensor(gf, "local_mask")) {
-                    const int lm_k = local_mask->ne[0];
-                    const int lm_q = local_mask->ne[1];
-                    const int window_size = lm_k - lm_q + 1;
-                    std::vector<float> lm_data(lm_q * lm_k);
-                    for (int q = 0; q < lm_q; ++q) {
-                        for (int k = 0; k < lm_k; ++k) {
-                            const int rel = k - q;
-                            lm_data[q * lm_k + k] = (rel >= 0 && rel < window_size) ? 1.0f : 0.0f;
-                        }
-                    }
-                    set_input_f32(local_mask->name, lm_data);
-                }
-
-                // Generate rotation frequencies for relative positional encoding.
-                {
-                    const int n_state     = hparams.n_embd;
-                    const int d_half      = n_state / 2;
-                    const float log_10000 = logf(10000.0f);
-                    std::vector<float> freqs(d_half);
-                    for (int k = 0; k < d_half; ++k) {
-                        freqs[k] = expf(-(float(k * 2) * log_10000 / float(n_state)));
-                    }
-                    set_input_f32("pos_freqs", freqs);
-                }
-
-                // Generate relative positional distance values which scaled by
-                // the frequency to produce the angles for sin/cos.
-                {
-                    // window_size is only known after graph construction since it depends on
-                    // n_time from the conv output, so we read it back from the graph tensor.
-                    struct ggml_tensor * rel_pos = ggml_graph_get_tensor(gf, "rel_positions");
-                    const int window_size = rel_pos->ne[1];
-                    std::vector<float> pos(window_size);
-                    // local attention: window is fixed at [att_left, att_right]
-                    // full attention: window covers the full sequence, centered
-                    if (ggml_graph_get_tensor(gf, "local_mask")) {
-                        const int att_left = window_size / 2;
-                        for (int t = 0; t < window_size; ++t) {
-                            pos[t] = float(att_left - t);
-                        }
-                    } else {
-                        const int n_time = (window_size + 1) / 2;
-                        for (int t = 0; t < window_size; ++t) {
-                            pos[t] = float(n_time - 1 - t);
-                        }
-                    }
-                    set_input_f32(rel_pos->name, pos);
-                }
+                // one hidden-state vector fed back to the talker per call
+                n_patches = 1;
             } break;
-        case PROJECTOR_TYPE_GRANITE_SPEECH:
+        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
             {
-                const int context_size = ctx->model.hparams.audio_chunk_size;
-                const int max_pos_emb  = ctx->model.hparams.audio_max_pos_emb;
-
-                std::vector<int32_t> dists((size_t) context_size * (size_t) context_size);
-                for (int i = 0; i < context_size; i++) {
-                    for (int j = 0; j < context_size; j++) {
-                        int d = i - j;
-                        if (d < -context_size) d = -context_size;
-                        if (d >  context_size) d =  context_size;
-                        dists[(size_t) i * (size_t) context_size + (size_t) j] = d + max_pos_emb;
-                    }
-                }
-                set_input_i32("attn_dists", dists);
-
-                const int n_frames   = image_size_width;
-                const int remainder  = n_frames % context_size;
-                if (remainder > 0) {
-                    const int num_blocks = (n_frames + context_size - 1) / context_size;
-                    std::vector<float> mask((size_t) context_size * (size_t) context_size * (size_t) num_blocks, 0.0f);
-                    const float neg_inf = -INFINITY;
-                    const size_t last_block_offset = (size_t) (num_blocks - 1) * (size_t) context_size * (size_t) context_size;
-                    for (int q = 0; q < context_size; q++) {
-                        for (int k = 0; k < context_size; k++) {
-                            if (q >= remainder || k >= remainder) {
-                                mask[last_block_offset + (size_t) q * (size_t) context_size + (size_t) k] = neg_inf;
-                            }
-                        }
-                    }
-                    set_input_f32("attn_mask", mask);
-                }
+                // one conditioning row per 12.5Hz frame
+                const int hop = hparams.mimi_downsample * 120;
+                n_patches = img->nx() / hop;
+            } break;
+        case PROJECTOR_TYPE_POCKETTTS_GEN:
+            {
+                // one latent per call for GEN_CODE, GEN_WAV sizes its input from the caller
+                n_patches = 1;
             } break;
         case PROJECTOR_TYPE_GRANITE4_VISION:
             {
-                // Granite Vision 4.1 uses precomputed permutation index
-                // tensors to express the _win / _unwin / spatial sampling
-                // reshapes as ggml_get_rows gathers. The names are set
-                // by g4v_gather() in models/granite4-vision.cpp.
-                const int patch_size  = model.hparams.patch_size;
-                const int image_side  = imgs.entries.front().nx() / patch_size;
+                // Per-tile output token count: each projector block outputs
+                // query_side^2 tokens per window x n^2 windows.
+                // For 384x384 input: n = 24/8 = 3, query_side = 4 -> 144.
                 const int window_side = hparams.downsample_window_side;
                 const int query_side  = hparams.downsample_query_side;
-                const int n           = image_side / window_side;
-                const int new_side    = n * query_side;
-
-                // Builds the raster→window permutation indices for a
-                // (side, side) grid split into (n × n) windows of (win × win)
-                // tokens each.  dst[w * win*win + p] = source raster index.
-                auto make_win_idx = [](int side, int win) {
-                    const int nn = side / win;
-                    std::vector<int32_t> idx(static_cast<size_t>(side) * side);
-                    for (int wy = 0; wy < nn; ++wy) {
-                        for (int wx = 0; wx < nn; ++wx) {
-                            for (int iy = 0; iy < win; ++iy) {
-                                for (int ix = 0; ix < win; ++ix) {
-                                    const int w  = wy * nn + wx;
-                                    const int p  = iy * win + ix;
-                                    const int y  = wy * win + iy;
-                                    const int x  = wx * win + ix;
-                                    idx[static_cast<size_t>(w) * (win*win) + p] = y * side + x;
-                                }
-                            }
-                        }
-                    }
-                    return idx;
-                };
-
-                auto make_unwin_idx = [&](int side, int win) {
-                    const std::vector<int32_t> fwd = make_win_idx(side, win);
-                    std::vector<int32_t> inv(fwd.size());
-                    for (size_t i = 0; i < fwd.size(); ++i) {
-                        inv[fwd[i]] = static_cast<int32_t>(i);
-                    }
-                    return inv;
-                };
-
-                auto make_spatial_idx = [](int side, int offset) {
-                    const int off_y = (offset >> 1) & 1;
-                    const int off_x = offset & 1;
-                    const int new_s = side / 2;
-                    std::vector<int32_t> idx(static_cast<size_t>(new_s) * new_s);
-                    for (int y = 0; y < new_s; ++y) {
-                        for (int x = 0; x < new_s; ++x) {
-                            idx[y * new_s + x] = (y * 2 + off_y) * side + (x * 2 + off_x);
-                        }
-                    }
-                    return idx;
-                };
-
-                // the same permutation is applied to every tile of the stacked image
-                auto upload = [&](const std::string & name, const std::vector<int32_t> & idx) {
-                    ggml_tensor * t = ggml_graph_get_tensor(gf, name.c_str());
-                    GGML_ASSERT(t);
-                    GGML_ASSERT(ggml_nelements(t) % (int64_t) idx.size() == 0);
-                    const int n_rep = ggml_nelements(t) / idx.size();
-                    std::vector<int32_t> buf;
-                    buf.reserve(idx.size() * n_rep);
-                    for (int i = 0; i < n_rep; ++i) {
-                        buf.insert(buf.end(), idx.begin(), idx.end());
-                    }
-                    ggml_backend_tensor_set(t, buf.data(), 0, ggml_nbytes(t));
-                };
-
-                // Stage 1b only uses block 0's permutations; future stages
-                // will upload all blocks.
-                for (size_t bid = 0; bid < hparams.feature_layers.size(); ++bid) {
-                    const std::string prefix = "g4v_blk" + std::to_string(bid) + "_";
-                    upload(prefix + "win_idx",     make_win_idx(image_side, window_side));
-                    upload(prefix + "qwin_idx",    make_win_idx(new_side, query_side));
-                    upload(prefix + "unwin_idx",   make_unwin_idx(new_side, query_side));
-                    const auto spatial_offset = hparams.proj_spatial_offsets[bid];
-                    if (spatial_offset >= 0) {
-                        upload(prefix + "spatial_idx", make_spatial_idx(image_side,spatial_offset));
-                    }
+                const int side        = img->nx() / params.patch_size;
+                const int n           = side / window_side;
+                const int out_side    = query_side * n;
+                n_patches             = out_side * out_side;
+                if (img->anyres.is_tiled()) {
+                    // overview tile, then the unpadded tile grid with one newline per row
+                    int off_x, off_y, w, h;
+                    mtmd_anyres_unpad(img->anyres.grid_x * out_side, img->anyres.grid_y * out_side,
+                                      img->anyres.orig_nx, img->anyres.orig_ny, off_x, off_y, w, h);
+                    n_patches += h * (w + 1);
                 }
             } break;
         default:
-            GGML_ABORT("Unknown projector type");
-    }
-
-    clip_backend_cpu_set_n_threads(ctx->backend_cpu, params->n_threads);
-
-    auto status = ggml_backend_sched_graph_compute(ctx->sched.get(), gf);
-    if (status != GGML_STATUS_SUCCESS) {
-        LOG_ERR("%s: ggml_backend_sched_graph_compute failed with error %d\n", __func__, status);
-        return false;
-    }
-
-    if (embeddings != nullptr) {
-        // sanity check (assuming that all images in batch have the same number of tokens, so we only check the first one)
-        const int n_tokens_out = embeddings->ne[1];
-        const int expected_n_tokens_out = clip_n_output_tokens(ctx, &imgs.entries[0]);
-        if (n_tokens_out != expected_n_tokens_out) {
-            LOG_ERR("%s: expected output %d tokens, got %d\n", __func__, expected_n_tokens_out, n_tokens_out);
-            GGML_ABORT("Invalid number of output tokens");
-        }
-
-        LOG_DBG("%s: output embedding shape [%d, %d, %d]\n", __func__,
-            (int)embeddings->ne[0], (int)embeddings->ne[1], (int)embeddings->ne[2]);
-
-        // copy output to user buffer if provided
-        // if output is empty, skip the copy
-        auto & out_batch_embd = *params->out_embd;
-        if (!out_batch_embd.empty()) {
-            if (out_batch_embd.size() != (size_t)ggml_nelements(embeddings)) {
-                LOG_ERR("%s: output buffer has %zu elements but expected %zu\n", __func__, out_batch_embd.size(), (size_t)ggml_nelements(embeddings));
-                GGML_ABORT("Output buffer size mismatch");
-            }
-            ggml_backend_tensor_get(embeddings, out_batch_embd.data(), 0, ggml_nbytes(embeddings));
-        } else {
-            LOG_WRN("%s: output buffer is empty, skipping copy\n", __func__);
-        }
-    }
-
-    //
-    // for audio gen models
-    //
-
-    if (ctx->proj_type() == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == CLIP_GEN_PROCESS_TOKENIZE) {
-        ggml_tensor * fsq = ggml_graph_get_tensor(gf, "out_fsq");
-        GGML_ASSERT(fsq != nullptr && params->out_codes);
-        const int n_tok = (int) fsq->ne[1];
-        std::vector<float> h((size_t) 8 * n_tok);
-        ggml_backend_tensor_get(fsq, h.data(), 0, ggml_nbytes(fsq));
-
-        // fsq round to base 3: h in (-1, 1) maps to digits {0, 1, 2}
-        params->out_codes->resize(n_tok);
-        for (int t = 0; t < n_tok; t++) {
-            int32_t code = 0;
-            for (int i = 7; i >= 0; i--) {
-                const int32_t d = (int32_t) roundf(h[(size_t) t * 8 + i] * 0.9990000128746033f) + 1;
-                code = code * 3 + d;
-            }
-            (*params->out_codes)[(size_t) t] = code;
-        }
-
-        // speech table rows of the codes, the multilingual variant adds its learned positions as its perceiver expects
-        if (params->out_code_embd) {
-            const auto & tensors = ctx->model.cbx_tensors;
-            auto tab_it = tensors.find("a.gen.code.out_embd.weight");
-            GGML_ASSERT(tab_it != tensors.end());
-            ggml_tensor * tab = tab_it->second;
-            const int n_e = (int) tab->ne[0];
-            auto pos_it = tensors.find("a.gen.t3.speech_pos_emb");
-            ggml_tensor * pos = pos_it == tensors.end() ? nullptr : pos_it->second;
-            if ((tab->type != GGML_TYPE_F16 && tab->type != GGML_TYPE_F32) ||
-                (pos && (pos->type != GGML_TYPE_F32 || pos->ne[0] != n_e || pos->ne[1] < n_tok))) {
-                LOG_ERR("%s: unexpected speech table or positions\n", __func__);
-                return false;
-            }
-
-            params->out_code_embd->resize((size_t) n_tok * n_e);
-            std::vector<ggml_fp16_t> h16(n_e);
-            std::vector<float> pr(n_e);
-            for (int t = 0; t < n_tok; t++) {
-                float * dst = params->out_code_embd->data() + (size_t) t * n_e;
-                if ((*params->out_codes)[(size_t) t] >= tab->ne[1]) {
-                    LOG_ERR("%s: speech code beyond the speech table\n", __func__);
-                    return false;
-                }
-                const size_t r = (size_t) (*params->out_codes)[(size_t) t] * n_e;
-                if (tab->type == GGML_TYPE_F16) {
-                    ggml_backend_tensor_get(tab, h16.data(), r * sizeof(ggml_fp16_t), (size_t) n_e * sizeof(ggml_fp16_t));
-                    for (int j = 0; j < n_e; j++) {
-                        dst[j] = ggml_fp16_to_fp32(h16[j]);
-                    }
-                } else {
-                    ggml_backend_tensor_get(tab, dst, r * sizeof(float), (size_t) n_e * sizeof(float));
-                }
-                if (pos) {
-                    ggml_backend_tensor_get(pos, pr.data(), (size_t) t * n_e * sizeof(float), (size_t) n_e * sizeof(float));
-                    for (int j = 0; j < n_e; j++) {
-                        dst[j] += pr[j];
-                    }
-                }
-            }
-        }
-        return true;
-    }
-
-    if (ctx->proj_type() == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == CLIP_GEN_PROCESS_TTS_VOCODE) {
-        ggml_tensor * sp = ggml_graph_get_tensor(gf, "out_spec");
-        GGML_ASSERT(sp != nullptr && params->out_spec);
-        params->out_spec->resize(ggml_nelements(sp));
-        ggml_backend_tensor_get(sp, params->out_spec->data(), 0, ggml_nbytes(sp));
-        return true;
-    }
-
-    if (ctx->proj_type() == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == CLIP_GEN_PROCESS_TTS) {
-        ggml_tensor * mel = ggml_graph_get_tensor(gf, "out_mel");
-        GGML_ASSERT(mel != nullptr);
-        std::vector<float> mel_data(ggml_nelements(mel));
-        ggml_backend_tensor_get(mel, mel_data.data(), 0, ggml_nbytes(mel));
-
-        // hift: f0 -> harmonic source -> source stft on the host, then the vocoder graph and the istft
-        ggml_tensor * f0_t = ggml_graph_get_tensor(gf, "out_f0");
-        GGML_ASSERT(f0_t != nullptr);
-        const int n_mel_out = (int) ggml_nelements(f0_t);
-        std::vector<float> f0(n_mel_out);
-        ggml_backend_tensor_get(f0_t, f0.data(), 0, ggml_nbytes(f0_t));
-
-        // SineGen: f0 upsampled x480, 9 harmonics with cumulative phase, uv gating and noise, merged by l_linear + tanh
-        const int ups_total = 480;
-        const double sr = 24000.0;
-        const int64_t n_wav = (int64_t) n_mel_out * ups_total;
-        std::vector<float> lw(9);
-        float lb = 0.0f;
-        if (clip_cbx_read_tensor(ctx, "a.gen.hift.m_source.l_linear.weight", lw.data(), lw.size()) != 9 ||
-            clip_cbx_read_tensor(ctx, "a.gen.hift.m_source.l_linear.bias", &lb, 1) != 1) {
-            LOG_ERR("%s: unexpected hift source module\n", __func__);
-            return false;
-        }
-
-        std::mt19937 & srng = params->gen_state->rng;
-        std::uniform_real_distribution<double> ud(-M_PI, M_PI);
-        std::normal_distribution<float> snd(0.0f, 1.0f);
-        double phase[9];
-        for (int h = 0; h < 9; h++) {
-            phase[h] = h == 0 ? 0.0 : ud(srng);
-        }
-        std::vector<float> src((size_t) n_wav);
-        double cum[9] = {0.0};
-        for (int64_t t = 0; t < n_wav; t++) {
-            const float f = f0[(size_t) (t / ups_total)];
-            const float uv = f > 10.0f ? 1.0f : 0.0f; // nsf_voiced_threshold
-            const float namp = uv * 0.003f + (1.0f - uv) * 0.1f / 3.0f;
-            float merged = lb;
-            for (int h = 0; h < 9; h++) {
-                cum[h] += (double) f * (h + 1) / sr;
-                cum[h] -= floor(cum[h]);
-                float sine = 0.1f * (float) sin(2.0 * M_PI * cum[h] + phase[h]);
-                sine = sine * uv + namp * snd(srng);
-                merged += lw[(size_t) h] * sine;
-            }
-            src[(size_t) t] = tanhf(merged);
-        }
-
-        // stft of the source: n_fft 16, hop 4, hann window, centered
-        const int n_fft = 16, hop = 4, n_bins = 9;
-        std::vector<float> win(n_fft);
-        for (int i = 0; i < n_fft; i++) win[(size_t) i] = 0.5f - 0.5f * cosf(2.0f * (float) M_PI * i / n_fft);
-        const int n_stft = (int) (n_wav / hop) + 1;
-        std::vector<float> sstft((size_t) n_stft * 18);
-        for (int fr = 0; fr < n_stft; fr++) {
-            const int64_t c0 = (int64_t) fr * hop - n_fft / 2; // centered, reflect padded
-            for (int k = 0; k < n_bins; k++) {
-                double re = 0.0, im = 0.0;
-                for (int i = 0; i < n_fft; i++) {
-                    int64_t idx = c0 + i;
-                    if (idx < 0) idx = -idx;
-                    if (idx >= n_wav) idx = 2 * (n_wav - 1) - idx;
-                    const double v = (double) src[(size_t) idx] * win[(size_t) i];
-                    const double a = 2.0 * M_PI * k * i / n_fft;
-                    re += v * cos(a);
-                    im -= v * sin(a);
-                }
-                sstft[(size_t) fr * 18 + k    ] = (float) re;
-                sstft[(size_t) fr * 18 + 9 + k] = (float) im;
-            }
-        }
-
-        // vocoder graph on mel + source stft
-        std::vector<float> spec;
-        {
-            clip_encode_params vp = *params;
-            vp.gen_process   = CLIP_GEN_PROCESS_TTS_VOCODE;
-            vp.mel_in        = &mel_data;
-            vp.sstft_in      = &sstft;
-            vp.vocode_n_mel  = n_mel_out;
-            vp.vocode_n_stft = n_stft;
-            vp.out_audio     = nullptr;
-            vp.out_spec      = &spec;
-            if (!clip_encode(ctx, &vp)) {
-                LOG_ERR("%s: vocoder stage failed\n", __func__);
-                return false;
-            }
-        }
-
-        // istft: mag = clipped exp, phase = sin, hann overlap-add
-        const int n_frames_out = (int) (spec.size() / 18);
-        const int64_t n_out = (int64_t) (n_frames_out - 1) * hop;
-        std::vector<double> acc((size_t) n_out + n_fft, 0.0);
-        std::vector<double> wsum((size_t) n_out + n_fft, 0.0);
-        for (int fr = 0; fr < n_frames_out; fr++) {
-            double frame[16];
-            for (int i = 0; i < n_fft; i++) {
-                double v = 0.0;
-                for (int k = 0; k < n_bins; k++) {
-                    const double mag = fmin(exp((double) spec[(size_t) fr * 18 + k]), 1e2);
-                    const double ph  = sin((double) spec[(size_t) fr * 18 + 9 + k]);
-                    const double re  = mag * cos(ph), im = mag * sin(ph);
-                    const double a   = 2.0 * M_PI * k * i / n_fft;
-                    const double w   = (k == 0 || k == n_fft / 2) ? 1.0 : 2.0;
-                    v += w * (re * cos(a) - im * sin(a));
-                }
-                frame[i] = v / n_fft;
-            }
-            const int64_t o = (int64_t) fr * hop;
-            for (int i = 0; i < n_fft; i++) {
-                acc [(size_t) (o + i)] += frame[i] * win[(size_t) i];
-                wsum[(size_t) (o + i)] += (double) win[(size_t) i] * win[(size_t) i];
-            }
-        }
-        GGML_ASSERT(params->out_audio);
-        auto & out_audio = *params->out_audio;
-        out_audio.resize((size_t) std::min<int64_t>(n_out - n_fft / 2, n_wav));
-        for (size_t i = 0; i < out_audio.size(); i++) {
-            const size_t j = i + n_fft / 2; // drop the centering pad
-            const double v = wsum[j] > 1e-11 ? acc[j] / wsum[j] : 0.0;
-            out_audio[(size_t) i] = (float) fmax(-0.99, fmin(0.99, v));
-        }
-
-        // trim_fade of the reference: silence 20 ms then fade in 20 ms, hides the artifact of the prompt boundary
-        const size_t n_trim = 24000 / 50;
-        for (size_t i = 0; i < 2 * n_trim && i < out_audio.size(); i++) {
-            const double g = i < n_trim ? 0.0
-                : (cos(M_PI * (1.0 - (double) (i - n_trim) / n_trim)) + 1.0) / 2.0;
-            out_audio[(size_t) i] *= (float) g;
-        }
-        return true;
+            GGML_ABORT("unsupported projector type");
     }

-    // optional outputs: a pipeline yields codes or feats, and not all have an eos head
-    if (params->out_codes != nullptr) {
-        ggml_tensor * codes = ggml_graph_get_tensor(gf, "out_codes");
-        if (codes != nullptr) {
-            auto & out_codes = *params->out_codes;
-            out_codes.resize(ggml_nelements(codes));
-            ggml_backend_tensor_get(codes, out_codes.data(), 0, ggml_nbytes(codes));
-        }
-    }
-    if (params->out_feats != nullptr) {
-        ggml_tensor * feats = ggml_graph_get_tensor(gf, "out_feats");
-        if (feats != nullptr) {
-            auto & out_feats = *params->out_feats;
-            out_feats.resize(ggml_nelements(feats));
-            ggml_backend_tensor_get(feats, out_feats.data(), 0, ggml_nbytes(feats));
-        }
-    }
-    if (params->out_is_eos != nullptr) {
-        ggml_tensor * eos = ggml_graph_get_tensor(gf, "out_eos_score");
-        if (eos != nullptr) {
-            GGML_ASSERT(ggml_nelements(eos) == 1);
-            float score = 0.0f;
-            ggml_backend_tensor_get(eos, &score, 0, sizeof(float));
-            *params->out_is_eos = score > hparams.gen_eos_threshold;
-        }
-    }
-    if (params->out_audio != nullptr) {
-        ggml_tensor * audio = ggml_graph_get_tensor(gf, "out_audio");
-        if (audio == nullptr) {
-            GGML_ABORT("out_audio requested but graph has no \"out_audio\" tensor");
-        }
-        auto & out_audio = *params->out_audio;
-        out_audio.resize(ggml_nelements(audio));
-        ggml_backend_tensor_get(audio, out_audio.data(), 0, ggml_nbytes(audio));
+    n_patches += (int) mtmd_suffix_rows(img->suffix_type).size();

-        if (model.proj_type == PROJECTOR_TYPE_SOPRANO) {
-            std::vector<float> spectrum = std::move(out_audio);
-            clip_graph_soprano::decode_spectrum(model, spectrum, out_audio);
-        }
+    return n_patches;
+}

-        // drop the tail audio that comes from the code-0 rear padding, drama3 has no padding
-        if (model.proj_type != PROJECTOR_TYPE_DRAMA3_GEN) {
-            const int64_t n_codes    = params->codes ? model.gen_code_head_w->ne[2] + 1 : 0;
-            const int64_t n_frames_w = hparams.wav_tfm_swa;
-            const int64_t n_frames   = params->codes ? (int64_t) params->codes->size() / n_codes : n_frames_w;
-            if (n_frames < n_frames_w) {
-                const size_t hop = out_audio.size() / n_frames_w;
-                out_audio.resize((size_t) n_frames * hop);
-            }
-        }
+size_t mtmd_model::cbx_read_tensor(const char * name, float * out, size_t n_max) const {
+    const auto & tensors = cbx_tensors;
+    auto it = tensors.find(name);
+    if (it == tensors.end() || !it->second) {
+        return 0;
     }
-    if (params->enc_state != nullptr && model.proj_type == PROJECTOR_TYPE_LFM3A) {
-        const auto & slots = params->enc_state->slots;
-        size_t i = 0;
-        for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
-            ggml_tensor * t = ggml_graph_get_tensor(gf, ("state_out_" + slot.name).c_str());
-            GGML_ASSERT(t != nullptr);
-            copy_gen_state_out(t, slots[i++]);
-        }
-    } else if (params->enc_state != nullptr && model.proj_type == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
-        clip_enc_state * st = params->enc_state;
-        ggml_tensor * chunk = ggml_graph_get_tensor(gf, "spk_chunk");
-        ggml_tensor * preds = ggml_graph_get_tensor(gf, "spk_preds");
-        GGML_ASSERT(chunk != nullptr && preds != nullptr);
-        st->spk_chunk.resize(ggml_nelements(chunk));
-        st->spk_preds.resize(ggml_nelements(preds));
-        ggml_backend_tensor_get(chunk, st->spk_chunk.data(), 0, ggml_nbytes(chunk));
-        ggml_backend_tensor_get(preds, st->spk_preds.data(), 0, ggml_nbytes(preds));
-    } else if (params->enc_state != nullptr) {
-        clip_enc_state * st = params->enc_state;
-        ggml_tensor * tail = ggml_graph_get_tensor(gf, "out_stem_tail");
-        GGML_ASSERT(tail != nullptr);
-        st->stem_tail.resize(ggml_nelements(tail));
-        ggml_backend_tensor_get(tail, st->stem_tail.data(), 0, ggml_nbytes(tail));
-
-        const int n_new = (imgs.entries[0].nx() - 2) / 2;
-        for (int i = 0; i < n_new; i++) {
-            st->slot_pos[(st->n_past + i) % st->n_ctx] = st->n_past + i;
-        }
-        st->n_past += n_new;
+    ggml_tensor * t = it->second;
+    const size_t n = (size_t) ggml_nelements(t);
+    if (!out) {
+        return n;
     }
-
-    if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
-        const auto & slots = params->gen_state->slots;
-        size_t i = 0;
-        for (const auto & slot : list_gen_state_slots(hparams, model)) {
-            if (slot.in_place) {
-                i++;
-                continue;
-            }
-            ggml_tensor * t = ggml_graph_get_tensor(gf, ("state_out_" + slot.name).c_str());
-            if (t == nullptr) {
-                GGML_ABORT("graph has no \"state_out_%s\" tensor", slot.name.c_str());
-            }
-            GGML_ASSERT(i < slots.size());
-            copy_gen_state_out(t, slots[i++]);
-        }
+    if (n_max < n) {
+        return 0;
     }
-
-    //
-    // Debug: dump final embeddings if MTMD_DEBUG_EMBEDDINGS is set
-    //
-
-    if (ctx->debug_output_embeddings && embeddings != nullptr) {
-        const int64_t n_embd = embeddings->ne[0];
-        const int64_t n_tokens = embeddings->ne[1];
-        std::vector<float> emb_data(ggml_nelements(embeddings));
-        ggml_backend_tensor_get(embeddings, emb_data.data(), 0, ggml_nbytes(embeddings));
-
-        LOG_INF("\n=== MTMD_DEBUG_EMBEDDINGS ===\n");
-        LOG_INF("Shape: [%lld, %lld]\n", (long long)n_embd, (long long)n_tokens);
-
-        // TEMP debugging (parity validation), will be removed before merge
-        // when the env var holds a path, dump the raw data: [int32 n_tokens][int32 n_embd][f32 data]
-        const char * dump_path = std::getenv("MTMD_DEBUG_EMBEDDINGS");
-        if (dump_path && strcmp(dump_path, "1") != 0) {
-            FILE * f = fopen(dump_path, "wb");
-            if (f) {
-                const int32_t hdr[2] = { (int32_t)n_tokens, (int32_t)n_embd };
-                fwrite(hdr, sizeof(hdr), 1, f);
-                fwrite(emb_data.data(), sizeof(float), emb_data.size(), f);
-                fclose(f);
-            }
-        }
-
-        // Print first few values of first token
-        LOG_INF("Token 0 (first 16 values): ");
-        for (int i = 0; i < std::min((int64_t)16, n_embd); i++) {
-            LOG_INF("%.6f ", emb_data[i]);
-        }
-        LOG_INF("\n");
-
-        // Print last few values of first token
-        if (n_embd > 16) {
-            LOG_INF("Token 0 (last 16 values):  ");
-            for (int64_t i = n_embd - 16; i < n_embd; i++) {
-                LOG_INF("%.6f ", emb_data[i]);
-            }
-            LOG_INF("\n");
-        }
-
-        // Compute and print statistics
-        float sum = 0.0f, sum_sq = 0.0f, min_val = emb_data[0], max_val = emb_data[0];
-        for (size_t i = 0; i < emb_data.size(); i++) {
-            sum += emb_data[i];
-            sum_sq += emb_data[i] * emb_data[i];
-            min_val = std::min(min_val, emb_data[i]);
-            max_val = std::max(max_val, emb_data[i]);
-        }
-        float mean = sum / emb_data.size();
-        float variance = (sum_sq / emb_data.size()) - (mean * mean);
-        LOG_INF("Stats: mean=%.6f, std=%.6f, min=%.6f, max=%.6f, sum=%.6f\n",
-                mean, sqrtf(variance), min_val, max_val, sum);
-        LOG_INF("=== END MTMD_DEBUG_EMBEDDINGS ===\n\n");
+    if (t->type == GGML_TYPE_F32) {
+        ggml_backend_tensor_get(t, out, 0, n * sizeof(float));
+    } else if (t->type == GGML_TYPE_F16) {
+        std::vector<ggml_fp16_t> tmp(n);
+        ggml_backend_tensor_get(t, tmp.data(), 0, n * sizeof(ggml_fp16_t));
+        for (size_t i = 0; i < n; i++) out[i] = ggml_fp16_to_fp32(tmp[i]);
+    } else {
+        return 0;
     }
-
-    return true;
+    return n;
 }

-int clip_n_mmproj_embd(const struct clip_ctx * ctx) {
-    switch (ctx->model.proj_type) {
+int mtmd_model::n_mmproj_embd() const {
+    switch (proj_type) {
         case PROJECTOR_TYPE_LDP:
-            return ctx->model.mm_model_block_1_block_2_1_b->ne[0];
+            return mm_model_block_1_block_2_1_b->ne[0];
         case PROJECTOR_TYPE_LDPV2:
-            return ctx->model.mm_model_peg_0_b->ne[0];
+            return mm_model_peg_0_b->ne[0];
         case PROJECTOR_TYPE_MLP:
         case PROJECTOR_TYPE_PHI4:
         case PROJECTOR_TYPE_PIXTRAL:
@@ -8173,21 +4209,21 @@ int clip_n_mmproj_embd(const struct clip_ctx * ctx) {
         case PROJECTOR_TYPE_DOTS_OCR:
         case PROJECTOR_TYPE_DOTS3NOTE_V:
         case PROJECTOR_TYPE_DOTS3NOTE_A:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_MLP_NORM:
-            return ctx->model.mm_3_b->ne[0];
+            return mm_3_b->ne[0];
         case PROJECTOR_TYPE_MINICPMV:
-            return ctx->model.mm_model_proj->ne[0];
+            return mm_model_proj->ne[0];
         case PROJECTOR_TYPE_MINICPMV4_6:
         case PROJECTOR_TYPE_MINICPMV4_7:
         case PROJECTOR_TYPE_MINICPM_OCR_1_0:
-            return ctx->model.mm_ffn_down_w->ne[1];
+            return mm_ffn_down_w->ne[1];
         case PROJECTOR_TYPE_GLM_EDGE:
-            return ctx->model.mm_model_mlp_3_w->ne[1];
+            return mm_model_mlp_3_w->ne[1];
         case PROJECTOR_TYPE_MINIMAX_M3:
-            return ctx->model.mm_merger_fc2_b->ne[0];
+            return mm_merger_fc2_b->ne[0];
         case PROJECTOR_TYPE_MUSE_GLIMMER:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_QWEN2VL:
         case PROJECTOR_TYPE_QWEN25VL:
         case PROJECTOR_TYPE_MAGEVL:
@@ -8195,47 +4231,47 @@ int clip_n_mmproj_embd(const struct clip_ctx * ctx) {
         case PROJECTOR_TYPE_EXAONE4_5:
         case PROJECTOR_TYPE_JANUS_PRO:
         case PROJECTOR_TYPE_YOUTUVL:
-            return ctx->model.mm_1_b->ne[0];
+            return mm_1_b->ne[0];
         case PROJECTOR_TYPE_SENSENOVA_U1:
-            return ctx->model.hparams.projection_dim;
+            return hparams.projection_dim;
         case PROJECTOR_TYPE_QWEN3VL:
             // main path + deepstack paths
-            return ctx->model.mm_1_b->ne[0] * (1 + ctx->model.n_deepstack_layers);
+            return mm_1_b->ne[0] * (1 + n_deepstack_layers);
         case PROJECTOR_TYPE_LING3VL:
-            return ctx->model.mm_1_b->ne[0];
+            return mm_1_b->ne[0];
         case PROJECTOR_TYPE_MIMOVL:
-            return ctx->model.mm_1_w->ne[1];
+            return mm_1_w->ne[1];
         case PROJECTOR_TYPE_STEP3VL:
-            return ctx->model.mm_model_proj->ne[1];
+            return mm_model_proj->ne[1];
         case PROJECTOR_TYPE_GEMMA3:
         case PROJECTOR_TYPE_GEMMA3NV:
-            return ctx->model.mm_input_proj_w->ne[0];
+            return mm_input_proj_w->ne[0];
         case PROJECTOR_TYPE_GEMMA4V:
         case PROJECTOR_TYPE_GEMMA4UV:
         case PROJECTOR_TYPE_GEMMA4A:
         case PROJECTOR_TYPE_GEMMA4UA:
-            return ctx->model.mm_input_proj_w->ne[1];
+            return mm_input_proj_w->ne[1];
         case PROJECTOR_TYPE_IDEFICS3:
         case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
-            return ctx->model.mm_fc_w->ne[1];
+            return mm_fc_w->ne[1];
         case PROJECTOR_TYPE_ULTRAVOX:
         case PROJECTOR_TYPE_VOXTRAL:
         case PROJECTOR_TYPE_VOXTRAL_RT:
         case PROJECTOR_TYPE_MUSIC_FLAMINGO:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_MERALION:
-            return ctx->model.mm_3_w->ne[1]; // out_proj output dim
+            return mm_3_w->ne[1]; // out_proj output dim
         case PROJECTOR_TYPE_INTERNVL:
         case PROJECTOR_TYPE_NEMOTRON_V2_VL:
         case PROJECTOR_TYPE_NEMOTRON_OMNI:
-            return ctx->model.mm_3_w->ne[1];
+            return mm_3_w->ne[1];
         case PROJECTOR_TYPE_LLAMA4:
-            return ctx->model.mm_model_proj->ne[1];
+            return mm_model_proj->ne[1];
         case PROJECTOR_TYPE_QWEN2A:
         case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
-            return ctx->model.mm_fc_w->ne[1];
+            return mm_fc_w->ne[1];
         case PROJECTOR_TYPE_QWEN3A:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_GLMA:
         case PROJECTOR_TYPE_LFM2:
         case PROJECTOR_TYPE_D1OMNI_V:
@@ -8246,74 +4282,58 @@ int clip_n_mmproj_embd(const struct clip_ctx * ctx) {
         case PROJECTOR_TYPE_DEEPSEEK4V:
         case PROJECTOR_TYPE_DEEPSEEK41V:
         case PROJECTOR_TYPE_COHERE2V:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_HUNYUANVL:
-            return ctx->model.mm_model_proj->ne[1];
+            return mm_model_proj->ne[1];
         case PROJECTOR_TYPE_COGVLM:
-            return ctx->model.mm_4h_to_h_w->ne[1];
+            return mm_4h_to_h_w->ne[1];
         case PROJECTOR_TYPE_DEEPSEEKOCR:
         case PROJECTOR_TYPE_DEEPSEEKOCR2:
-            return ctx->model.mm_fc_w->ne[1];
+            return mm_fc_w->ne[1];
         case PROJECTOR_TYPE_LFM2A:
-            return ctx->model.position_embeddings->ne[0];
+            return position_embeddings->ne[0];
         case PROJECTOR_TYPE_D1OMNI_A:
         case PROJECTOR_TYPE_LFM3A:
-            return ctx->model.mm_3_w->ne[1];
+            return mm_3_w->ne[1];
         case PROJECTOR_TYPE_GRANITE_SPEECH:
-            return ctx->model.qf_proj_blocks[0].qf_proj_linear_w->ne[1];
+            return qf_proj_blocks[0].qf_proj_linear_w->ne[1];
         case PROJECTOR_TYPE_GRANITE4_VISION:
-            return ctx->model.qf_proj_blocks.size() * ctx->model.hparams.projection_dim;
+            return qf_proj_blocks.size() * hparams.projection_dim;
         case PROJECTOR_TYPE_GLM4V:
         case PROJECTOR_TYPE_GLM5V:
-            return ctx->model.mm_ffn_down_w->ne[1];
+            return mm_ffn_down_w->ne[1];
         case PROJECTOR_TYPE_MIMO_AUDIO:
-            return ctx->model.mm_2_w->ne[1];
+            return mm_2_w->ne[1];
         case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
-            return ctx->model.mm_fc_w->ne[2];
+            return mm_fc_w->ne[2];
         case PROJECTOR_TYPE_QWEN3TTS_GEN:
         case PROJECTOR_TYPE_LFM3A_GEN:
         case PROJECTOR_TYPE_DRAMA3_GEN:
-            return ctx->model.gen_code_out_embd_w->ne[0];
+            return gen_code_out_embd_w->ne[0];
         case PROJECTOR_TYPE_POCKETTTS_SPKENC:
-            return ctx->model.spk_proj_w->ne[1];
+            return spk_proj_w->ne[1];
         case PROJECTOR_TYPE_POCKETTTS_GEN:
-            return ctx->model.gen_input_lin_w->ne[1];
+            return gen_input_lin_w->ne[1];
         case PROJECTOR_TYPE_CHATTERBOX:
             // talker width, carried by the speech table
-            return ctx->model.cbx_tensors.at("a.gen.code.out_embd.weight")->ne[0];
+            return cbx_tensors.at("a.gen.code.out_embd.weight")->ne[0];
         case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
             // talker conditioning rows, the speaker projection carries their width
-            return ctx->model.cbx_tensors.at("a.cenc.spkr_enc.weight")->ne[1];
+            return cbx_tensors.at("a.cenc.spkr_enc.weight")->ne[1];
         case PROJECTOR_TYPE_SOPRANO:
-            return ctx->model.hparams.projection_dim;
+            return hparams.projection_dim;
         case PROJECTOR_TYPE_PARAKEET:
-            return ctx->model.mm_1_w->ne[1];
+            return mm_1_w->ne[1];
         default:
             GGML_ABORT("Unknown projector type");
     }
 }

-bool clip_is_llava(const struct clip_ctx * ctx) {
-    return ctx->model.hparams.has_llava_projector;
-}
-
-bool clip_has_vision_encoder(const struct clip_ctx * ctx) {
-    return ctx->model.modality == CLIP_MODALITY_VISION;
-}
-
-bool clip_has_audio_encoder(const struct clip_ctx * ctx) {
-    return ctx->model.modality == CLIP_MODALITY_AUDIO;
-}
-
-bool clip_support_batch(const struct clip_ctx * ctx) {
-    return ctx->support_batch;
-}
-
 // TODO @ngxson : this is no longer correct with mtmd_batch API
 // this was only meant to be used by qwen-vl-based models, to fuse 2 input images into one (qwen-vl video support)
 // this logic should be refactored in near future to distinctly handle "merge frames" and "batching"
-int clip_model_n_temporal_merge(const struct clip_ctx * ctx) {
-    switch (ctx->proj_type()) {
+int mtmd_model::n_temporal_merge() const {
+    switch (proj_type) {
         case PROJECTOR_TYPE_QWEN2VL:
         case PROJECTOR_TYPE_QWEN25VL:
         case PROJECTOR_TYPE_QWEN3VL:
@@ -8325,31 +4345,3 @@ int clip_model_n_temporal_merge(const struct clip_ctx * ctx) {
             return 1;
     }
 }
-
-//
-// API used internally with mtmd
-//
-
-projector_type clip_get_projector_type(const struct clip_ctx * ctx) {
-    return ctx->proj_type();
-}
-
-const clip_hparams * clip_get_hparams(const struct clip_ctx * ctx) {
-    return &ctx->model.hparams;
-}
-
-std::map<ggml_backend_dev_t, size_t> clip_get_mem_usage(const struct clip_ctx * ctx) {
-    std::map<ggml_backend_dev_t, size_t> result = ctx->mem_usage;
-    for (auto & [dev, size] : ctx->mem_compute) {
-        result[dev] += size;
-    }
-    return result;
-}
-
-//
-// API for debugging
-//
-
-void clip_set_debug_output_embeddings(clip_ctx * ctx, bool enable) {
-    ctx->debug_output_embeddings = enable;
-}
diff --git a/tools/mtmd/clip-model.h b/tools/mtmd/mtmd-model.h
similarity index 92%
rename from tools/mtmd/clip-model.h
rename to tools/mtmd/mtmd-model.h
index f50fc40b9..0d975ba36 100644
--- a/tools/mtmd/clip-model.h
+++ b/tools/mtmd/mtmd-model.h
@@ -1,8 +1,8 @@
 #pragma once

 #include "ggml.h"
-#include "clip.h"
-#include "clip-impl.h"
+#include "ggml-backend.h"
+#include "mtmd-impl.h"

 #include <algorithm>
 #include <array>
@@ -12,6 +12,25 @@
 #include <cstdint>
 #include <cmath>

+enum mtmd_modality {
+    MTMD_MODALITY_VISION,
+    MTMD_MODALITY_AUDIO,
+    MTMD_MODALITY_GEN_AUDIO,
+};
+
+// gen-side voice reference (chatterbox): speech tokens, mel-rate features and the speaker vector
+struct mtmd_gen_ref {
+    std::vector<int32_t> tokens;
+    std::vector<float>   feat;
+    std::vector<float>   spk;
+};
+
+// a predefined voice: backbone rows, plus the gen-side reference when the gen stack needs one
+struct mtmd_gen_voice {
+    std::vector<float> rows;
+    mtmd_gen_ref       ref;
+};
+
 enum ffn_op_type {
     FFN_GELU,
     FFN_GELU_ERF,
@@ -50,7 +69,7 @@ enum pad_style {
     PAD_NEAREST,
 };

-struct clip_hparams {
+struct mtmd_hparams {
     int32_t image_size = 0;
     int32_t patch_size = 0;
     int32_t n_embd = 0;
@@ -74,7 +93,7 @@ struct clip_hparams {
     std::array<uint8_t, 3> image_pad_color = {0, 0, 0};

     // (preprocessor) for llava-uhd style models
-    std::vector<clip_image_size> image_res_candidates;
+    std::vector<mtmd_image_size> image_res_candidates;
     int32_t preproc_min_tiles = 0;
     int32_t preproc_max_tiles = 0;
     int32_t max_slice_nums = 9; // llava-uhd slice cap; per-model, carried in the GGUF
@@ -277,7 +296,7 @@ struct clip_hparams {
     }
 };

-struct clip_layer {
+struct mtmd_layer {
     // layernorm 1 (or layer input norm, or pre-attention norm)
     ggml_tensor * ln_1_w = nullptr;
     ggml_tensor * ln_1_b = nullptr;
@@ -484,13 +503,13 @@ struct qf_block {
     ggml_tensor * qf_proj_post_norm_w = nullptr;
     ggml_tensor * qf_proj_post_norm_b = nullptr;
     ggml_tensor * qf_proj_img_pos     = nullptr; // Vision only
-    std::vector<clip_layer> qf_proj_layers;
+    std::vector<mtmd_layer> qf_proj_layers;
 };

 // pocket-tts SEANet stack, used in both directions:
 // encoder = conv_in -> per stage (residual unit, strided conv) -> conv_out
 // decoder = conv_in -> per stage (strided convtr, residual unit) -> conv_out
-struct clip_seanet {
+struct mtmd_seanet {
     // one residual unit: ELU -> dilated conv -> ELU -> pointwise conv, added to the input
     struct stage {
         ggml_tensor * res_conv1_w = nullptr;
@@ -509,7 +528,7 @@ struct clip_seanet {
 };

 // pocket-tts flow-matching decoder (SimpleMLPAdaLN)
-struct clip_flow_net {
+struct mtmd_flow_net {
     // AdaLN res block: in_ln -> modulate -> Linear -> SiLU -> Linear, gated residual
     struct block {
         ggml_tensor * norm_w = nullptr;
@@ -545,7 +564,7 @@ struct clip_flow_net {
 };

 // qwen3tts code2wav: RVQ codes -> raw PCM
-struct clip_code2wav {
+struct mtmd_code2wav {
     // "upsample" stage: one ConvNeXt block plus the causal ConvTranspose1d before it
     struct upsample_block {
         ggml_tensor * conv_w   = nullptr; // causal ConvTranspose1d, 2x
@@ -599,7 +618,7 @@ struct clip_code2wav {
     ggml_tensor * tfm_out_proj_w    = nullptr;
     ggml_tensor * tfm_out_proj_b    = nullptr;
     ggml_tensor * tfm_output_norm_w = nullptr;
-    std::vector<clip_layer> tfm_layers; // reuses the generic block fields (ln_1/attn/ln_2/ffn/ls_1/ls_2)
+    std::vector<mtmd_layer> tfm_layers; // reuses the generic block fields (ln_1/attn/ln_2/ffn/ls_1/ls_2)

     std::vector<upsample_block> upsample;

@@ -612,7 +631,7 @@ struct clip_code2wav {
     ggml_tensor * dac_post_conv_b      = nullptr;
 };

-struct clip_chatterbox {
+struct mtmd_chatterbox {
     // espnet rel-pos conformer layer of the flow encoder
     struct enc_layer {
         ggml_tensor * norm_mha_w = nullptr;
@@ -842,7 +861,7 @@ struct clip_chatterbox {
 };

 // drama3 tower: extra tensors around the generic block
-struct clip_drama3_layer {
+struct mtmd_drama3_layer {
     ggml_tensor * enorm_w    = nullptr;
     ggml_tensor * hnorm_w    = nullptr;
     ggml_tensor * eh_proj_w  = nullptr; // [code embd; hidden] -> hidden
@@ -850,7 +869,7 @@ struct clip_drama3_layer {
     ggml_tensor * out_norm_w = nullptr;
 };

-struct clip_vocos {
+struct mtmd_vocos {
     ggml_tensor * input_w        = nullptr;
     ggml_tensor * input_b        = nullptr;
     ggml_tensor * norm_w         = nullptr;
@@ -859,13 +878,23 @@ struct clip_vocos {
     ggml_tensor * output_norm_b  = nullptr;
     ggml_tensor * output_w       = nullptr;
     ggml_tensor * output_b       = nullptr;
-    std::vector<clip_code2wav::upsample_block> blocks;
+    std::vector<mtmd_code2wav::upsample_block> blocks;
 };

-struct clip_model {
-    clip_modality modality = CLIP_MODALITY_VISION;
+// the weights and hparams of one modality of an mmproj file, read-only once loaded
+struct mtmd_model {
+    mtmd_modality modality = MTMD_MODALITY_VISION;
     projector_type proj_type = PROJECTOR_TYPE_MLP;
-    clip_hparams hparams;
+    mtmd_hparams hparams;
+
+    // runtime requirements declared by the model, applied by mtmd_subcontext
+    int max_nodes        = 8192;
+    bool flash_attn_auto = true; // false: MTMD_FLASH_ATTN_TYPE_AUTO resolves to disabled
+
+    // weights
+    ggml_context_ptr        ctx_data;
+    ggml_backend_buffer_ptr buf;
+    std::map<ggml_backend_dev_t, size_t> mem_weights;

     // embeddings
     ggml_tensor * class_embedding = nullptr;
@@ -888,7 +917,7 @@ struct clip_model {
     ggml_tensor * pre_ln_w = nullptr;
     ggml_tensor * pre_ln_b = nullptr;

-    std::vector<clip_layer> layers;
+    std::vector<mtmd_layer> layers;

     // one patch embedder per audio pyramid stage (muse-spark)
     struct audio_patch {
@@ -898,7 +927,7 @@ struct clip_model {
     };
     std::vector<audio_patch> a_patches;

-    int32_t n_deepstack_layers = 0; // used by Qwen3-VL, calculated from clip_layer
+    int32_t n_deepstack_layers = 0; // used by Qwen3-VL, calculated from mtmd_layer

     ggml_tensor * post_ln_w;
     ggml_tensor * post_ln_b;
@@ -1085,7 +1114,7 @@ struct clip_model {

     // mimo-v2.5: LLM-side connector (input_local_transformer, separate from the
     // audio_tokenizer's own encoder `layers`)
-    std::vector<clip_layer> mm_a_local_layers;
+    std::vector<mtmd_layer> mm_a_local_layers;
     ggml_tensor * mm_a_local_norm_w = nullptr;

     // qwen3a
@@ -1116,18 +1145,18 @@ struct clip_model {
     ggml_tensor * gen_code_mask_embd   = nullptr;

     // qwen3tts code2wav: RVQ codes -> raw PCM
-    clip_code2wav c2w;
-    std::vector<clip_drama3_layer> drama3_layers;
+    mtmd_code2wav c2w;
+    std::vector<mtmd_drama3_layer> drama3_layers;

     // pocket-tts: SEANet stack, shared by the encoder (speaker path) and the decoder (gen path)
-    clip_seanet seanet;
+    mtmd_seanet seanet;

     // pocket-tts: voice latent -> backbone embd (speaker path)
     ggml_tensor * spk_proj_w      = nullptr;
     ggml_tensor * downsample_w    = nullptr;

     // pocket-tts: flow-matching decoder, backbone hidden state -> next latent
-    clip_flow_net flow;
+    mtmd_flow_net flow;
     ggml_tensor * gen_out_eos_w   = nullptr;
     ggml_tensor * gen_out_eos_b   = nullptr;
     ggml_tensor * gen_input_lin_w = nullptr; // latent -> backbone embd
@@ -1135,18 +1164,18 @@ struct clip_model {
     ggml_tensor * gen_emb_std     = nullptr;
     ggml_tensor * gen_quant_out_w = nullptr; // latent -> decoder dim
     ggml_tensor * gen_upsample_w  = nullptr; // depthwise convtr, frame rate -> encoder frame rate
-    std::vector<clip_layer> gen_tfm_layers; // mimi decoder_transformer
+    std::vector<mtmd_layer> gen_tfm_layers; // mimi decoder_transformer

     // chatterbox flow encoder, cfm estimator, hift vocoder, s3 tokenizer and CAMPPlus speaker encoder
-    clip_chatterbox cbx;
+    mtmd_chatterbox cbx;

-    // chatterbox tensors read on the host, see clip_cbx_read_tensor()
+    // chatterbox tensors read on the host, see cbx_read_tensor()
     std::map<std::string, ggml_tensor *> cbx_tensors;
     // predefined voices, no rows if the voice is named in the prompt (lfm3a)
-    std::map<std::string, clip_gen_voice> gen_voices;
+    std::map<std::string, mtmd_gen_voice> gen_voices;

     // soprano: Vocos decoder, hidden states -> STFT coefficients
-    clip_vocos vocos;
+    mtmd_vocos vocos;

     // cogvlm
     ggml_tensor * mm_post_fc_norm_w = nullptr;
@@ -1178,10 +1207,10 @@ struct clip_model {

     int32_t n_sam_layers = 12; // used by deepseek-ocr sam encoder

-    std::vector<clip_layer> sam_layers;
+    std::vector<mtmd_layer> sam_layers;

     // nemotron transcribe: speaker expert, Sortformer head, fusion
-    std::vector<clip_layer> spk_layers;
+    std::vector<mtmd_layer> spk_layers;
     ggml_tensor * spk_inp_proj_w     = nullptr;
     ggml_tensor * spk_pre_ln_w       = nullptr;
     ggml_tensor * spk_pre_ln_b       = nullptr;
@@ -1254,6 +1283,51 @@ struct clip_model {
             || proj_type == PROJECTOR_TYPE_VOXTRAL
             || proj_type == PROJECTOR_TYPE_MERALION;
     }
+
+    // this should be equal to the embedding dimension of the text model
+    int n_mmproj_embd() const;
+
+    int n_output_tokens(const mtmd_image_f32 * img) const;
+
+    // for M-RoPE, this will be the number of token positions in X and Y directions
+    // for other models, X will be the total number of tokens and Y will be 1
+    int n_output_tokens_x(const mtmd_image_f32 * img) const;
+    int n_output_tokens_y(const mtmd_image_f32 * img) const;
+
+    // max number of output tokens per image, -1 if not dynamic size
+    int image_max_tokens() const;
+
+    // TODO @ngxson : this is no longer correct with mtmd_batch API
+    int n_temporal_merge() const;
+
+    // read a chatterbox tensor by name as F32, returns the element count (0 if not found), out may be null to query the size
+    size_t cbx_read_tensor(const char * name, float * out, size_t n_max) const;
+};
+
+struct mtmd_model_params {
+    // custom value provided by user, -1 if not set
+    int32_t image_min_tokens = -1;
+    int32_t image_max_tokens = -1;
+    bool no_alloc = false; // only create the tensors, do not read their data
+    mtmd_progress_callback progress_callback = nullptr;
+    void * progress_callback_user_data = nullptr;
+};
+
+// the modalities of an mmproj file, a missing one is nullptr
+struct mtmd_models {
+    std::unique_ptr<mtmd_model> v;
+    std::unique_ptr<mtmd_model> a;
+    std::unique_ptr<mtmd_model> gen_a;
+};
+
+// the weights are allocated in buft, throws on error
+mtmd_models mtmd_model_load(const char * fname, const mtmd_model_params & params, ggml_backend_buffer_type_t buft);
+
+struct mtmd_model_caps {
+    bool has_vision;
+    bool has_audio;
+    bool has_gen_audio;
 };

-const clip_hparams * clip_get_hparams(const struct clip_ctx * ctx);
+// read the modalities of an mmproj file without loading its tensors
+mtmd_model_caps mtmd_model_get_caps(const char * fname);
diff --git a/tools/mtmd/mtmd-subcontext.cpp b/tools/mtmd/mtmd-subcontext.cpp
new file mode 100644
index 000000000..fa33d5887
--- /dev/null
+++ b/tools/mtmd/mtmd-subcontext.cpp
@@ -0,0 +1,2701 @@
+#include "mtmd-subcontext.h"
+#include "mtmd-graph.h"
+#include "models/models.h"
+#include "mtmd-audio.h"
+
+#include "ggml.h"
+#include "ggml-cpp.h"
+#include "ggml-alloc.h"
+#include "ggml-backend.h"
+#include "ggml-cpu.h"
+
+#include <algorithm>
+#include <cfloat>
+#include <cmath>
+#include <cstdlib>
+#include <cstring>
+#include <map>
+#include <memory>
+#include <random>
+#include <stdexcept>
+#include <vector>
+
+// ggml_backend_cpu_set_n_threads(backend_cpu, n_threads);
+static void mtmd_backend_cpu_set_n_threads(ggml_backend_t backend_cpu, int n_threads) {
+    ggml_backend_dev_t dev = ggml_backend_get_device(backend_cpu);
+    ggml_backend_reg_t reg = dev ? ggml_backend_dev_backend_reg(dev) : nullptr;
+    if (reg) {
+        auto ggml_backend_set_n_threads_fn = (ggml_backend_set_n_threads_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_set_n_threads");
+        if (ggml_backend_set_n_threads_fn) {
+            ggml_backend_set_n_threads_fn(backend_cpu, n_threads);
+        }
+    }
+}
+
+std::shared_ptr<ggml_threadpool> mtmd_threadpool_init(int n_threads) {
+    ggml_backend_dev_t dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);
+    ggml_backend_reg_t reg = dev ? ggml_backend_dev_backend_reg(dev) : nullptr;
+    if (!reg || n_threads < 2) {
+        return nullptr;
+    }
+    auto * new_fn  = (decltype(ggml_threadpool_new) *)  ggml_backend_reg_get_proc_address(reg, "ggml_threadpool_new");
+    auto * free_fn = (decltype(ggml_threadpool_free) *) ggml_backend_reg_get_proc_address(reg, "ggml_threadpool_free");
+    if (!new_fn || !free_fn) {
+        return nullptr;
+    }
+    ggml_threadpool_params tpp = ggml_threadpool_params_default(n_threads);
+    tpp.poll = 0; // the pool is idle most of the time, don't take CPU away from the text model
+    ggml_threadpool_t threadpool = new_fn(&tpp);
+    if (!threadpool) {
+        throw std::runtime_error("failed to create threadpool");
+    }
+    return std::shared_ptr<ggml_threadpool>(threadpool, free_fn);
+}
+
+ggml_backend_dev_t mtmd_select_device(bool use_gpu, ggml_backend_dev_t device) {
+    if (!use_gpu) {
+        return nullptr;
+    }
+    if (device != nullptr) {
+        return device;
+    }
+    ggml_backend_dev_t dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU);
+    return dev ? dev : ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_IGPU);
+}
+
+mtmd_subcontext::mtmd_subcontext(const mtmd_model & model, const mtmd_subctx_params & params) :
+        model(model),
+        threadpool(params.threadpool),
+        max_nodes(model.max_nodes),
+        flash_attn_type(params.flash_attn_type) {
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_AUTO && !model.flash_attn_auto) {
+        flash_attn_type = MTMD_FLASH_ATTN_TYPE_DISABLED;
+    }
+
+    backend_cpu = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_CPU, nullptr);
+    if (!backend_cpu) {
+        throw std::runtime_error("failed to initialize CPU backend");
+    }
+    if (threadpool) {
+        ggml_backend_reg_t reg = ggml_backend_dev_backend_reg(ggml_backend_get_device(backend_cpu));
+        auto * set_threadpool_fn = (decltype(ggml_backend_cpu_set_threadpool) *) ggml_backend_reg_get_proc_address(reg, "ggml_backend_cpu_set_threadpool");
+        if (set_threadpool_fn) {
+            set_threadpool_fn(backend_cpu, threadpool.get());
+        }
+    }
+    if (params.device != nullptr) {
+        backend = ggml_backend_dev_init(params.device, nullptr);
+        if (!backend) {
+            ggml_backend_free(backend_cpu);
+            throw std::runtime_error(string_format("%s: failed to initialize \"%s\" backend\n",
+                                                   __func__, ggml_backend_dev_name(params.device)));
+        }
+    }
+
+    if (backend) {
+        LOG_INF("%s: using %s backend\n", __func__, ggml_backend_name(backend));
+        backend_ptrs.push_back(backend);
+        backend_buft.push_back(ggml_backend_get_default_buffer_type(backend));
+    } else {
+        backend = backend_cpu;
+        LOG_INF("%s: using CPU backend\n", __func__);
+    }
+
+    backend_ptrs.push_back(backend_cpu);
+    backend_buft.push_back(ggml_backend_get_default_buffer_type(backend_cpu));
+
+    sched.reset(
+        ggml_backend_sched_new(backend_ptrs.data(), backend_buft.data(), backend_ptrs.size(), max_nodes, false, true)
+    );
+
+    if (params.cb_eval != nullptr) {
+        ggml_backend_sched_set_eval_callback(sched.get(), params.cb_eval, params.cb_eval_user_data);
+    }
+
+    debug_output_embeddings = std::getenv("MTMD_DEBUG_EMBEDDINGS") != nullptr;
+
+    buf_compute_meta.resize(max_nodes * ggml_tensor_overhead() + ggml_graph_overhead_custom(max_nodes, false));
+
+    // TODO: fix warmup for audio gen
+    if (model.modality != MTMD_MODALITY_GEN_AUDIO) {
+        // check batching support
+        auto batch = get_dummy_batch();
+        support_batch = mtmd_get_graph_builder(this, batch)->support_batch();
+
+        if (params.warmup) {
+            warmup(batch);
+        }
+    }
+}
+
+mtmd_subcontext::~mtmd_subcontext() {
+    // the scheduler references the backends
+    sched.reset();
+    ggml_backend_free(backend);
+    if (backend != backend_cpu) {
+        ggml_backend_free(backend_cpu);
+    }
+}
+
+mtmd_image_f32_batch mtmd_subcontext::get_dummy_batch() const {
+    // create a fake batch
+    const auto & hparams = model.hparams;
+    mtmd_image_f32_batch batch;
+    mtmd_image_f32 img;
+    if (model.modality == MTMD_MODALITY_VISION) {
+        const int sz = hparams.warmup_image_size;
+        img.set_size({sz, sz}, false, false);
+        LOG_INF("%s: warmup with image size = %d x %d\n", __func__, sz, sz);
+    } else {
+        // GEMMA4UA uses n_mel_bins as a raw-waveform frame size (640), not a mel-bin count,
+        // so the [1, 256] bound only applies to FFT-based models.
+        const bool fft_based = model.proj_type != PROJECTOR_TYPE_GEMMA4UA;
+        if (hparams.n_mel_bins <= 0 || (fft_based && hparams.n_mel_bins > 256)) {
+            throw std::runtime_error(string_format("%s: invalid n_mel_bins (%d), must be in [1, 256]\n", __func__, hparams.n_mel_bins));
+        }
+        img.set_size({hparams.warmup_audio_size, hparams.n_mel_bins}, false, false);
+        LOG_INF("%s: warmup with audio size = %d\n", __func__, hparams.warmup_audio_size);
+    }
+    batch.entries.push_back(img);
+    return batch;
+}
+
+void mtmd_subcontext::warmup(const mtmd_image_f32_batch & batch) {
+    support_info_graph info;
+
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_AUTO) {
+        // try to enable flash attention to see if it's supported
+        flash_attn_type = MTMD_FLASH_ATTN_TYPE_ENABLED;
+        info = reserve_compute_meta(batch);
+        if (!info.fattn && info.fattn_op) {
+            auto op = info.fattn_op;
+            LOG_WRN("%s: *****************************************************************\n", __func__);
+            LOG_WRN("%s: WARNING: flash attention not supported by %s, memory usage will increase\n", __func__, ggml_backend_name(backend));
+            LOG_WRN("%s: op params: \n", __func__);
+            static auto print_shape = [](const char * fn, const char * name, ggml_tensor * t) {
+                LOG_WRN("%s:   %s: type = %s, ne = [%d %d %d %d], nb = [%d %d %d %d]\n", fn,
+                        name, ggml_type_name(t->type),
+                        t->ne[0], t->ne[1], t->ne[2], t->ne[3],
+                        t->nb[0], t->nb[1], t->nb[2], t->nb[3]);
+            };
+            print_shape(__func__, " dst", op);
+            print_shape(__func__, "src0", op->src[0]);
+            print_shape(__func__, "src1", op->src[1]);
+            print_shape(__func__, "src2", op->src[2]);
+            LOG_WRN("%s: please report this on github as an issue\n", __func__);
+            LOG_WRN("%s: *****************************************************************\n", __func__);
+            flash_attn_type = MTMD_FLASH_ATTN_TYPE_DISABLED;
+            reserve_compute_meta(batch);
+        }
+    } else {
+        info = reserve_compute_meta(batch);
+        if (!info.fattn && flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
+            LOG_WRN("%s: flash attention is not supported by the current backend; falling back to CPU (performance will be degraded)\n", __func__);
+        }
+    }
+
+    is_allocated = true; // mark buffers as allocated
+
+    LOG_INF("%s: flash attention is %s\n", __func__,
+        (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) ? "enabled" : "disabled");
+
+    // print ops that are not supported by the GPU backend (if there is one)
+    if (backend && backend != backend_cpu) {
+        std::vector<support_info_op> unsupported_ops;
+        for (const auto & op : info.ops) {
+            if (!op.is_accel) {
+                unsupported_ops.push_back(op);
+            }
+        }
+        if (!unsupported_ops.empty()) {
+            LOG_WRN("%s: *****************************************************************\n", __func__);
+            LOG_WRN("%s: WARNING: the mmproj graph uses unsupported operators by the backend\n", __func__);
+            LOG_WRN("%s:          the performance will be suboptimal                      \n", __func__);
+            LOG_WRN("%s:          list of unsupported ops (backend=%s):\n", __func__, ggml_backend_name(backend));
+            for (const auto & op : unsupported_ops) {
+                LOG_WRN("%s: %16s: type = %s, ne = [%d %d %d %d]\n", __func__,
+                        ggml_op_name(op.op->op),
+                        ggml_type_name(op.op->type),
+                        op.op->ne[0], op.op->ne[1], op.op->ne[2], op.op->ne[3]);
+            }
+            LOG_WRN("%s: flash attention is %s\n", __func__,
+                (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) ? "enabled" : "disabled");
+            LOG_WRN("%s: please report this on github as an issue\n", __func__);
+            LOG_WRN("%s: ref: https://github.com/ggml-org/llama.cpp/pull/16837#issuecomment-3461676118\n", __func__);
+            LOG_WRN("%s: *****************************************************************\n", __func__);
+        }
+    }
+}
+
+mtmd_subcontext::support_info_graph mtmd_subcontext::reserve_compute_meta(const mtmd_image_f32_batch & batch) {
+    ggml_cgraph * gf = mtmd_get_graph_builder(this, batch)->build();
+    ggml_backend_sched_reserve(sched.get(), gf);
+
+    mem_compute.clear();
+    for (size_t i = 0; i < backend_ptrs.size(); ++i) {
+        ggml_backend_t backend = backend_ptrs[i];
+        ggml_backend_buffer_type_t buft = backend_buft[i];
+        size_t size = ggml_backend_sched_get_buffer_size(sched.get(), backend);
+        if (size > 1) {
+            LOG_INF("%s: %10s compute buffer size = %8.2f MiB\n", __func__,
+                    ggml_backend_buft_name(buft),
+                    size / 1024.0 / 1024.0);
+        }
+        mem_compute[ggml_backend_get_device(backend)] += size;
+    }
+
+    const int n_splits = ggml_backend_sched_get_n_splits(sched.get());
+    const int n_nodes  = ggml_graph_n_nodes(gf);
+
+    LOG_INF("%s: graph splits = %d, nodes = %d\n", __func__,  n_splits, n_nodes);
+
+    support_info_graph res {
+        /*.fattn    = */ true,
+        /*.fattn_op = */ nullptr,
+        /*.ops      = */ {},
+    };
+
+    // check op support
+    for (int i = 0; i < ggml_graph_n_nodes(gf); i++) {
+        ggml_tensor * node = ggml_graph_node(gf, i);
+        res.ops.push_back({node, true});
+        if (!ggml_backend_supports_op(backend, node)) {
+            res.ops.back().is_accel = false;
+            if (node->op == GGML_OP_FLASH_ATTN_EXT) {
+                res.fattn    = false;
+                res.fattn_op = node;
+            }
+        }
+    }
+
+    return res;
+}
+
+// TODO: remove this and use ggml_mul_mat with GGML_PREC_F32 once all backends honor it for F32 x F32
+// ggml_mul_mat for 2D F32 tensors, always in F32 on CPU: some GPU backends round the inputs to F16 or TF32, which loses the quiet bins of a spectrum
+static void mtmd_mul_mat_prec_op(ggml_tensor * dst, int ith, int nth, void * /*userdata*/) {
+    const ggml_tensor * a = dst->src[0];
+    const ggml_tensor * b = dst->src[1];
+    const int64_t n_k = a->ne[0];
+
+    for (int64_t n = ith; n < b->ne[1]; n += nth) {
+        const float * b_row = (const float *) ((const char *) b->data + n * b->nb[1]);
+        float * dst_row = (float *) ((char *) dst->data + n * dst->nb[1]);
+        for (int64_t m = 0; m < a->ne[1]; m++) {
+            const float * a_row = (const float *) ((const char *) a->data + m * a->nb[1]);
+            // independent partial sums, so that the compiler can vectorize the loop
+            float acc[16] = {};
+            int64_t k = 0;
+            for (; k + 16 <= n_k; k += 16) {
+                for (int l = 0; l < 16; l++) {
+                    acc[l] += a_row[k + l] * b_row[k + l];
+                }
+            }
+            float sum = 0.0f;
+            for (; k < n_k; k++) {
+                sum += a_row[k] * b_row[k];
+            }
+            for (int l = 0; l < 16; l++) {
+                sum += acc[l];
+            }
+            dst_row[m] = sum;
+        }
+    }
+}
+
+static ggml_tensor * mtmd_mul_mat_prec(ggml_context * ctx, ggml_tensor * a, ggml_tensor * b) {
+    GGML_ASSERT(a->type == GGML_TYPE_F32 && b->type == GGML_TYPE_F32);
+    GGML_ASSERT(ggml_n_dims(a) <= 2 && ggml_n_dims(b) <= 2 && a->ne[0] == b->ne[0]);
+    GGML_ASSERT(a->nb[0] == sizeof(float) && b->nb[0] == sizeof(float));
+    ggml_tensor * args[] = { a, b };
+    return ggml_custom_4d(ctx, GGML_TYPE_F32, a->ne[1], b->ne[1], 1, 1, args, 2, mtmd_mul_mat_prec_op, GGML_N_TASKS_MAX, nullptr);
+}
+
+bool mtmd_audio_log_mel(const mtmd_audio_mel_config & config, const float * samples, const float * window, int64_t n_frames, float * out) {
+    ggml_backend_sched_t sched = config.sched;
+    ggml_backend_t backend_cpu = ggml_backend_sched_get_backend(sched, ggml_backend_sched_get_n_backends(sched) - 1);
+    mtmd_backend_cpu_set_n_threads(backend_cpu, std::max(1, config.n_threads));
+
+    const auto &  cache      = *config.cache;
+    const auto &  filters    = cache.filters.data;
+    const int64_t n_fft_bins = config.n_fft_bins;
+    const int64_t frame_size = (n_fft_bins - 1) * 2;
+    const int64_t n_mel      = config.n_mel;
+    GGML_ASSERT(filters.size() == (size_t) (n_mel * n_fft_bins));
+    GGML_ASSERT(cache.cos_vals.size() == (size_t) frame_size && cache.sin_vals.size() == (size_t) frame_size);
+
+    // DFT matrix with the window folded in, the first n_fft_bins rows give the real part
+    std::vector<float> dft((size_t) 2 * n_fft_bins * frame_size);
+    for (int64_t k = 0; k < n_fft_bins; k++) {
+        for (int64_t j = 0; j < frame_size; j++) {
+            const int64_t q = (j * k) % frame_size;
+            dft[k * frame_size + j]                = window[j] * cache.cos_vals[q];
+            dft[(k + n_fft_bins) * frame_size + j] = window[j] * cache.sin_vals[q];
+        }
+    }
+
+    // the spectrum is several times larger than the result, so long audio is processed in blocks
+    const int64_t n_frames_max = 8192;
+
+    for (int64_t off = 0; off < n_frames; off += n_frames_max) {
+        const int64_t n_cur = std::min(n_frames_max, n_frames - off);
+
+        ggml_init_params params = {
+            /*.mem_size   =*/ ggml_tensor_overhead() * 32 + ggml_graph_overhead(),
+            /*.mem_buffer =*/ nullptr,
+            /*.no_alloc   =*/ true,
+        };
+        ggml_context_ptr ctx_ptr(ggml_init(params));
+        ggml_context * ctx0 = ctx_ptr.get();
+
+        ggml_tensor * inp_samples = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, (n_cur - 1) * config.hop_length + frame_size);
+        ggml_tensor * inp_dft     = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, frame_size, 2 * n_fft_bins);
+        ggml_tensor * inp_filters = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_fft_bins, n_mel);
+        ggml_set_input(inp_samples);
+        ggml_set_input(inp_dft);
+        ggml_set_input(inp_filters);
+
+        ggml_tensor * cur = ggml_cont(ctx0, ggml_view_2d(ctx0, inp_samples, frame_size, n_cur, config.hop_length * ggml_element_size(inp_samples), 0));
+        cur = mtmd_mul_mat_prec(ctx0, inp_dft, cur); // [2 * n_fft_bins, n_cur]
+
+        cur = ggml_sqr(ctx0, cur);
+        cur = ggml_add(ctx0,
+                ggml_view_2d(ctx0, cur, n_fft_bins, n_cur, cur->nb[1], 0),
+                ggml_view_2d(ctx0, cur, n_fft_bins, n_cur, cur->nb[1], n_fft_bins * ggml_element_size(cur)));
+        if (config.use_magnitude) {
+            cur = ggml_sqrt(ctx0, cur);
+        }
+
+        cur = mtmd_mul_mat_prec(ctx0, cur, inp_filters); // [n_cur, n_mel]
+        cur = config.mel_floor_add
+            ? ggml_scale_bias(ctx0, cur, 1.0f, config.mel_floor)
+            : ggml_clamp(ctx0, cur, config.mel_floor, FLT_MAX);
+        cur = ggml_log(ctx0, cur);
+        if (!config.use_natural_log) {
+            cur = ggml_scale(ctx0, cur, 1.0f / logf(10.0f));
+        }
+        ggml_set_output(cur);
+
+        ggml_cgraph * gf = ggml_new_graph(ctx0);
+        ggml_build_forward_expand(gf, cur);
+
+        ggml_backend_sched_reset(sched);
+        if (!ggml_backend_sched_alloc_graph(sched, gf)) {
+            LOG_ERR("%s: failed to allocate compute graph\n", __func__);
+            return false;
+        }
+
+        ggml_backend_tensor_set(inp_samples, samples + off * config.hop_length, 0, ggml_nbytes(inp_samples));
+        ggml_backend_tensor_set(inp_dft,     dft.data(),                        0, ggml_nbytes(inp_dft));
+        ggml_backend_tensor_set(inp_filters, filters.data(),                    0, ggml_nbytes(inp_filters));
+
+        auto status = ggml_backend_sched_graph_compute(sched, gf);
+        if (status != GGML_STATUS_SUCCESS) {
+            LOG_ERR("%s: ggml_backend_sched_graph_compute failed with error %d\n", __func__, status);
+            return false;
+        }
+
+        for (int64_t j = 0; j < n_mel; j++) {
+            ggml_backend_tensor_get(cur, out + j * n_frames + off, j * cur->nb[1], n_cur * ggml_element_size(cur));
+        }
+    }
+
+    return true;
+}
+
+bool mtmd_subcontext::encode_image(int n_threads, const mtmd_image_f32 * img, std::vector<float> & out_vec) {
+    mtmd_image_f32_batch imgs;
+    mtmd_image_f32 img_copy = *img;
+    imgs.entries.push_back(std::move(img_copy));
+
+    return encode_batch(n_threads, &imgs, out_vec);
+}
+
+bool mtmd_subcontext::encode_batch(int n_threads, const mtmd_image_f32_batch * imgs_c_ptr, std::vector<float> & out_batch_embd) {
+    mtmd_subctx_encode_params params;
+    params.imgs = imgs_c_ptr;
+    params.n_threads = n_threads;
+    params.out_embd = &out_batch_embd;
+
+    if (model.proj_type == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
+        return mtmd_graph_nemotron_transcribe::encode_windows(this, model, &params);
+    }
+    return encode(&params);
+}
+
+// persisted state slots of the gen-audio decoders and the streaming audio encoders
+static std::vector<c2w_state_slot> list_gen_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
+    switch (model.proj_type) {
+        case PROJECTOR_TYPE_QWEN3TTS_GEN:  return list_c2w_state_slots(hparams, model);
+        case PROJECTOR_TYPE_DRAMA3_GEN:    return list_drama3_state_slots(hparams, model);
+        case PROJECTOR_TYPE_POCKETTTS_GEN: return list_pockettts_state_slots(hparams, model);
+        case PROJECTOR_TYPE_LFM3A_GEN:     return list_lfm3a_gen_state_slots(hparams, model);
+        default:                           return {};
+    }
+}
+
+// state_out can have another shape than its slot, only the byte size must match
+static void copy_gen_state_out(ggml_tensor * src, ggml_tensor * dst) {
+    GGML_ASSERT(ggml_nbytes(src) == ggml_nbytes(dst));
+    if (src->type == dst->type && ggml_are_same_shape(src, dst) && ggml_is_contiguous(src)) {
+        ggml_backend_tensor_copy(src, dst);
+        return;
+    }
+    std::vector<uint8_t> tmp(ggml_nbytes(src));
+    ggml_backend_tensor_get(src, tmp.data(), 0, tmp.size());
+    ggml_backend_tensor_set(dst, tmp.data(), 0, tmp.size());
+}
+
+mtmd_subctx_gen_state * mtmd_subcontext::gen_state_init(uint32_t seed) {
+    auto st = std::make_unique<mtmd_subctx_gen_state>();
+    st->rng.seed(seed == UINT32_MAX ? std::random_device{}() : seed);
+
+    const auto slots = list_gen_state_slots(model.hparams, model);
+    if (slots.empty()) {
+        return st.release();
+    }
+
+    ggml_init_params params = {
+        /*.mem_size   =*/ ggml_tensor_overhead() * slots.size(),
+        /*.mem_buffer =*/ nullptr,
+        /*.no_alloc   =*/ true,
+    };
+    st->ctx_data.reset(ggml_init(params));
+    for (const auto & slot : slots) {
+        ggml_tensor * t = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F32, slot.ne0, slot.ne1);
+        ggml_format_name(t, "gen_state_%s", slot.name.c_str());
+        st->slots.push_back(t);
+    }
+    st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), backend));
+    if (!st->buf) {
+        LOG_ERR("%s: failed to allocate the gen state\n", __func__);
+        return nullptr;
+    }
+    // a zero state is the cold start
+    ggml_backend_buffer_clear(st->buf.get(), 0);
+
+    return st.release();
+}
+
+mtmd_subctx_enc_state * mtmd_subcontext::enc_state_init(int32_t n_frames_max) {
+    const auto & hparams = model.hparams;
+    if (model.proj_type == PROJECTOR_TYPE_LFM3A) {
+        // downsampler context, short-conv taps and attention window, see list_lfm3a_state_slots()
+        const auto slots = list_lfm3a_state_slots(hparams, model);
+        auto st = std::make_unique<mtmd_subctx_enc_state>();
+        ggml_init_params params = {
+            /*.mem_size   =*/ ggml_tensor_overhead() * slots.size(),
+            /*.mem_buffer =*/ nullptr,
+            /*.no_alloc   =*/ true,
+        };
+        st->ctx_data.reset(ggml_init(params));
+        for (const auto & slot : slots) {
+            ggml_tensor * t = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F32, slot.ne0, slot.ne1);
+            ggml_format_name(t, "enc_state_%s", slot.name.c_str());
+            st->slots.push_back(t);
+        }
+        st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), backend));
+        if (!st->buf) {
+            LOG_ERR("%s: failed to allocate the encoder state\n", __func__);
+            return nullptr;
+        }
+        // a zero state is the cold start
+        ggml_backend_buffer_clear(st->buf.get(), 0);
+        return st.release();
+    }
+    if (model.proj_type != PROJECTOR_TYPE_VOXTRAL_RT || hparams.attn_window_size <= 0) {
+        LOG_ERR("%s: the encoder does not support streaming input\n", __func__);
+        return nullptr;
+    }
+    if (n_frames_max <= 0) {
+        LOG_ERR("%s: invalid n_frames_max %d\n", __func__, n_frames_max);
+        return nullptr;
+    }
+
+    const int n_layer   = hparams.n_layer;
+    const int d_head    = hparams.n_embd_head > 0 ? hparams.n_embd_head : hparams.n_embd / hparams.n_head;
+    const int n_embd_kv = hparams.n_head_kv * d_head;
+
+    auto st = std::make_unique<mtmd_subctx_enc_state>();
+    st->n_ctx = hparams.attn_window_size + n_frames_max;
+    st->slot_pos.assign(st->n_ctx, -1);
+
+    ggml_init_params params = {
+        /*.mem_size   =*/ ggml_tensor_overhead() * 2 * n_layer,
+        /*.mem_buffer =*/ nullptr,
+        /*.no_alloc   =*/ true,
+    };
+    st->ctx_data.reset(ggml_init(params));
+    for (int il = 0; il < n_layer; il++) {
+        ggml_tensor * k = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F16, n_embd_kv, st->n_ctx);
+        ggml_tensor * v = ggml_new_tensor_2d(st->ctx_data.get(), GGML_TYPE_F16, n_embd_kv, st->n_ctx);
+        ggml_format_name(k, "enc_state_k_%d", il);
+        ggml_format_name(v, "enc_state_v_%d", il);
+        st->k.push_back(k);
+        st->v.push_back(v);
+    }
+    st->buf.reset(ggml_backend_alloc_ctx_tensors(st->ctx_data.get(), backend));
+    if (!st->buf) {
+        LOG_ERR("%s: failed to allocate the encoder state\n", __func__);
+        return nullptr;
+    }
+    // empty slots are masked out, but they must not hold NaN
+    ggml_backend_buffer_clear(st->buf.get(), 0);
+
+    LOG_INF("%s: encoder state: %d slots, %.2f MiB\n", __func__, st->n_ctx, ggml_backend_buffer_get_size(st->buf.get()) / 1024.0 / 1024.0);
+
+    return st.release();
+}
+
+bool mtmd_subcontext::encode(mtmd_subctx_encode_params * params) {
+    const mtmd_image_f32_batch & imgs = *params->imgs;
+    int n_batch_cur = imgs.entries.size();
+
+    // [QWEN_VIDEO] for video models, the batch dimension is used as temporal dimension for merged frames
+    if (!support_batch && n_batch_cur > model.n_temporal_merge()) {
+        LOG_ERR("%s: batch size %d exceeds maximum supported batch/temporal-merge size %d\n", __func__, n_batch_cur, model.n_temporal_merge());
+        return false;
+    }
+
+    // if buffers are not allocated, we need to do a warmup run to allocate them
+    if (!is_allocated) {
+        warmup(*params->imgs);
+    }
+
+    const bool needs_gen_state = params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_CODE ||
+                                 params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV ||
+                                 params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TTS;
+    if (needs_gen_state && params->gen_state == nullptr) {
+        LOG_ERR("%s: audio gen requires a gen state\n", __func__);
+        return false;
+    }
+    if (model.proj_type == PROJECTOR_TYPE_DRAMA3_GEN && params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_CODE) {
+        float pos = 0.0f;
+        ggml_backend_tensor_get(params->gen_state->slots[0], &pos, 0, sizeof(float));
+        if ((int) pos >= mtmd_graph_drama3_gen::n_pos_max) {
+            LOG_ERR("%s: the codebook tower holds at most %d rows\n", __func__, mtmd_graph_drama3_gen::n_pos_max);
+            return false;
+        }
+    }
+
+    // build the inference graph
+    ggml_backend_sched_reset(sched.get());
+    ggml_cgraph * gf = mtmd_get_graph_builder(this, imgs, params)->build();
+
+    // the last node is the embedding tensor, code2wav has no out_embd
+    // read it before scheduling: backends may reorder the nodes of graphs with several outputs (Metal)
+    ggml_tensor * embeddings = params->out_embd ? ggml_graph_node(gf, -1) : nullptr;
+
+    if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) {
+        LOG_ERR("%s: failed to allocate compute graph\n", __func__);
+        return false;
+    }
+
+    // set inputs
+    const auto & hparams = model.hparams;
+
+    const int image_size_width  = imgs.entries[0].nx();
+    const int image_size_height = imgs.entries[0].ny();
+
+    const int patch_size    = hparams.patch_size;
+    const int num_patches   = ((image_size_width / patch_size) * (image_size_height / patch_size));
+    const int n_pos = num_patches + (model.class_embedding ? 1 : 0);
+    const int pos_w = image_size_width  / patch_size;
+    const int pos_h = image_size_height / patch_size;
+
+
+    auto get_inp_tensor = [&gf](const char * name) {
+        ggml_tensor * inp = ggml_graph_get_tensor(gf, name);
+        if (inp == nullptr) {
+            GGML_ABORT("Failed to get tensor %s", name);
+        }
+        if (!(inp->flags & GGML_TENSOR_FLAG_INPUT)) {
+            GGML_ABORT("Tensor %s is not an input tensor", name);
+        }
+        return inp;
+    };
+
+    auto set_input_f32 = [&get_inp_tensor](const char * name, const std::vector<float> & values) {
+        ggml_tensor * cur = get_inp_tensor(name);
+        GGML_ASSERT(cur->type == GGML_TYPE_F32);
+        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
+        ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
+    };
+
+    // mask from build_inp_attn_mask(), f16 if flash attn is enabled
+    auto set_input_attn_mask = [&get_inp_tensor](const std::vector<float> & values, int idx = 0) {
+        ggml_tensor * cur = get_inp_tensor(get_attn_mask_name(idx).c_str());
+        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
+        if (cur->type == GGML_TYPE_F16) {
+            std::vector<ggml_fp16_t> values_f16(values.size());
+            ggml_fp32_to_fp16_row(values.data(), values_f16.data(), values.size());
+            ggml_backend_tensor_set(cur, values_f16.data(), 0, ggml_nbytes(cur));
+        } else {
+            GGML_ASSERT(cur->type == GGML_TYPE_F32);
+            ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
+        }
+    };
+
+    auto set_input_i32 = [&get_inp_tensor](const char * name, std::vector<int32_t> & values) {
+        ggml_tensor * cur = get_inp_tensor(name);
+        GGML_ASSERT(cur->type == GGML_TYPE_I32);
+        GGML_ASSERT(ggml_nelements(cur) == (int64_t)values.size());
+        ggml_backend_tensor_set(cur, values.data(), 0, ggml_nbytes(cur));
+    };
+
+    // state of the previous call, zero on a cold start
+    auto set_gen_state_in = [&]() {
+        const auto & slots = params->gen_state->slots;
+        size_t i = 0;
+        for (const auto & slot : list_gen_state_slots(hparams, model)) {
+            GGML_ASSERT(i < slots.size());
+            if (slot.in_place) {
+                i++;
+                continue;
+            }
+            ggml_backend_tensor_copy(slots[i++], get_inp_tensor(("state_in_" + slot.name).c_str()));
+        }
+    };
+
+    // lfm3a encoder and codec: causal sliding window over [cached window | this call]
+    auto set_lfm3a_window_inputs = [&](int base, int n_pos, int window) {
+        std::vector<int32_t> positions(n_pos);
+        for (int i = 0; i < n_pos; i++) {
+            positions[i] = base + i;
+        }
+        set_input_i32("positions", positions);
+
+        // key j holds position base - prefix + j
+        const int prefix = window - 1;
+        const int n_kv   = prefix + n_pos;
+        std::vector<float> mask((size_t) n_kv * n_pos, -INFINITY);
+        for (int q = 0; q < n_pos; q++) {
+            for (int j = std::max(0, q + prefix - window + 1); j <= q + prefix; j++) {
+                if (base - prefix + j >= 0) {
+                    mask[(size_t) q * n_kv + j] = 0.0f;
+                }
+            }
+        }
+        set_input_attn_mask(mask);
+    };
+
+    // rope positions and attention mask of the mimi transformers (pocket-tts).
+    // the mask is causal with a sliding window, see _build_attention_mask() in the reference
+    auto set_pockettts_tfm_inputs = [&]() {
+        const int64_t n_pos = ggml_nelements(get_inp_tensor("inp_pos"));
+        GGML_ASSERT(n_pos > 0);
+        std::vector<int32_t> positions((size_t) n_pos);
+        for (int64_t i = 0; i < n_pos; i++) {
+            positions[(size_t) i] = (int32_t) i;
+        }
+        set_input_i32("inp_pos", positions);
+
+        // the preprocessor truncates the waveform to keep this mask bounded
+        const int64_t max_pos = (int64_t) mtmd_hparams::pockettts_max_spk_seconds * hparams.audio_sample_rate / 120;
+        GGML_ASSERT(n_pos <= max_pos && "pocket-tts speaker reference too long for a dense mask");
+
+        const int64_t context = hparams.mimi_tfm_context;
+        std::vector<float> mask((size_t) n_pos * n_pos, -INFINITY);
+        for (int64_t q = 0; q < n_pos; q++) {
+            for (int64_t k = 0; k < n_pos; k++) {
+                const int64_t delta = q - k;
+                if (delta >= 0 && delta < context) {
+                    mask[(size_t) q * n_pos + k] = 0.0f;
+                }
+            }
+        }
+        set_input_attn_mask(mask);
+    };
+
+    // set input pixel values
+    if (!imgs.is_audio) {
+        size_t nelem = 0;
+        for (const auto & img : imgs.entries) {
+            nelem += img.nx() * img.ny() * 3;
+        }
+        std::vector<float> inp_raw(nelem);
+
+        // layout of data (note: the channel dim is unrolled to better visualize the layout):
+        //
+        // ┌──W──┐
+        // │     H │  channel = R
+        // ├─────┤ │
+        // │     H │  channel = G
+        // ├─────┤ │
+        // │     H │  channel = B
+        // └─────┘ │
+        //   ──────┘ x B
+
+        // IMPORTANT: [QWEN_VIDEO] the batch dim is currently used for temporal dim in Qwen-VL models
+        // All entries must have the same spatial size (enforced by can_batch_with() during merging)
+        {
+            const int nx = imgs.entries[0].nx();
+            const int ny = imgs.entries[0].ny();
+            const int n  = nx * ny;
+
+            for (int b = 0; b < n_batch_cur; b++) {
+                LOG_DBG("%s: copying image %d/%d to input buffer (nx=%d, ny=%d)\n", __func__, b+1, n_batch_cur, nx, ny);
+                const auto & buf = imgs.entries[b].get_ro_buf();
+                float * batch_entry = inp_raw.data() + b * (3*n);
+                for (int y = 0; y < ny; y++) {
+                    for (int x = 0; x < nx; x++) {
+                        size_t base_src = 3*(y * nx + x);
+                        size_t base_dst =    y * nx + x;
+                        batch_entry[      base_dst] = buf[base_src    ];
+                        batch_entry[1*n + base_dst] = buf[base_src + 1];
+                        batch_entry[2*n + base_dst] = buf[base_src + 2];
+                    }
+                }
+            }
+        }
+        set_input_f32("inp_raw", inp_raw);
+
+    } else if (params->gen_process != MTMD_SUBCTX_GEN_PROCESS_GEN_WAV &&
+               params->gen_process != MTMD_SUBCTX_GEN_PROCESS_TTS &&
+               params->gen_process != MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE &&
+               ggml_graph_get_tensor(gf, "inp_raw") != nullptr) {
+        // audio input. GEN_WAV and the chatterbox TTS stages are not here: they take codes or feats, set in the switch below.
+        // a GEN_CODE graph may have no hidden-state input (lfm3a_gen feedback embedding)
+        GGML_ASSERT(imgs.entries.size() == 1);
+
+        const auto & mel_inp = imgs.entries[0];
+        const auto & buf = mel_inp.get_ro_buf();
+        const int n_step = mel_inp.nx();
+        const int n_mel  = mel_inp.ny();
+        GGML_ASSERT((size_t)n_step * n_mel == buf.size());
+
+        set_input_f32("inp_raw", buf);
+    }
+
+    // set input per projector
+    switch (model.proj_type) {
+        case PROJECTOR_TYPE_VOXTRAL_RT:
+            {
+                mtmd_subctx_enc_state * st = params->enc_state;
+                if (!st) {
+                    LOG_ERR("%s: this encoder needs a mtmd_subctx_enc_state\n", __func__);
+                    return false;
+                }
+                const int n_window = hparams.attn_window_size;
+                const int n_new    = (imgs.entries[0].nx() - 2) / 2;
+                GGML_ASSERT(n_new <= st->n_ctx - n_window);
+
+                std::vector<int32_t> pos(n_new);
+                std::vector<int64_t> idx(n_new);
+                std::vector<int32_t> slot_pos = st->slot_pos; // including the frames written by this call
+                for (int i = 0; i < n_new; i++) {
+                    pos[i] = st->n_past + i;
+                    idx[i] = pos[i] % st->n_ctx;
+                    slot_pos[idx[i]] = pos[i];
+                }
+                set_input_i32("inp_pos", pos);
+                ggml_backend_tensor_set(get_inp_tensor("inp_kv_idx"), idx.data(), 0, idx.size() * sizeof(int64_t));
+
+                // each frame sees itself and the n_window - 1 frames before it
+                std::vector<float> mask((size_t) st->n_ctx * n_new, -INFINITY);
+                for (int i = 0; i < n_new; i++) {
+                    for (int s = 0; s < st->n_ctx; s++) {
+                        const int32_t p = slot_pos[s];
+                        if (p >= 0 && p <= pos[i] && p > pos[i] - n_window) {
+                            mask[(size_t) i * st->n_ctx + s] = 0.0f;
+                        }
+                    }
+                }
+                set_input_attn_mask(mask);
+
+                std::vector<float> tail = st->stem_tail;
+                tail.resize(hparams.n_embd, 0.0f);
+                set_input_f32("inp_stem_tail", tail);
+            } break;
+        case PROJECTOR_TYPE_MUSE_GLIMMER:
+            {
+                const int grid_w = pos_w;            // image_size_width  / patch_size
+                const int grid_h = pos_h;            // image_size_height / patch_size
+                const int n_tok  = grid_w * grid_h;
+                const int pgrid  = (int) std::sqrt((double) model.position_embeddings->ne[1]); // 32
+                const int f      = hparams.n_merge;  // downsample 2
+
+                // pixel patchify runs inside the graph via build_inp() (ggml_conv_2d);
+                // pos-emb bilinear interp via resize_position_embeddings().
+
+                // --- sparse window grouping (pgrid x pgrid windows) ---
+                const int win = pgrid;
+                const int nwin_h = (grid_h + win - 1) / win;
+                const int nwin_w = (grid_w + win - 1) / win;
+                std::vector<int32_t> sp_perm; sp_perm.reserve(n_tok);
+                std::vector<int>     sp_slens;
+                for (int wy = 0; wy < nwin_h; wy++) {
+                    for (int wx = 0; wx < nwin_w; wx++) {
+                        int cnt = 0;
+                        for (int hh = 0; hh < win; hh++) {
+                            for (int ww = 0; ww < win; ww++) {
+                                const int gy = wy * win + hh;
+                                const int gx = wx * win + ww;
+                                if (gy < grid_h && gx < grid_w) { sp_perm.push_back(gy * grid_w + gx); cnt++; }
+                            }
+                        }
+                        if (cnt > 0) sp_slens.push_back(cnt);
+                    }
+                }
+                std::vector<int32_t> rpos_w(n_tok), rpos_h(n_tok), inv_perm(n_tok);
+                for (int i = 0; i < n_tok; i++) {
+                    const int orig = sp_perm[i];
+                    rpos_w[i] = (orig % grid_w) + 1; // 1-indexed
+                    rpos_h[i] = (orig / grid_w) + 1;
+                    inv_perm[orig] = i;
+                }
+                set_input_i32("muse_glimmer_sp_perm",  sp_perm);
+                set_input_i32("muse_glimmer_inv_perm", inv_perm);
+                set_input_i32("muse_glimmer_pos_w",    rpos_w);
+                set_input_i32("muse_glimmer_pos_h",    rpos_h);
+
+                // block-diagonal window mask (permuted order)
+                std::vector<float> sp_mask((size_t) n_tok * n_tok, -INFINITY);
+                {
+                    int off = 0;
+                    for (int s : sp_slens) {
+                        for (int a = 0; a < s; a++)
+                            for (int b = 0; b < s; b++)
+                                sp_mask[(size_t) (off + a) * n_tok + (off + b)] = 0.0f;
+                        off += s;
+                    }
+                }
+                set_input_attn_mask(sp_mask);
+
+                // pixel-shuffle gather (original order): f*f spatial neighbours grouped
+                std::vector<int32_t> dsp; dsp.reserve(n_tok);
+                for (int oy = 0; oy < grid_h / f; oy++)
+                    for (int ox = 0; ox < grid_w / f; ox++)
+                        for (int ry = 0; ry < f; ry++)
+                            for (int rx = 0; rx < f; rx++)
+                                dsp.push_back((oy * f + ry) * grid_w + (ox * f + rx));
+                set_input_i32("muse_glimmer_ds_perm", dsp);
+            } break;
+        case PROJECTOR_TYPE_MINICPMV:
+            {
+                // inspired from siglip:
+                //    -> https://huggingface.co/HuggingFaceM4/siglip-so400m-14-980-flash-attn2-navit
+                //    -> https://huggingface.co/HuggingFaceM4/siglip-so400m-14-980-flash-attn2-navit/blob/d66538faeba44480d0bfaa42145eef26f9423199/modeling_siglip.py#L316
+                std::vector<int32_t> positions(pos_h * pos_w);
+                // note: sized by the actual patch counts; a tall/wide image produces more
+                // than 1024 patches per side and a fixed [1024] array would be overrun
+                std::vector<int> bucket_coords_h(pos_h);
+                std::vector<int> bucket_coords_w(pos_w);
+                for (int i = 0; i < pos_h; i++){
+                    bucket_coords_h[i] = std::floor(70.0*i/pos_h);
+                }
+                for (int i = 0; i < pos_w; i++){
+                    bucket_coords_w[i] = std::floor(70.0*i/pos_w);
+                }
+                for (int i = 0, id = 0; i < pos_h; i++){
+                    for (int j = 0; j < pos_w; j++){
+                        positions[id++] = bucket_coords_h[i]*70 + bucket_coords_w[j];
+                    }
+                }
+                set_input_i32("positions", positions);
+
+                // inputs for resampler projector
+                // set the 2D positions (using float for sinusoidal embedding)
+                int n_patches_per_col = image_size_width / patch_size;
+                std::vector<float> pos_data(n_pos);
+                // dimension H
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = static_cast<float>(i / n_patches_per_col);
+                }
+                set_input_f32("pos_h", pos_data);
+                // dimension W
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = static_cast<float>(i % n_patches_per_col);
+                }
+                set_input_f32("pos_w", pos_data);
+                // base frequency omega
+                const float base_freq   = 10000.0f;
+                const int   n_embd_proj = model.n_mmproj_embd();
+                std::vector<float> omega(n_embd_proj / 4);
+                for (int i = 0; i < n_embd_proj / 4; ++i) {
+                    omega[i] = 1.0f / std::pow(base_freq, static_cast<float>(i) / (n_embd_proj / 4));
+                }
+                set_input_f32("omega", omega);
+            } break;
+        case PROJECTOR_TYPE_MINICPMV4_6:
+        case PROJECTOR_TYPE_MINICPMV4_7:
+        case PROJECTOR_TYPE_MINICPM_OCR_1_0:
+            {
+                const bool is_4x = hparams.n_merge == 2;
+
+                // SigLIP position buckets (same as resampler path)
+                std::vector<int32_t> positions(pos_h * pos_w);
+                std::vector<int> bucket_coords_h(pos_h);
+                std::vector<int> bucket_coords_w(pos_w);
+                for (int i = 0; i < pos_h; i++){
+                    bucket_coords_h[i] = std::floor(70.0*i/pos_h);
+                }
+                for (int i = 0; i < pos_w; i++){
+                    bucket_coords_w[i] = std::floor(70.0*i/pos_w);
+                }
+                for (int i = 0, id = 0; i < pos_h; i++){
+                    for (int j = 0; j < pos_w; j++){
+                        positions[id++] = bucket_coords_h[i]*70 + bucket_coords_w[j];
+                    }
+                }
+                set_input_i32("positions", positions);
+
+                const int half_h = pos_h / 2;
+                const int half_w = pos_w / 2;
+
+                auto make_ds_idx = [](int off_r, int off_c, int ds_h, int ds_w, int stride_w) {
+                    std::vector<int32_t> idx(ds_h * ds_w);
+                    for (int i = 0; i < ds_h; i++) {
+                        for (int j = 0; j < ds_w; j++) {
+                            idx[i * ds_w + j] = (2*i + off_r) * stride_w + (2*j + off_c);
+                        }
+                    }
+                    return idx;
+                };
+
+                if (!is_4x) {
+                    // window reorder indices for 2x2 windows
+                    std::vector<int32_t> window_idx(n_pos);
+                    std::vector<int32_t> inv_window_idx(n_pos);
+                    {
+                        int k = 0;
+                        for (int wi = 0; wi < half_h; wi++) {
+                            for (int wj = 0; wj < half_w; wj++) {
+                                window_idx[k++] = (2*wi    ) * pos_w + (2*wj    );
+                                window_idx[k++] = (2*wi    ) * pos_w + (2*wj + 1);
+                                window_idx[k++] = (2*wi + 1) * pos_w + (2*wj    );
+                                window_idx[k++] = (2*wi + 1) * pos_w + (2*wj + 1);
+                            }
+                        }
+                        for (int i = 0; i < n_pos; i++) {
+                            inv_window_idx[window_idx[i]] = i;
+                        }
+                    }
+                    set_input_i32("vit_merger_window_idx",     window_idx);
+                    set_input_i32("vit_merger_inv_window_idx", inv_window_idx);
+
+                    // block-diagonal attention mask: tokens in the same 4-token
+                    // window attend to each other (mask = 0), all other positions
+                    // are masked out (-inf). matches the window-major reorder above.
+                    std::vector<float> window_mask_data(n_pos * n_pos, std::numeric_limits<float>::lowest());
+                    for (int wi = 0; wi < n_pos / 4; wi++) {
+                        for (int i = 0; i < 4; i++) {
+                            for (int j = 0; j < 4; j++) {
+                                window_mask_data[(wi*4 + i) * n_pos + (wi*4 + j)] = 0.0f;
+                            }
+                        }
+                    }
+                    set_input_attn_mask(window_mask_data);
+
+                    // ViT merger 2x2 downsample indices
+                    auto vit_merger_ds_0 = make_ds_idx(0, 0, half_h, half_w, pos_w);
+                    auto vit_merger_ds_1 = make_ds_idx(0, 1, half_h, half_w, pos_w);
+                    auto vit_merger_ds_2 = make_ds_idx(1, 0, half_h, half_w, pos_w);
+                    auto vit_merger_ds_3 = make_ds_idx(1, 1, half_h, half_w, pos_w);
+                    set_input_i32("vit_merger_ds_idx_0", vit_merger_ds_0);
+                    set_input_i32("vit_merger_ds_idx_1", vit_merger_ds_1);
+                    set_input_i32("vit_merger_ds_idx_2", vit_merger_ds_2);
+                    set_input_i32("vit_merger_ds_idx_3", vit_merger_ds_3);
+                }
+
+                const int merger_h = is_4x ? pos_h : half_h;
+                const int merger_w = is_4x ? pos_w : half_w;
+                auto m_ds_0 = make_ds_idx(0, 0, merger_h / 2, merger_w / 2, merger_w);
+                auto m_ds_1 = make_ds_idx(0, 1, merger_h / 2, merger_w / 2, merger_w);
+                auto m_ds_2 = make_ds_idx(1, 0, merger_h / 2, merger_w / 2, merger_w);
+                auto m_ds_3 = make_ds_idx(1, 1, merger_h / 2, merger_w / 2, merger_w);
+                set_input_i32("merger_ds_idx_0", m_ds_0);
+                set_input_i32("merger_ds_idx_1", m_ds_1);
+                set_input_i32("merger_ds_idx_2", m_ds_2);
+                set_input_i32("merger_ds_idx_3", m_ds_3);
+            } break;
+        case PROJECTOR_TYPE_LFM3VL:
+            {
+                // patches in raster-scan order (no interleave before encoder layers)
+                const int pw = image_size_width  / patch_size;
+                const int ph = image_size_height / patch_size;
+                std::vector<int> positions(n_pos * 4);
+                int ptr = 0;
+                for (int y = 0; y < ph; y++) {
+                    for (int x = 0; x < pw; x++) {
+                        positions[                  ptr] = y;
+                        positions[    num_patches + ptr] = x;
+                        positions[2 * num_patches + ptr] = y;
+                        positions[3 * num_patches + ptr] = x;
+                        ptr++;
+                    }
+                }
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_MAGEVL:
+            {
+                // t, h, w in 2x2 block order, t is always 0 for an image
+                const int merge_ratio = hparams.n_merge;
+                const int pw = image_size_width  / patch_size / merge_ratio;
+                const int ph = image_size_height / patch_size / merge_ratio;
+                std::vector<int> positions(n_pos * 4, 0);
+                int ptr = 0;
+                for (int ty = 0; ty < ph; ty++) {
+                    for (int tx = 0; tx < pw; tx++) {
+                        for (int dy = 0; dy < merge_ratio; dy++) {
+                            for (int dx = 0; dx < merge_ratio; dx++) {
+                                positions[    num_patches + ptr] = ty * merge_ratio + dy;
+                                positions[2 * num_patches + ptr] = tx * merge_ratio + dx;
+                                ptr++;
+                            }
+                        }
+                    }
+                }
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_QWEN2VL:
+        case PROJECTOR_TYPE_QWEN3VL:
+        case PROJECTOR_TYPE_LING3VL:
+        case PROJECTOR_TYPE_GLM4V:
+        case PROJECTOR_TYPE_GLM5V:
+            {
+                const int merge_ratio = hparams.n_merge;
+                const int pw = image_size_width  / patch_size / merge_ratio;
+                const int ph = image_size_height / patch_size / merge_ratio;
+                std::vector<int> positions(n_pos * 4);
+                int ptr = 0;
+                for (int ty = 0; ty < ph; ty++) {
+                    for (int tx = 0; tx < pw; tx++) {
+                        for (int dy = 0; dy < merge_ratio; dy++) {
+                            for (int dx = 0; dx < merge_ratio; dx++) {
+                                const int y = ty * merge_ratio + dy;
+                                const int x = tx * merge_ratio + dx;
+                                positions[                  ptr] = y;
+                                positions[    num_patches + ptr] = x;
+                                positions[2 * num_patches + ptr] = y;
+                                positions[3 * num_patches + ptr] = x;
+                                ptr++;
+                            }
+                        }
+                    }
+                }
+
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_STEP3VL:
+            {
+                std::vector<int32_t> pos_data(n_pos);
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i / pos_w;
+                }
+                set_input_i32("pos_h", pos_data);
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i % pos_w;
+                }
+                set_input_i32("pos_w", pos_data);
+            } break;
+        case PROJECTOR_TYPE_PADDLEOCR:
+            {
+                const int merge_ratio = hparams.n_merge;
+                const int pw = image_size_width  / patch_size / merge_ratio;
+                const int ph = image_size_height / patch_size / merge_ratio;
+                std::vector<int> positions(n_pos * 4);
+                int ptr = 0;
+                // NOTE: same as Qwen-VL, but x and y are swapped
+                for (int ty = 0; ty < ph; ty++) {
+                    for (int dy = 0; dy < merge_ratio; dy++) {
+                        for (int tx = 0; tx < pw; tx++) {
+                            for (int dx = 0; dx < merge_ratio; dx++) {
+                                const int y = ty * merge_ratio + dy;
+                                const int x = tx * merge_ratio + dx;
+                                positions[                  ptr] = y;
+                                positions[    num_patches + ptr] = x;
+                                positions[2 * num_patches + ptr] = y;
+                                positions[3 * num_patches + ptr] = x;
+                                ptr++;
+                            }
+                        }
+                    }
+                }
+
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_MINIMAX_M3:
+            {
+                const int n_merge = hparams.n_merge;
+                const int gh = image_size_height / patch_size;
+                const int gw = image_size_width  / patch_size;
+                std::vector<int32_t> pos_h, pos_w;
+                pos_h.reserve(gh * gw);
+                pos_w.reserve(gh * gw);
+                for (int bh = 0; bh < gh / n_merge; bh++)
+                for (int bw = 0; bw < gw / n_merge; bw++)
+                for (int mh = 0; mh < n_merge; mh++)
+                for (int mw = 0; mw < n_merge; mw++) {
+                    pos_h.push_back(bh * n_merge + mh);
+                    pos_w.push_back(bw * n_merge + mw);
+                }
+                set_input_i32("minimax_pos_h", pos_h);
+                set_input_i32("minimax_pos_w", pos_w);
+            } break;
+        case PROJECTOR_TYPE_DOTS_OCR:
+        case PROJECTOR_TYPE_DOTS3NOTE_V:
+            {
+                const int pw = image_size_width / patch_size;
+                const int ph = image_size_height / patch_size;
+                const int n_pos = ph * pw;
+                std::vector<int> positions(n_pos * 4);
+                int ptr = 0;
+
+                // flat layout: [h, w, h, w] for each patch
+                // patches are in raster order (matching conv2d output)
+                for (int y = 0; y < ph; y++) {
+                    for (int x = 0; x < pw; x++) {
+                        positions[          ptr] = y;
+                        positions[  n_pos + ptr] = x;
+                        positions[2*n_pos + ptr] = y;
+                        positions[3*n_pos + ptr] = x;
+                        ptr++;
+                    }
+                }
+
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_QWEN25VL:
+        case PROJECTOR_TYPE_EXAONE4_5:
+        case PROJECTOR_TYPE_YOUTUVL:
+            {
+                // pw * ph = number of tokens output by ViT after apply patch merger
+                // ipw * ipw = number of vision token been processed inside ViT
+                const bool use_window_attn =
+                    (model.proj_type == PROJECTOR_TYPE_QWEN25VL || model.proj_type == PROJECTOR_TYPE_EXAONE4_5)
+                        ? hparams.n_wa_pattern > 0
+                        : !hparams.wa_layer_indexes.empty();
+                const int merge_ratio = 2;
+                const int pw  = image_size_width  / patch_size / merge_ratio;
+                const int ph  = image_size_height / patch_size / merge_ratio;
+                const int ipw = image_size_width  / patch_size;
+                const int iph = image_size_height / patch_size;
+
+                std::vector<int> idx    (ph * pw);
+                std::vector<int> inv_idx(ph * pw);
+
+                if (use_window_attn) {
+                    const int attn_window_size = hparams.attn_window_size > 0 ? hparams.attn_window_size : 112;
+                    const int grid_window = attn_window_size / patch_size / merge_ratio;
+                    int dst = 0;
+                    // [num_vision_tokens, num_vision_tokens] attention mask tensor
+                    std::vector<float> mask(pow(ipw * iph, 2), std::numeric_limits<float>::lowest());
+                    int mask_row = 0;
+
+                    for (int y = 0; y < ph; y += grid_window) {
+                        for (int x = 0; x < pw; x += grid_window) {
+                            const int win_h = std::min(grid_window, ph - y);
+                            const int win_w = std::min(grid_window, pw - x);
+                            const int dst_0 = dst;
+                            // group all tokens belong to the same window togather (to a continue range)
+                            for (int dy = 0; dy < win_h; dy++) {
+                                for (int dx = 0; dx < win_w; dx++) {
+                                    const int src = (y + dy) * pw + (x + dx);
+                                    GGML_ASSERT(src < (int)idx.size());
+                                    GGML_ASSERT(dst < (int)inv_idx.size());
+                                    idx    [src] = dst;
+                                    inv_idx[dst] = src;
+                                    dst++;
+                                }
+                            }
+
+                            for (int r=0; r < win_h * win_w * merge_ratio * merge_ratio; r++) {
+                                int row_offset = mask_row * (ipw * iph);
+                                std::fill(
+                                    mask.begin() + row_offset + (dst_0 * merge_ratio * merge_ratio),
+                                    mask.begin() + row_offset + (dst   * merge_ratio * merge_ratio),
+                                    0.0);
+                                mask_row++;
+                            }
+                        }
+                    }
+
+                    set_input_i32("window_idx",     idx);
+                    set_input_i32("inv_window_idx", inv_idx);
+                    set_input_attn_mask(mask);
+                } else {
+                    for (int i = 0; i < ph * pw; i++) {
+                        idx[i] = i;
+                    }
+                }
+
+                const int mpow = merge_ratio * merge_ratio;
+                std::vector<int> positions(n_pos * 4);
+
+                int ptr = 0;
+                for (int ty = 0; ty < ph; ty++) {
+                    for (int tx = 0; tx < pw; tx++) {
+                        for (int dy = 0; dy < merge_ratio; dy++) {
+                            for (int dx = 0; dx < merge_ratio; dx++) {
+                                auto remap = idx[ptr / mpow];
+                                remap = (remap * mpow) + (ptr % mpow);
+
+                                const int y = ty * merge_ratio + dy;
+                                const int x = tx * merge_ratio + dx;
+                                positions[                  remap] = y;
+                                positions[    num_patches + remap] = x;
+                                positions[2 * num_patches + remap] = y;
+                                positions[3 * num_patches + remap] = x;
+                                ptr++;
+                            }
+                        }
+                    }
+                }
+
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_MIMOVL:
+            {
+                const int merge      = hparams.n_merge;        // 2
+                const int merge_unit = merge * merge;          // 4
+                const int patch      = hparams.patch_size;     // 16
+                const int H          = image_size_height / patch;
+                const int W          = image_size_width  / patch;
+                const int n_pos_full = H * W;
+                const int llm_h      = H / merge;
+                const int llm_w      = W / merge;
+                const int n_units    = llm_h * llm_w;          // n_pos / merge_unit
+
+                // Row-major merge-tile-ordered (h, w) positions
+                std::vector<int32_t> pos_h_row(n_pos_full);
+                std::vector<int32_t> pos_w_row(n_pos_full);
+                {
+                    int idx = 0;
+                    for (int ty = 0; ty < llm_h; ty++) {
+                        for (int tx = 0; tx < llm_w; tx++) {
+                            for (int dy = 0; dy < merge; dy++) {
+                                for (int dx = 0; dx < merge; dx++) {
+                                    pos_h_row[idx] = ty * merge + dy;
+                                    pos_w_row[idx] = tx * merge + dx;
+                                    idx++;
+                                }
+                            }
+                        }
+                    }
+                }
+
+                // Col-major merge-unit permutation
+                std::vector<float> idx_col(n_units);
+                for (int r = 0; r < llm_h; r++) {
+                    for (int c = 0; c < llm_w; c++) {
+                        int u_row = r * llm_w + c;
+                        int u_col = c * llm_h + r;
+                        idx_col[u_col] = (float) u_row;
+                    }
+                }
+
+                // Col-mode positions: permute pos_*_row by idx_col
+                std::vector<int32_t> pos_h_col(n_pos_full);
+                std::vector<int32_t> pos_w_col(n_pos_full);
+                for (int u = 0; u < n_units; u++) {
+                    int src = (int) idx_col[u];
+                    for (int k = 0; k < merge_unit; k++) {
+                        pos_h_col[u * merge_unit + k] = pos_h_row[src * merge_unit + k];
+                        pos_w_col[u * merge_unit + k] = pos_w_row[src * merge_unit + k];
+                    }
+                }
+
+                // Pack into ggml_rope_multi VISION-mode layout. The non-CPU kernels
+                // only read slots 0 and 1, so pack h in slot 0, w in slot 1:
+                //   positions[0..n_pos)         = h
+                //   positions[n_pos..2*n_pos)   = w
+                //   positions[2*n_pos..3*n_pos) = 0
+                //   positions[3*n_pos..4*n_pos) = 0
+                std::vector<int32_t> positions_row(static_cast<size_t>(n_pos_full) * 4, 0);
+                std::vector<int32_t> positions_col(static_cast<size_t>(n_pos_full) * 4, 0);
+                for (int i = 0; i < n_pos_full; i++) {
+                    positions_row[0 * n_pos_full + i] = pos_h_row[i];
+                    positions_row[1 * n_pos_full + i] = pos_w_row[i];
+                    positions_col[0 * n_pos_full + i] = pos_h_col[i];
+                    positions_col[1 * n_pos_full + i] = pos_w_col[i];
+                }
+
+                // Banded 1D sliding-window mask
+                const int window = hparams.attn_window_size;
+                GGML_ASSERT(window > 0);
+                std::vector<float> mask(static_cast<size_t>(n_pos_full) * n_pos_full, std::numeric_limits<float>::lowest());
+                for (int q = 0; q < n_pos_full; q++) {
+                    int lo = std::max(0, q - window);
+                    int hi = std::min(n_pos_full - 1, q + window);
+                    for (int k = lo; k <= hi; k++) {
+                        mask[static_cast<size_t>(q) * n_pos_full + k] = 0.0f;
+                    }
+                }
+
+                set_input_i32("mimovl_positions_row", positions_row);
+                set_input_i32("mimovl_positions_col", positions_col);
+                set_input_f32("mimovl_idx_col",       idx_col);
+                set_input_attn_mask(mask);
+            } break;
+        case PROJECTOR_TYPE_PIXTRAL:
+        case PROJECTOR_TYPE_KIMIVL:
+        case PROJECTOR_TYPE_KIMIK25:
+        case PROJECTOR_TYPE_LIGHTONOCR:
+            {
+                // set the 2D positions
+                int n_patches_per_col = image_size_width / patch_size;
+                std::vector<int> pos_data(n_pos);
+                // dimension H
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i / n_patches_per_col;
+                }
+                set_input_i32("pos_h", pos_data);
+                // dimension W
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i % n_patches_per_col;
+                }
+                set_input_i32("pos_w", pos_data);
+            } break;
+        case PROJECTOR_TYPE_SENSENOVA_U1:
+            {
+                const int n_patches_per_row = image_size_width / patch_size;
+                std::vector<int> pos_data(n_pos);
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i / n_patches_per_row;
+                }
+                set_input_i32("pos_y", pos_data);
+                for (int i = 0; i < n_pos; i++) {
+                    pos_data[i] = i % n_patches_per_row;
+                }
+                set_input_i32("pos_x", pos_data);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEK4V:
+            {
+                // set the 2D positions (mrope layout, only the first 2 channels are used)
+                int n_patches_per_row = image_size_width / patch_size;
+                std::vector<int32_t> positions(n_pos * 4, 0);
+                for (int i = 0; i < n_pos; i++) {
+                    positions[i]         = i / n_patches_per_row; // row
+                    positions[n_pos + i] = i % n_patches_per_row; // col
+                }
+                set_input_i32("positions", positions);
+
+                // token block layout index (see mtmd_graph_deepseek4v::build)
+                // rows [0, n_grid) are the aligner output, the sentinels follow
+                const int n_merge = hparams.n_merge;
+                const int n_llm_w = MTMD_ALIGN(pos_w, n_merge) / n_merge;
+                const int n_llm_h = MTMD_ALIGN(pos_h, n_merge) / n_merge;
+                const int n_grid  = n_llm_w * n_llm_h;
+                const int idx_start   = n_grid;
+                const int idx_end     = n_grid + 1;
+                const int idx_newline = n_grid + 2;
+                const int idx_pad     = n_grid + 3;
+
+                const int lead_pad = imgs.entries[0].lead_pad;
+                const auto bl = dsv4_get_block_layout(n_llm_w, n_llm_h, lead_pad);
+
+                std::vector<int32_t> idx;
+                idx.reserve(bl.n_out);
+                for (int i = 0; i < lead_pad; i++) {
+                    idx.push_back(idx_pad);
+                }
+                idx.push_back(idx_start);
+                // pairs of adjacent rows are interleaved column-wise ("N-layout")
+                // ref: build_image_block in inference/image_processor.py
+                for (int t = 0; t < bl.rows * bl.row_len; t++) {
+                    const int g   = t / (2 * bl.row_len);
+                    const int rem = t % (2 * bl.row_len);
+                    const int c   = rem / 2; // column
+                    const int r   = 2 * g + rem % 2; // row
+                    if (r >= n_llm_h) {
+                        idx.push_back(idx_pad);
+                    } else if (c == n_llm_w) {
+                        idx.push_back(idx_newline);
+                    } else {
+                        idx.push_back(r * n_llm_w + c);
+                    }
+                }
+                for (int i = 0; i < bl.pad_last; i++) {
+                    idx.push_back(idx_pad);
+                }
+                idx.push_back(idx_end);
+                set_input_i32("layout_idx", idx);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEK41V:
+            {
+                // mrope layout, only the first 2 channels are used
+                const int n_patches_per_row = image_size_width / patch_size;
+                std::vector<int32_t> positions(n_pos * 4, 0);
+                for (int i = 0; i < n_pos; i++) {
+                    positions[i]         = i / n_patches_per_row;
+                    positions[n_pos + i] = i % n_patches_per_row;
+                }
+                set_input_i32("positions", positions);
+
+                // reading-order block: rows [0, n_grid) are the aligner output, then start / end / newline
+                const int n_merge = hparams.n_merge;
+                const int n_llm_w = MTMD_ALIGN(pos_w, n_merge) / n_merge;
+                const int n_llm_h = MTMD_ALIGN(pos_h, n_merge) / n_merge;
+                const int n_grid  = n_llm_w * n_llm_h;
+                std::vector<int32_t> idx;
+                idx.reserve(dsv41_n_block_tokens(n_llm_w, n_llm_h));
+                idx.push_back(n_grid);         // IMAGE_START
+                for (int r = 0; r < n_llm_h; r++) {
+                    for (int c = 0; c < n_llm_w; c++) {
+                        idx.push_back(r * n_llm_w + c);
+                    }
+                    idx.push_back(n_grid + 2); // IMAGE_NEW_LINE
+                }
+                idx.push_back(n_grid + 1);     // IMAGE_END
+                set_input_i32("layout_idx", idx);
+            } break;
+        case PROJECTOR_TYPE_GLM_EDGE:
+        {
+            // llava and other models
+            std::vector<int32_t> positions(n_pos);
+            for (int i = 0; i < n_pos; i++) {
+                positions[i] = i;
+            }
+            set_input_i32("positions", positions);
+        } break;
+        case PROJECTOR_TYPE_MLP:
+        case PROJECTOR_TYPE_MLP_NORM:
+        case PROJECTOR_TYPE_LDP:
+        case PROJECTOR_TYPE_LDPV2:
+            {
+                // llava and other models
+                std::vector<int32_t> positions(n_pos);
+                for (int i = 0; i < n_pos; i++) {
+                    positions[i] = i;
+                }
+                set_input_i32("positions", positions);
+
+                // The patches vector is used to get rows to index into the embeds with;
+                // we should skip dim 0 only if we have CLS to avoid going out of bounds
+                // when retrieving the rows.
+                int patch_offset = model.class_embedding ? 1 : 0;
+                std::vector<int32_t> patches(num_patches);
+                for (int i = 0; i < num_patches; i++) {
+                    patches[i] = i + patch_offset;
+                }
+                set_input_i32("patches", patches);
+            } break;
+        case PROJECTOR_TYPE_POCKETTTS_SPKENC:
+            {
+                set_pockettts_tfm_inputs();
+            } break;
+        case PROJECTOR_TYPE_POCKETTTS_GEN:
+            {
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                    GGML_ASSERT(params->feats != nullptr);
+                    set_input_f32("inp_feats", *params->feats);
+                    // positions and mask are derived in-graph from the persisted counter
+                    set_gen_state_in();
+                } else {
+                    // flow matching starts from gaussian noise, std = sqrt(temp)
+                    ggml_tensor * t = get_inp_tensor("inp_noise");
+                    // Config.default_temperature, for a caller that does not set one
+                    const float temp = params->temp > 0.0f ? params->temp : 0.7f;
+                    std::normal_distribution<float> dist(0.0f, std::sqrt(temp));
+                    std::vector<float> noise(ggml_nelements(t));
+                    for (auto & v : noise) {
+                        v = dist(params->gen_state->rng);
+                    }
+                    set_input_f32("inp_noise", noise);
+                }
+            } break;
+        case PROJECTOR_TYPE_SOPRANO:
+            {
+                set_input_f32("inp_feats", *params->feats);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4V:
+        case PROJECTOR_TYPE_GEMMA4UV:
+            {
+                // set (col, row) patch positions for learned positional embedding
+                const int n_cols = image_size_width  / patch_size;
+                std::vector<int> pos_x(num_patches), pos_y(num_patches);
+                for (int i = 0; i < num_patches; i++) {
+                    pos_x[i] = i % n_cols;
+                    pos_y[i] = i / n_cols;
+                }
+                set_input_i32("pos_x", pos_x);
+                set_input_i32("pos_y", pos_y);
+            } break;
+        case PROJECTOR_TYPE_DEEPSEEKOCR:
+        case PROJECTOR_TYPE_DEEPSEEKOCR2:
+            {
+                GGML_ASSERT(
+                    (pos_w == pos_h) // overview image
+                    || (pos_h >= pos_w && pos_h % pos_w == 0) // tile images
+                );
+
+                const int window = hparams.attn_window_size;
+                const int pos = pos_w;
+                std::vector<int32_t> rel_pos_indices_local(window * window);
+                std::vector<int32_t> rel_pos_indices_global(pos * pos);
+
+                for (int q = 0; q < window; q++) {
+                    for (int k = 0; k < window; k++) {
+                        rel_pos_indices_local[q * window + k] = q - k + window - 1;
+                    }
+                }
+
+                for (int q = 0; q < pos; q++) {
+                    for (int k = 0; k < pos; k++) {
+                        rel_pos_indices_global[q * pos + k] = q - k + pos - 1;
+                    }
+                }
+
+                set_input_i32("rel_pos_indices_local", rel_pos_indices_local);
+                set_input_i32("rel_pos_indices_global", rel_pos_indices_global);
+
+                if (model.proj_type == PROJECTOR_TYPE_DEEPSEEKOCR2) {
+
+                    // qwen2 encoder attention mask
+
+                    // num_image_tokens = num_patches / 16
+                    //   256 for 1024 global view
+                    //   144 for 768 tile views
+                    const int   num_image_tokens = num_patches / 16;
+                    const int   seq_len          = num_image_tokens * 2;
+                    std::vector qwen2_mask(static_cast<size_t>(seq_len) * seq_len, 0.0f);
+
+                    // attention mask layout
+                    //  +--------------+---------------+
+                    //  |    all 0     |   all -inf    |
+                    //  +--------------+---------------+
+                    //  |    all 0     |  lower tri 0  |
+                    //  +--------------+---------------+
+                    for (int i = 0; i < seq_len; i++) {
+                        for (int j = 0; j < seq_len; j++) {
+                            const bool zero = i < num_image_tokens ?
+                                                     j < num_image_tokens :
+                                                     j < num_image_tokens || j <= i;
+                            qwen2_mask[static_cast<size_t>(i) * seq_len + j] = zero ? 0.0f : -1e9f;
+                        }
+                    }
+                    set_input_attn_mask(qwen2_mask);
+                }
+            } break;
+        case PROJECTOR_TYPE_CHATTERBOX:
+            {
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE) {
+                    set_input_f32("inp_mel",   *params->mel_in);
+                    set_input_f32("inp_sstft", *params->sstft_in);
+                    break;
+                }
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TOKENIZE) {
+                    const int T1 = (imgs.entries[0].nx() - 1) / 2 + 1;
+                    const int T2 = (T1 - 1) / 2 + 1;
+                    std::vector<int32_t> pos(T2);
+                    for (int i = 0; i < T2; i++) {
+                        pos[(size_t) i] = i;
+                    }
+                    set_input_i32("inp_pos", pos);
+                    break;
+                }
+                if (params->gen_process != MTMD_SUBCTX_GEN_PROCESS_TTS) {
+                    break;
+                }
+                const int n_gen = (int) params->codes->size();
+                GGML_ASSERT(params->ref);
+                const int n_prompt = (int) params->ref->tokens.size();
+                std::vector<int32_t> tokens = params->ref->tokens;
+                tokens.resize((size_t) n_prompt + n_gen);
+                memcpy(tokens.data() + n_prompt, params->codes->data(), (size_t) n_gen * sizeof(int32_t));
+                const int64_t n_codes = model.cbx.input_embedding_w->ne[1];
+                for (int32_t t : tokens) {
+                    if (t < 0 || t >= n_codes) {
+                        LOG_ERR("%s: speech token out of range (%d, codebook size %lld)\n", __func__, t, (long long) n_codes);
+                        return false;
+                    }
+                }
+                const int T1 = n_prompt + n_gen;
+                const int T2 = 2 * T1;
+                set_input_i32("inp_tokens", tokens);
+
+                // mel-rate reference conditioning of the voice
+                set_input_f32("inp_prompt_feat", params->ref->feat);
+                set_input_f32("inp_xvec", params->ref->spk);
+
+                // espnet relative positional encoding, entry k holds the sinusoid of relative position (T-1) - k
+                auto fill_pos = [&](const char * name, int T) {
+                    const int d = 512;
+                    std::vector<float> pos((size_t) (2 * T - 1) * d);
+                    for (int k = 0; k < 2 * T - 1; k++) {
+                        const double rel = (double) (T - 1 - k);
+                        for (int i = 0; i < d / 2; i++) {
+                            const double div = exp(-(double) (2 * i) * log(10000.0) / d);
+                            pos[(size_t) k * d + 2 * i    ] = (float) sin(rel * div);
+                            pos[(size_t) k * d + 2 * i + 1] = (float) cos(rel * div);
+                        }
+                    }
+                    set_input_f32(name, pos);
+                };
+                fill_pos("inp_pos1", T1);
+                fill_pos("inp_pos2", T2);
+
+                // noise and the time embeddings of the solver span points, matcha layout (sines then cosines, scale 1000)
+                std::vector<float> noise((size_t) 80 * T2);
+                std::normal_distribution<float> nd(0.0f, 1.0f);
+                for (auto & f : noise) f = nd(params->gen_state->rng);
+                set_input_f32("inp_noise", noise);
+
+                const bool meanflow = model.cbx.time_embed_mixer_w != nullptr;
+                const int n_steps = meanflow ? 2 : 10;
+                std::vector<float> temb((size_t) 320 * (n_steps + 1));
+                for (int s = 0; s <= n_steps; s++) {
+                    const double u = (double) s / n_steps;
+                    const double t = meanflow ? u : 1.0 - cos(u * M_PI / 2.0);
+                    for (int i = 0; i < 160; i++) {
+                        const double div = exp(-(double) i * log(10000.0) / 159.0);
+                        temb[(size_t) s * 320 + i      ] = (float) sin(1000.0 * t * div);
+                        temb[(size_t) s * 320 + i + 160] = (float) cos(1000.0 * t * div);
+                    }
+                }
+                set_input_f32("inp_temb", temb);
+            } break;
+        case PROJECTOR_TYPE_GEMMA3:
+        case PROJECTOR_TYPE_GEMMA3NV:
+        case PROJECTOR_TYPE_IDEFICS3:
+        case PROJECTOR_TYPE_COHERE2V:
+        case PROJECTOR_TYPE_INTERNVL:
+        case PROJECTOR_TYPE_NEMOTRON_V2_VL:
+        case PROJECTOR_TYPE_NEMOTRON_OMNI:
+        case PROJECTOR_TYPE_QWEN2A:
+        case PROJECTOR_TYPE_QWEN3A:
+        case PROJECTOR_TYPE_GLMA:
+        case PROJECTOR_TYPE_ULTRAVOX:
+        case PROJECTOR_TYPE_LFM2:
+        case PROJECTOR_TYPE_D1OMNI_V:
+        case PROJECTOR_TYPE_VOXTRAL:
+        case PROJECTOR_TYPE_MERALION:
+        case PROJECTOR_TYPE_MUSIC_FLAMINGO:
+        case PROJECTOR_TYPE_JANUS_PRO:
+        case PROJECTOR_TYPE_PHI4:
+        case PROJECTOR_TYPE_COGVLM:
+        case PROJECTOR_TYPE_YASA2:
+        case PROJECTOR_TYPE_GEMMA4UA:
+        case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
+        case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
+            {
+                // do nothing
+            } break;
+        case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
+            {
+                // the ceil-mode seg pooling divides by the full segment length, rescale the last partial segment
+                const int T1 = (imgs.entries[0].nx() - 1) / 2 + 1;
+                const int S  = (T1 + 99) / 100;
+                std::vector<float> segfix((size_t) S, 1.0f);
+                const int last = T1 - (S - 1) * 100;
+                segfix[(size_t) S - 1] = 100.0f / (float) last;
+                set_input_f32("inp_segfix", segfix);
+            } break;
+        case PROJECTOR_TYPE_LFM3A_GEN:
+            {
+                const int64_t n_cb    = model.gen_code_embd_w->ne[2];
+                const int64_t n_vocab = model.gen_code_embd_w->ne[1];
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                    GGML_ASSERT(params->codes != nullptr);
+                    // codes index the FSQ table via ggml_get_rows, the end-of-audio code has no waveform
+                    const int64_t n_codes = model.c2w.quant_first_cb_w->ne[1];
+                    for (int32_t c : *params->codes) {
+                        if (c < 0 || c >= n_codes) {
+                            LOG_ERR("%s: code out of range (%d, codebook size %lld)\n", __func__, c, (long long) n_codes);
+                            return false;
+                        }
+                    }
+                    std::vector<int32_t> codes = *params->codes;
+                    set_input_i32("inp_codes", codes);
+                    set_gen_state_in();
+                    // the "pos" slot leads the state
+                    float base = 0.0f;
+                    ggml_backend_tensor_get(params->gen_state->slots[0], &base, 0, sizeof(float));
+                    set_lfm3a_window_inputs((int) base, (int) ggml_nelements(get_inp_tensor("positions")), hparams.wav_tfm_swa);
+                } else {
+                    // rows of the merged per-codebook tables; a masked codebook (-1) takes row 0, the mask drops it
+                    std::vector<int32_t> codes(n_cb, -1);
+                    if (params->codes) {
+                        GGML_ASSERT(params->codes->size() == (size_t) n_cb);
+                        codes = *params->codes;
+                    }
+                    std::vector<float> mask(n_cb, 0.0f);
+                    for (int64_t i = 0; i < n_cb; i++) {
+                        if (codes[i] >= n_vocab) {
+                            LOG_ERR("%s: code out of range (%d, vocab %lld)\n", __func__, codes[i], (long long) n_vocab);
+                            return false;
+                        }
+                        if (codes[i] < 0) {
+                            codes[i] = 0;
+                        } else {
+                            mask[i] = 1.0f;
+                        }
+                        codes[i] += (int32_t) (i * n_vocab);
+                    }
+                    set_input_i32("inp_codes", codes);
+                    if (ggml_graph_get_tensor(gf, "inp_mask") != nullptr) {
+                        set_input_f32("inp_mask", mask);
+                    }
+                }
+            } break;
+        case PROJECTOR_TYPE_DRAMA3_GEN:
+            {
+                const int64_t n_cb      = model.gen_code_embd_w->ne[2];
+                const int64_t n_vocab   = model.gen_code_embd_w->ne[1];
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                    // frame-major to group-major, clamp: codec residual codebooks are smaller than the tower vocab
+                    GGML_ASSERT(params->codes != nullptr && params->codes->size() % n_cb == 0);
+                    const int64_t n_frames = (int64_t) params->codes->size() / n_cb;
+                    const int64_t n_first  = model.c2w.quant_first_cb_w->ne[1];
+                    const int64_t n_rest   = model.c2w.quant_rest_cb_w->ne[1];
+                    std::vector<int32_t> codes(params->codes->size());
+                    for (int64_t f = 0; f < n_frames; f++) {
+                        for (int64_t g = 0; g < n_cb; g++) {
+                            const int64_t n = g == 0 ? n_first : n_rest;
+                            codes[g * n_frames + f] = std::clamp<int32_t>((*params->codes)[f * n_cb + g], 0, (int32_t) n - 1);
+                        }
+                    }
+                    set_input_i32("inp_codes", codes);
+                    set_gen_state_in();
+                } else {
+                    // code0 < 0: next row is text (prompt), the tower only fills its KV
+                    // codes: input frame codes for a speech row, empty for a text row
+                    const bool next_speech = params->code0 >= 0;
+                    const bool cur_speech  = params->codes && (int64_t) params->codes->size() == n_cb;
+                    if (params->code0 >= n_vocab) {
+                        LOG_ERR("%s: code0 out of range (%d, vocab %lld)\n", __func__, params->code0, (long long) n_vocab);
+                        return false;
+                    }
+                    std::vector<int32_t> code0 = { std::max(params->code0, 0) };
+                    set_input_i32("inp_code0", code0);
+
+                    std::vector<int32_t> extra(n_cb - 2);
+                    for (int64_t g = 2; g < n_cb; g++) {
+                        const int32_t c = cur_speech ? std::clamp<int32_t>((*params->codes)[g], 0, (int32_t) n_vocab - 1) : 0;
+                        extra[g - 2] = c + (int32_t) (g * n_vocab);
+                    }
+                    set_input_i32("inp_extra", extra);
+                    set_input_f32("inp_scale", { next_speech ? 1.0f : 0.0f, cur_speech ? 1.0f : 0.0f });
+
+                    float pos = 0.0f;
+                    ggml_backend_tensor_get(params->gen_state->slots[0], &pos, 0, sizeof(float));
+                    std::vector<int32_t> inp_pos = { (int32_t) pos };
+                    set_input_i32("inp_pos", inp_pos);
+
+                    std::uniform_real_distribution<float> dist(0.0f, 1.0f);
+                    std::vector<float> r(model.gen_code_head_w->ne[2]);
+                    for (auto & v : r) {
+                        v = dist(params->gen_state->rng);
+                    }
+                    set_input_f32("inp_rand", r);
+                }
+            } break;
+        case PROJECTOR_TYPE_QWEN3TTS_GEN:
+            {
+                if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                    GGML_ASSERT(params->codes != nullptr);
+
+                    // frame-major input to group-major, rear-padded with code 0 up to one window
+                    const int64_t n_codes  = model.gen_code_head_w->ne[2] + 1;
+                    const int64_t n_frames_w = hparams.wav_tfm_swa;
+                    const int64_t n_frames   = (int64_t) params->codes->size() / n_codes;
+                    GGML_ASSERT(n_frames > 0 && n_frames <= n_frames_w);
+
+                    // codes are used as ggml_get_rows indices, so check them against the codebook vocab
+                    const int64_t vocab_first = model.c2w.quant_first_cb_w->ne[1];
+                    const int64_t vocab_rest  = model.c2w.quant_rest_cb_w->ne[1];
+                    for (int64_t f = 0; f < n_frames; f++) {
+                        for (int64_t g = 0; g < n_codes; g++) {
+                            const int32_t c = (*params->codes)[f * n_codes + g];
+                            const int64_t vocab = (g == 0) ? vocab_first : vocab_rest;
+                            if (c < 0 || (int64_t) c >= vocab) {
+                                LOG_ERR("%s: code out of range (frame %lld, group %lld, code %d, vocab %lld)\n",
+                                        __func__, (long long) f, (long long) g, c, (long long) vocab);
+                                return false;
+                            }
+                        }
+                    }
+
+                    std::vector<int32_t> codes(n_frames_w * n_codes, 0);
+                    for (int64_t f = 0; f < n_frames; f++) {
+                        for (int64_t g = 0; g < n_codes; g++) {
+                            codes[g * n_frames_w + f] = (*params->codes)[f * n_codes + g];
+                        }
+                    }
+                    set_input_i32("inp_codes", codes);
+                    set_gen_state_in();
+                } else {
+                    // code0 indexes gen_code_out_embd_w via ggml_get_rows; bound it
+                    const int64_t vocab0 = model.gen_code_out_embd_w->ne[1];
+                    if (params->code0 < 0 || (int64_t) params->code0 >= vocab0) {
+                        LOG_ERR("%s: code0 out of range (%d, vocab %lld)\n", __func__, params->code0, (long long) vocab0);
+                        return false;
+                    }
+                    std::vector<int32_t> code0 = { params->code0 };
+                    set_input_i32("inp_code0", code0);
+
+                    // one uniform(0,1) draw per codebook, used by do_sampling()
+                    std::uniform_real_distribution<float> dist(0.0f, 1.0f);
+                    const int64_t n_acoustic = model.gen_code_head_w->ne[2];
+                    for (int64_t g = 0; g < n_acoustic; g++) {
+                        std::vector<float> r = { dist(params->gen_state->rng) };
+                        set_input_f32(("inp_rand_" + std::to_string(g)).c_str(), r);
+                    }
+                }
+            } break;
+        case PROJECTOR_TYPE_HUNYUANVL:
+            {
+                // Compute the HunyuanVL 2D position embedding on CPU (with the
+                // custom sf=(target+0.1)/n_grid bilinear sampling that the
+                // reference implementation uses) and upload it to the graph
+                // input declared in mtmd_graph_hunyuanvl::build().
+                GGML_ASSERT(model.position_embeddings != nullptr);
+                ggml_tensor * src_t   = model.position_embeddings;
+                const int64_t n_embd  = src_t->ne[0];
+                const int64_t n_pos   = src_t->ne[1];            // = n_grid * n_grid
+                const int     n_grid  = (int)std::lround(std::sqrt((double)n_pos));
+                GGML_ASSERT((int64_t)n_grid * n_grid == n_pos);
+                const int     out_w   = pos_w;                    // pw
+                const int     out_h   = pos_h;                    // ph
+
+                // Pull weight to host.
+                std::vector<float> src(n_embd * n_pos);
+                ggml_backend_tensor_get(src_t, src.data(), 0, ggml_nbytes(src_t));
+
+                // Output layout matches ggml_new_tensor_2d(F32, n_embd, out_h*out_w):
+                //   ne[0] = n_embd (fastest), ne[1] = out_h*out_w
+                //   dst[(y*out_w + x) * n_embd + c]
+                std::vector<float> dst((size_t)n_embd * out_h * out_w);
+
+                const float sx = (float)(out_w + 0.1f) / (float)n_grid;
+                const float sy = (float)(out_h + 0.1f) / (float)n_grid;
+
+                for (int y = 0; y < out_h; ++y) {
+                    // Match ggml_compute_forward_upscale_f32 pixel-center
+                    // convention (align_corners=False): src_y = (y+0.5)/sy - 0.5.
+                    const float fy = ((float)y + 0.5f) / sy - 0.5f;
+                    int y0 = (int)std::floor(fy);
+                    int y1 = y0 + 1;
+                    y0 = std::clamp(y0, 0, n_grid - 1);
+                    y1 = std::clamp(y1, 0, n_grid - 1);
+                    float wy1 = std::clamp(fy - (float)y0, 0.0f, 1.0f);
+                    const float wy0 = 1.0f - wy1;
+                    for (int x = 0; x < out_w; ++x) {
+                        const float fx = ((float)x + 0.5f) / sx - 0.5f;
+                        int x0 = (int)std::floor(fx);
+                        int x1 = x0 + 1;
+                        x0 = std::clamp(x0, 0, n_grid - 1);
+                        x1 = std::clamp(x1, 0, n_grid - 1);
+                        float wx1 = std::clamp(fx - (float)x0, 0.0f, 1.0f);
+                        const float wx0 = 1.0f - wx1;
+
+                        const float w00 = wy0 * wx0;
+                        const float w01 = wy0 * wx1;
+                        const float w10 = wy1 * wx0;
+                        const float w11 = wy1 * wx1;
+
+                        const float * s00 = &src[((size_t)y0 * n_grid + x0) * n_embd];
+                        const float * s01 = &src[((size_t)y0 * n_grid + x1) * n_embd];
+                        const float * s10 = &src[((size_t)y1 * n_grid + x0) * n_embd];
+                        const float * s11 = &src[((size_t)y1 * n_grid + x1) * n_embd];
+                        float * d         = &dst[((size_t)y * out_w + x) * n_embd];
+                        for (int c = 0; c < n_embd; ++c) {
+                            d[c] = w00 * s00[c] + w01 * s01[c] + w10 * s10[c] + w11 * s11[c];
+                        }
+                    }
+                }
+
+                set_input_f32("hunyuanvl_pos_embd", dst);
+            } break;
+        case PROJECTOR_TYPE_LLAMA4:
+            {
+                // set the 2D positions
+                int n_patches_per_col = image_size_width / patch_size;
+                std::vector<int> pos_data(num_patches + 1, 0); // +1 for the [CLS] token
+                // last pos is always kept 0, it's for CLS
+                // dimension H
+                for (int i = 0; i < num_patches; i++) {
+                    pos_data[i] = (i / n_patches_per_col) + 1;
+                }
+                set_input_i32("pos_h", pos_data);
+                // dimension W
+                for (int i = 0; i < num_patches; i++) {
+                    pos_data[i] = (i % n_patches_per_col) + 1;
+                }
+                set_input_i32("pos_w", pos_data);
+            } break;
+        case PROJECTOR_TYPE_DOTS3NOTE_A:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                const int n_pos = (imgs.entries.front().nx() + 7) / 8; // 3x stride-2 conv2d
+                std::vector<int32_t> positions(n_pos);
+                for (int i = 0; i < n_pos; i++) {
+                    positions[i] = i;
+                }
+                set_input_i32("positions", positions);
+            } break;
+        case PROJECTOR_TYPE_GEMMA4A:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                const auto & img0 = imgs.entries.front();
+                // Compute n_pos matching SSCP output: two stride-2 convs
+                int n_pos = img0.nx();
+                for (int i = 0; i < 2; i++) { n_pos = (n_pos - 1) / 2 + 1; }
+
+                // Chunked local attention: blocked causal mask and RPE
+                const int chunk_size   = 12;
+                const int max_past     = 12;
+                const int context_size = chunk_size + max_past;
+                const int num_blocks   = (n_pos + chunk_size - 1) / chunk_size;
+
+                // Blocked causal attention mask: [context_size, chunk_size, num_blocks]
+                {
+                    std::vector<float> mask(context_size * chunk_size * num_blocks, -1e9f);
+                    for (int b = 0; b < num_blocks; b++) {
+                        for (int q = 0; q < chunk_size; q++) {
+                            int gq = b * chunk_size + q;
+                            for (int k = 0; k < context_size; k++) {
+                                int gk = b * chunk_size - max_past + k;
+                                if (gq < n_pos && gk >= 0 && gk < n_pos && gk <= gq && (gq - gk) < max_past) {
+                                    mask[k + q * context_size + b * context_size * chunk_size] = 0.0f;
+                                }
+                            }
+                        }
+                    }
+                    set_input_f32("kq_mask", mask);
+                }
+
+                // Sinusoidal RPE: 13 positions [12, 11, ..., 0]
+                {
+                    const int n_embd = model.hparams.n_embd;
+                    const int num_timescales = n_embd / 2;
+                    const float log_timescale_increment = logf(10000.0f) / std::max(num_timescales - 1, 1);
+                    const int rpe_len = max_past + 1;
+                    std::vector<float> pos_emb(n_embd * rpe_len, 0.0f);
+                    for (int p = 0; p < rpe_len; p++) {
+                        float position = (float)(max_past - p);
+                        for (int i = 0; i < num_timescales; i++) {
+                            float inv_ts = expf(-(float)i * log_timescale_increment);
+                            float scaled = position * inv_ts;
+                            pos_emb[p * n_embd + i]                 = sinf(scaled);
+                            pos_emb[p * n_embd + i + num_timescales] = cosf(scaled);
+                        }
+                    }
+                    set_input_f32("pos_emb", pos_emb);
+                }
+            } break;
+        case PROJECTOR_TYPE_MIMO_AUDIO:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                const int n_frames = imgs.entries.front().nx();
+                const int n_pos    = (n_frames - 1) / 2 + 1; // matches conv1(s=1)+conv2(s=2) output length
+
+                std::vector<int32_t> positions(n_pos);
+                for (int i = 0; i < n_pos; i++) {
+                    positions[i] = i;
+                }
+                set_input_i32("mimo_audio_positions", positions);
+
+                const int window = hparams.attn_window_size;
+                GGML_ASSERT(window > 0);
+
+                const float neg_inf = std::numeric_limits<float>::lowest();
+                std::vector<float> full_mask((size_t) n_pos * n_pos);
+                std::vector<float> window_mask((size_t) n_pos * n_pos);
+                for (int q = 0; q < n_pos; q++) {
+                    for (int k = 0; k < n_pos; k++) {
+                        const bool causal_ok = k <= q;
+                        full_mask[(size_t) q * n_pos + k]   = causal_ok ? 0.0f : neg_inf;
+                        window_mask[(size_t) q * n_pos + k] = (causal_ok && (q - k) <= window) ? 0.0f : neg_inf;
+                    }
+                }
+                set_input_attn_mask(full_mask, 0);
+                set_input_attn_mask(window_mask, 1);
+
+                // input_local_transformer: block-diagonal mask + in-group positions
+                {
+                    const int n_pos_ds   = (n_pos - 2) / 2 + 1; // matches downsample conv (k=2,s=2,p=0)
+                    const int group_size = hparams.audio_local_group_size;
+                    GGML_ASSERT(group_size > 0);
+                    const int n_groups = (n_pos_ds + group_size - 1) / group_size;
+                    const int n_padded = n_groups * group_size;
+
+                    std::vector<int32_t> local_positions(n_padded);
+                    for (int i = 0; i < n_padded; i++) {
+                        local_positions[i] = i % group_size;
+                    }
+                    set_input_i32("mimo_audio_local_positions", local_positions);
+
+                    std::vector<float> local_mask((size_t) n_padded * n_padded);
+                    for (int q = 0; q < n_padded; q++) {
+                        for (int k = 0; k < n_padded; k++) {
+                            const bool same_group = (q / group_size) == (k / group_size);
+                            local_mask[(size_t) q * n_padded + k] = same_group ? 0.0f : neg_inf;
+                        }
+                    }
+                    set_input_attn_mask(local_mask, 2);
+                }
+            } break;
+        case PROJECTOR_TYPE_LFM2A:
+        case PROJECTOR_TYPE_D1OMNI_A:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                const auto n_frames = model.n_output_tokens(&imgs.entries.front());
+
+                auto d_model = 512;
+                auto seq_len = n_frames * 2 - 1;
+                std::vector<float> pos_emb(d_model*seq_len);
+                std::vector<double> inv_freq(d_model / 2);
+                for (size_t i = 0; i < inv_freq.size(); ++i) {
+                    inv_freq[i] = std::exp(-(std::log(10000.0) / (float)d_model) * (2.0f * (float)(i)));
+                }
+                for (int64_t pos = 0; pos < seq_len; ++pos) {
+                    for (size_t i = 0; i < inv_freq.size(); ++i) {
+                        const float ang = (n_frames - pos - 1) * inv_freq[i];
+                        pos_emb[pos*d_model + 2*i + 0] = sinf(ang);  // even
+                        pos_emb[pos*d_model + 2*i + 1] = cosf(ang);  // odd
+                    }
+                }
+                set_input_f32("pos_emb", pos_emb);
+            } break;
+        case PROJECTOR_TYPE_LFM3A:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                const int n_pos = model.n_output_tokens(&imgs.entries.front());
+
+                // the "pos" slot leads the state; without a stream the state is zero, the clip is encoded from its start
+                int base = 0;
+                if (params->enc_state != nullptr) {
+                    const auto & slots = params->enc_state->slots;
+                    float base_f = 0.0f;
+                    ggml_backend_tensor_get(slots[0], &base_f, 0, sizeof(float));
+                    base = (int) base_f;
+                    size_t i = 0;
+                    for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
+                        ggml_backend_tensor_copy(slots[i++], get_inp_tensor(("state_in_" + slot.name).c_str()));
+                    }
+                } else {
+                    for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
+                        ggml_tensor * t = get_inp_tensor(("state_in_" + slot.name).c_str());
+                        ggml_backend_tensor_memset(t, 0, 0, ggml_nbytes(t));
+                    }
+                }
+
+                set_lfm3a_window_inputs(base, n_pos, hparams.attn_window_size);
+            } break;
+        case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                std::vector<int32_t> positions(ggml_nelements(get_inp_tensor("positions")));
+                for (size_t i = 0; i < positions.size(); i++) {
+                    positions[i] = (int32_t) i;
+                }
+                set_input_i32("positions", positions);
+                GGML_ASSERT(params->enc_state != nullptr);
+                set_input_f32("inp_norm", params->enc_state->mel_norm);
+                if (ggml_graph_get_tensor(gf, "spk_prev") != nullptr) {
+                    set_input_f32("spk_prev", params->enc_state->spk_prev);
+                }
+            } break;
+        case PROJECTOR_TYPE_PARAKEET:
+            {
+                GGML_ASSERT(imgs.entries.size() == 1);
+                struct ggml_tensor * attn_mask = ggml_graph_get_tensor(gf, "attn_mask");
+                const int n_q = attn_mask->ne[1];
+                const int n_k = attn_mask->ne[0];
+                const int n_frames = imgs.entries.front().nx();
+                const int n_tokens_real = (n_frames + hparams.subsampling_factor-1) / hparams.subsampling_factor;
+                const float mask_value  = -1e30f;
+
+                std::vector<float> mask_data(n_q * n_k);
+                if (n_k == n_q) {
+                    // full attention: mask keys that are padding
+                    for (int q = 0; q < n_q; ++q) {
+                        for (int k = 0; k < n_k; ++k) {
+                            mask_data[q * n_k + k] = (k >= n_tokens_real) ? mask_value : 0.0f;
+                        }
+                    }
+                } else {
+                    // local attention: mask keys outside the valid window
+                    const int att_left = n_k / 2;
+                    for (int q = 0; q < n_q; ++q) {
+                        for (int k = 0; k < n_k; ++k) {
+                            const int key = q - att_left + k;
+                            mask_data[q * n_k + k] = (key >= 0 && key < n_tokens_real) ? 0.0f : mask_value;
+                        }
+                    }
+                }
+                set_input_f32(attn_mask->name, mask_data);
+
+                // local attention skew mask: zeroes out the probs that were
+                // computed for keys outside the valid sliding window.
+                if (struct ggml_tensor * local_mask = ggml_graph_get_tensor(gf, "local_mask")) {
+                    const int lm_k = local_mask->ne[0];
+                    const int lm_q = local_mask->ne[1];
+                    const int window_size = lm_k - lm_q + 1;
+                    std::vector<float> lm_data(lm_q * lm_k);
+                    for (int q = 0; q < lm_q; ++q) {
+                        for (int k = 0; k < lm_k; ++k) {
+                            const int rel = k - q;
+                            lm_data[q * lm_k + k] = (rel >= 0 && rel < window_size) ? 1.0f : 0.0f;
+                        }
+                    }
+                    set_input_f32(local_mask->name, lm_data);
+                }
+
+                // Generate rotation frequencies for relative positional encoding.
+                {
+                    const int n_state     = hparams.n_embd;
+                    const int d_half      = n_state / 2;
+                    const float log_10000 = logf(10000.0f);
+                    std::vector<float> freqs(d_half);
+                    for (int k = 0; k < d_half; ++k) {
+                        freqs[k] = expf(-(float(k * 2) * log_10000 / float(n_state)));
+                    }
+                    set_input_f32("pos_freqs", freqs);
+                }
+
+                // Generate relative positional distance values which scaled by
+                // the frequency to produce the angles for sin/cos.
+                {
+                    // window_size is only known after graph construction since it depends on
+                    // n_time from the conv output, so we read it back from the graph tensor.
+                    struct ggml_tensor * rel_pos = ggml_graph_get_tensor(gf, "rel_positions");
+                    const int window_size = rel_pos->ne[1];
+                    std::vector<float> pos(window_size);
+                    // local attention: window is fixed at [att_left, att_right]
+                    // full attention: window covers the full sequence, centered
+                    if (ggml_graph_get_tensor(gf, "local_mask")) {
+                        const int att_left = window_size / 2;
+                        for (int t = 0; t < window_size; ++t) {
+                            pos[t] = float(att_left - t);
+                        }
+                    } else {
+                        const int n_time = (window_size + 1) / 2;
+                        for (int t = 0; t < window_size; ++t) {
+                            pos[t] = float(n_time - 1 - t);
+                        }
+                    }
+                    set_input_f32(rel_pos->name, pos);
+                }
+            } break;
+        case PROJECTOR_TYPE_GRANITE_SPEECH:
+            {
+                const int context_size = model.hparams.audio_chunk_size;
+                const int max_pos_emb  = model.hparams.audio_max_pos_emb;
+
+                std::vector<int32_t> dists((size_t) context_size * (size_t) context_size);
+                for (int i = 0; i < context_size; i++) {
+                    for (int j = 0; j < context_size; j++) {
+                        int d = i - j;
+                        if (d < -context_size) d = -context_size;
+                        if (d >  context_size) d =  context_size;
+                        dists[(size_t) i * (size_t) context_size + (size_t) j] = d + max_pos_emb;
+                    }
+                }
+                set_input_i32("attn_dists", dists);
+
+                const int n_frames   = image_size_width;
+                const int remainder  = n_frames % context_size;
+                if (remainder > 0) {
+                    const int num_blocks = (n_frames + context_size - 1) / context_size;
+                    std::vector<float> mask((size_t) context_size * (size_t) context_size * (size_t) num_blocks, 0.0f);
+                    const float neg_inf = -INFINITY;
+                    const size_t last_block_offset = (size_t) (num_blocks - 1) * (size_t) context_size * (size_t) context_size;
+                    for (int q = 0; q < context_size; q++) {
+                        for (int k = 0; k < context_size; k++) {
+                            if (q >= remainder || k >= remainder) {
+                                mask[last_block_offset + (size_t) q * (size_t) context_size + (size_t) k] = neg_inf;
+                            }
+                        }
+                    }
+                    set_input_f32("attn_mask", mask);
+                }
+            } break;
+        case PROJECTOR_TYPE_GRANITE4_VISION:
+            {
+                // Granite Vision 4.1 uses precomputed permutation index
+                // tensors to express the _win / _unwin / spatial sampling
+                // reshapes as ggml_get_rows gathers. The names are set
+                // by g4v_gather() in models/granite4-vision.cpp.
+                const int patch_size  = model.hparams.patch_size;
+                const int image_side  = imgs.entries.front().nx() / patch_size;
+                const int window_side = hparams.downsample_window_side;
+                const int query_side  = hparams.downsample_query_side;
+                const int n           = image_side / window_side;
+                const int new_side    = n * query_side;
+
+                // Builds the raster→window permutation indices for a
+                // (side, side) grid split into (n × n) windows of (win × win)
+                // tokens each.  dst[w * win*win + p] = source raster index.
+                auto make_win_idx = [](int side, int win) {
+                    const int nn = side / win;
+                    std::vector<int32_t> idx(static_cast<size_t>(side) * side);
+                    for (int wy = 0; wy < nn; ++wy) {
+                        for (int wx = 0; wx < nn; ++wx) {
+                            for (int iy = 0; iy < win; ++iy) {
+                                for (int ix = 0; ix < win; ++ix) {
+                                    const int w  = wy * nn + wx;
+                                    const int p  = iy * win + ix;
+                                    const int y  = wy * win + iy;
+                                    const int x  = wx * win + ix;
+                                    idx[static_cast<size_t>(w) * (win*win) + p] = y * side + x;
+                                }
+                            }
+                        }
+                    }
+                    return idx;
+                };
+
+                auto make_unwin_idx = [&](int side, int win) {
+                    const std::vector<int32_t> fwd = make_win_idx(side, win);
+                    std::vector<int32_t> inv(fwd.size());
+                    for (size_t i = 0; i < fwd.size(); ++i) {
+                        inv[fwd[i]] = static_cast<int32_t>(i);
+                    }
+                    return inv;
+                };
+
+                auto make_spatial_idx = [](int side, int offset) {
+                    const int off_y = (offset >> 1) & 1;
+                    const int off_x = offset & 1;
+                    const int new_s = side / 2;
+                    std::vector<int32_t> idx(static_cast<size_t>(new_s) * new_s);
+                    for (int y = 0; y < new_s; ++y) {
+                        for (int x = 0; x < new_s; ++x) {
+                            idx[y * new_s + x] = (y * 2 + off_y) * side + (x * 2 + off_x);
+                        }
+                    }
+                    return idx;
+                };
+
+                // the same permutation is applied to every tile of the stacked image
+                auto upload = [&](const std::string & name, const std::vector<int32_t> & idx) {
+                    ggml_tensor * t = ggml_graph_get_tensor(gf, name.c_str());
+                    GGML_ASSERT(t);
+                    GGML_ASSERT(ggml_nelements(t) % (int64_t) idx.size() == 0);
+                    const int n_rep = ggml_nelements(t) / idx.size();
+                    std::vector<int32_t> buf;
+                    buf.reserve(idx.size() * n_rep);
+                    for (int i = 0; i < n_rep; ++i) {
+                        buf.insert(buf.end(), idx.begin(), idx.end());
+                    }
+                    ggml_backend_tensor_set(t, buf.data(), 0, ggml_nbytes(t));
+                };
+
+                // Stage 1b only uses block 0's permutations; future stages
+                // will upload all blocks.
+                for (size_t bid = 0; bid < hparams.feature_layers.size(); ++bid) {
+                    const std::string prefix = "g4v_blk" + std::to_string(bid) + "_";
+                    upload(prefix + "win_idx",     make_win_idx(image_side, window_side));
+                    upload(prefix + "qwin_idx",    make_win_idx(new_side, query_side));
+                    upload(prefix + "unwin_idx",   make_unwin_idx(new_side, query_side));
+                    const auto spatial_offset = hparams.proj_spatial_offsets[bid];
+                    if (spatial_offset >= 0) {
+                        upload(prefix + "spatial_idx", make_spatial_idx(image_side,spatial_offset));
+                    }
+                }
+            } break;
+        default:
+            GGML_ABORT("Unknown projector type");
+    }
+
+    mtmd_backend_cpu_set_n_threads(backend_cpu, params->n_threads);
+
+    auto status = ggml_backend_sched_graph_compute(sched.get(), gf);
+    if (status != GGML_STATUS_SUCCESS) {
+        LOG_ERR("%s: ggml_backend_sched_graph_compute failed with error %d\n", __func__, status);
+        return false;
+    }
+
+    if (embeddings != nullptr) {
+        // sanity check (assuming that all images in batch have the same number of tokens, so we only check the first one)
+        const int n_tokens_out = embeddings->ne[1];
+        const int expected_n_tokens_out = model.n_output_tokens(&imgs.entries[0]);
+        if (n_tokens_out != expected_n_tokens_out) {
+            LOG_ERR("%s: expected output %d tokens, got %d\n", __func__, expected_n_tokens_out, n_tokens_out);
+            GGML_ABORT("Invalid number of output tokens");
+        }
+
+        LOG_DBG("%s: output embedding shape [%d, %d, %d]\n", __func__,
+            (int)embeddings->ne[0], (int)embeddings->ne[1], (int)embeddings->ne[2]);
+
+        // copy output to user buffer if provided
+        // if output is empty, skip the copy
+        auto & out_batch_embd = *params->out_embd;
+        if (!out_batch_embd.empty()) {
+            if (out_batch_embd.size() != (size_t)ggml_nelements(embeddings)) {
+                LOG_ERR("%s: output buffer has %zu elements but expected %zu\n", __func__, out_batch_embd.size(), (size_t)ggml_nelements(embeddings));
+                GGML_ABORT("Output buffer size mismatch");
+            }
+            ggml_backend_tensor_get(embeddings, out_batch_embd.data(), 0, ggml_nbytes(embeddings));
+        } else {
+            LOG_WRN("%s: output buffer is empty, skipping copy\n", __func__);
+        }
+    }
+
+    //
+    // for audio gen models
+    //
+
+    if (model.proj_type == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TOKENIZE) {
+        ggml_tensor * fsq = ggml_graph_get_tensor(gf, "out_fsq");
+        GGML_ASSERT(fsq != nullptr && params->out_codes);
+        const int n_tok = (int) fsq->ne[1];
+        std::vector<float> h((size_t) 8 * n_tok);
+        ggml_backend_tensor_get(fsq, h.data(), 0, ggml_nbytes(fsq));
+
+        // fsq round to base 3: h in (-1, 1) maps to digits {0, 1, 2}
+        params->out_codes->resize(n_tok);
+        for (int t = 0; t < n_tok; t++) {
+            int32_t code = 0;
+            for (int i = 7; i >= 0; i--) {
+                const int32_t d = (int32_t) roundf(h[(size_t) t * 8 + i] * 0.9990000128746033f) + 1;
+                code = code * 3 + d;
+            }
+            (*params->out_codes)[(size_t) t] = code;
+        }
+
+        // speech table rows of the codes, the multilingual variant adds its learned positions as its perceiver expects
+        if (params->out_code_embd) {
+            const auto & tensors = model.cbx_tensors;
+            auto tab_it = tensors.find("a.gen.code.out_embd.weight");
+            GGML_ASSERT(tab_it != tensors.end());
+            ggml_tensor * tab = tab_it->second;
+            const int n_e = (int) tab->ne[0];
+            auto pos_it = tensors.find("a.gen.t3.speech_pos_emb");
+            ggml_tensor * pos = pos_it == tensors.end() ? nullptr : pos_it->second;
+            if ((tab->type != GGML_TYPE_F16 && tab->type != GGML_TYPE_F32) ||
+                (pos && (pos->type != GGML_TYPE_F32 || pos->ne[0] != n_e || pos->ne[1] < n_tok))) {
+                LOG_ERR("%s: unexpected speech table or positions\n", __func__);
+                return false;
+            }
+
+            params->out_code_embd->resize((size_t) n_tok * n_e);
+            std::vector<ggml_fp16_t> h16(n_e);
+            std::vector<float> pr(n_e);
+            for (int t = 0; t < n_tok; t++) {
+                float * dst = params->out_code_embd->data() + (size_t) t * n_e;
+                if ((*params->out_codes)[(size_t) t] >= tab->ne[1]) {
+                    LOG_ERR("%s: speech code beyond the speech table\n", __func__);
+                    return false;
+                }
+                const size_t r = (size_t) (*params->out_codes)[(size_t) t] * n_e;
+                if (tab->type == GGML_TYPE_F16) {
+                    ggml_backend_tensor_get(tab, h16.data(), r * sizeof(ggml_fp16_t), (size_t) n_e * sizeof(ggml_fp16_t));
+                    for (int j = 0; j < n_e; j++) {
+                        dst[j] = ggml_fp16_to_fp32(h16[j]);
+                    }
+                } else {
+                    ggml_backend_tensor_get(tab, dst, r * sizeof(float), (size_t) n_e * sizeof(float));
+                }
+                if (pos) {
+                    ggml_backend_tensor_get(pos, pr.data(), (size_t) t * n_e * sizeof(float), (size_t) n_e * sizeof(float));
+                    for (int j = 0; j < n_e; j++) {
+                        dst[j] += pr[j];
+                    }
+                }
+            }
+        }
+        return true;
+    }
+
+    if (model.proj_type == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE) {
+        ggml_tensor * sp = ggml_graph_get_tensor(gf, "out_spec");
+        GGML_ASSERT(sp != nullptr && params->out_spec);
+        params->out_spec->resize(ggml_nelements(sp));
+        ggml_backend_tensor_get(sp, params->out_spec->data(), 0, ggml_nbytes(sp));
+        return true;
+    }
+
+    if (model.proj_type == PROJECTOR_TYPE_CHATTERBOX && params->gen_process == MTMD_SUBCTX_GEN_PROCESS_TTS) {
+        ggml_tensor * mel = ggml_graph_get_tensor(gf, "out_mel");
+        GGML_ASSERT(mel != nullptr);
+        std::vector<float> mel_data(ggml_nelements(mel));
+        ggml_backend_tensor_get(mel, mel_data.data(), 0, ggml_nbytes(mel));
+
+        // hift: f0 -> harmonic source -> source stft on the host, then the vocoder graph and the istft
+        ggml_tensor * f0_t = ggml_graph_get_tensor(gf, "out_f0");
+        GGML_ASSERT(f0_t != nullptr);
+        const int n_mel_out = (int) ggml_nelements(f0_t);
+        std::vector<float> f0(n_mel_out);
+        ggml_backend_tensor_get(f0_t, f0.data(), 0, ggml_nbytes(f0_t));
+
+        // SineGen: f0 upsampled x480, 9 harmonics with cumulative phase, uv gating and noise, merged by l_linear + tanh
+        const int ups_total = 480;
+        const double sr = 24000.0;
+        const int64_t n_wav = (int64_t) n_mel_out * ups_total;
+        std::vector<float> lw(9);
+        float lb = 0.0f;
+        if (model.cbx_read_tensor("a.gen.hift.m_source.l_linear.weight", lw.data(), lw.size()) != 9 ||
+            model.cbx_read_tensor("a.gen.hift.m_source.l_linear.bias", &lb, 1) != 1) {
+            LOG_ERR("%s: unexpected hift source module\n", __func__);
+            return false;
+        }
+
+        std::mt19937 & srng = params->gen_state->rng;
+        std::uniform_real_distribution<double> ud(-M_PI, M_PI);
+        std::normal_distribution<float> snd(0.0f, 1.0f);
+        double phase[9];
+        for (int h = 0; h < 9; h++) {
+            phase[h] = h == 0 ? 0.0 : ud(srng);
+        }
+        std::vector<float> src((size_t) n_wav);
+        double cum[9] = {0.0};
+        for (int64_t t = 0; t < n_wav; t++) {
+            const float f = f0[(size_t) (t / ups_total)];
+            const float uv = f > 10.0f ? 1.0f : 0.0f; // nsf_voiced_threshold
+            const float namp = uv * 0.003f + (1.0f - uv) * 0.1f / 3.0f;
+            float merged = lb;
+            for (int h = 0; h < 9; h++) {
+                cum[h] += (double) f * (h + 1) / sr;
+                cum[h] -= floor(cum[h]);
+                float sine = 0.1f * (float) sin(2.0 * M_PI * cum[h] + phase[h]);
+                sine = sine * uv + namp * snd(srng);
+                merged += lw[(size_t) h] * sine;
+            }
+            src[(size_t) t] = tanhf(merged);
+        }
+
+        // stft of the source: n_fft 16, hop 4, hann window, centered
+        const int n_fft = 16, hop = 4, n_bins = 9;
+        std::vector<float> win(n_fft);
+        for (int i = 0; i < n_fft; i++) win[(size_t) i] = 0.5f - 0.5f * cosf(2.0f * (float) M_PI * i / n_fft);
+        const int n_stft = (int) (n_wav / hop) + 1;
+        std::vector<float> sstft((size_t) n_stft * 18);
+        for (int fr = 0; fr < n_stft; fr++) {
+            const int64_t c0 = (int64_t) fr * hop - n_fft / 2; // centered, reflect padded
+            for (int k = 0; k < n_bins; k++) {
+                double re = 0.0, im = 0.0;
+                for (int i = 0; i < n_fft; i++) {
+                    int64_t idx = c0 + i;
+                    if (idx < 0) idx = -idx;
+                    if (idx >= n_wav) idx = 2 * (n_wav - 1) - idx;
+                    const double v = (double) src[(size_t) idx] * win[(size_t) i];
+                    const double a = 2.0 * M_PI * k * i / n_fft;
+                    re += v * cos(a);
+                    im -= v * sin(a);
+                }
+                sstft[(size_t) fr * 18 + k    ] = (float) re;
+                sstft[(size_t) fr * 18 + 9 + k] = (float) im;
+            }
+        }
+
+        // vocoder graph on mel + source stft
+        std::vector<float> spec;
+        {
+            mtmd_subctx_encode_params vp = *params;
+            vp.gen_process   = MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE;
+            vp.mel_in        = &mel_data;
+            vp.sstft_in      = &sstft;
+            vp.vocode_n_mel  = n_mel_out;
+            vp.vocode_n_stft = n_stft;
+            vp.out_audio     = nullptr;
+            vp.out_spec      = &spec;
+            if (!encode(&vp)) {
+                LOG_ERR("%s: vocoder stage failed\n", __func__);
+                return false;
+            }
+        }
+
+        // istft: mag = clipped exp, phase = sin, hann overlap-add
+        const int n_frames_out = (int) (spec.size() / 18);
+        const int64_t n_out = (int64_t) (n_frames_out - 1) * hop;
+        std::vector<double> acc((size_t) n_out + n_fft, 0.0);
+        std::vector<double> wsum((size_t) n_out + n_fft, 0.0);
+        for (int fr = 0; fr < n_frames_out; fr++) {
+            double frame[16];
+            for (int i = 0; i < n_fft; i++) {
+                double v = 0.0;
+                for (int k = 0; k < n_bins; k++) {
+                    const double mag = fmin(exp((double) spec[(size_t) fr * 18 + k]), 1e2);
+                    const double ph  = sin((double) spec[(size_t) fr * 18 + 9 + k]);
+                    const double re  = mag * cos(ph), im = mag * sin(ph);
+                    const double a   = 2.0 * M_PI * k * i / n_fft;
+                    const double w   = (k == 0 || k == n_fft / 2) ? 1.0 : 2.0;
+                    v += w * (re * cos(a) - im * sin(a));
+                }
+                frame[i] = v / n_fft;
+            }
+            const int64_t o = (int64_t) fr * hop;
+            for (int i = 0; i < n_fft; i++) {
+                acc [(size_t) (o + i)] += frame[i] * win[(size_t) i];
+                wsum[(size_t) (o + i)] += (double) win[(size_t) i] * win[(size_t) i];
+            }
+        }
+        GGML_ASSERT(params->out_audio);
+        auto & out_audio = *params->out_audio;
+        out_audio.resize((size_t) std::min<int64_t>(n_out - n_fft / 2, n_wav));
+        for (size_t i = 0; i < out_audio.size(); i++) {
+            const size_t j = i + n_fft / 2; // drop the centering pad
+            const double v = wsum[j] > 1e-11 ? acc[j] / wsum[j] : 0.0;
+            out_audio[(size_t) i] = (float) fmax(-0.99, fmin(0.99, v));
+        }
+
+        // trim_fade of the reference: silence 20 ms then fade in 20 ms, hides the artifact of the prompt boundary
+        const size_t n_trim = 24000 / 50;
+        for (size_t i = 0; i < 2 * n_trim && i < out_audio.size(); i++) {
+            const double g = i < n_trim ? 0.0
+                : (cos(M_PI * (1.0 - (double) (i - n_trim) / n_trim)) + 1.0) / 2.0;
+            out_audio[(size_t) i] *= (float) g;
+        }
+        return true;
+    }
+
+    // optional outputs: a pipeline yields codes or feats, and not all have an eos head
+    if (params->out_codes != nullptr) {
+        ggml_tensor * codes = ggml_graph_get_tensor(gf, "out_codes");
+        if (codes != nullptr) {
+            auto & out_codes = *params->out_codes;
+            out_codes.resize(ggml_nelements(codes));
+            ggml_backend_tensor_get(codes, out_codes.data(), 0, ggml_nbytes(codes));
+        }
+    }
+    if (params->out_feats != nullptr) {
+        ggml_tensor * feats = ggml_graph_get_tensor(gf, "out_feats");
+        if (feats != nullptr) {
+            auto & out_feats = *params->out_feats;
+            out_feats.resize(ggml_nelements(feats));
+            ggml_backend_tensor_get(feats, out_feats.data(), 0, ggml_nbytes(feats));
+        }
+    }
+    if (params->out_is_eos != nullptr) {
+        ggml_tensor * eos = ggml_graph_get_tensor(gf, "out_eos_score");
+        if (eos != nullptr) {
+            GGML_ASSERT(ggml_nelements(eos) == 1);
+            float score = 0.0f;
+            ggml_backend_tensor_get(eos, &score, 0, sizeof(float));
+            *params->out_is_eos = score > hparams.gen_eos_threshold;
+        }
+    }
+    if (params->out_audio != nullptr) {
+        ggml_tensor * audio = ggml_graph_get_tensor(gf, "out_audio");
+        if (audio == nullptr) {
+            GGML_ABORT("out_audio requested but graph has no \"out_audio\" tensor");
+        }
+        auto & out_audio = *params->out_audio;
+        out_audio.resize(ggml_nelements(audio));
+        ggml_backend_tensor_get(audio, out_audio.data(), 0, ggml_nbytes(audio));
+
+        if (model.proj_type == PROJECTOR_TYPE_SOPRANO) {
+            std::vector<float> spectrum = std::move(out_audio);
+            mtmd_graph_soprano::decode_spectrum(model, spectrum, out_audio);
+        }
+
+        // drop the tail audio that comes from the code-0 rear padding, drama3 has no padding
+        if (model.proj_type != PROJECTOR_TYPE_DRAMA3_GEN) {
+            const int64_t n_codes    = params->codes ? model.gen_code_head_w->ne[2] + 1 : 0;
+            const int64_t n_frames_w = hparams.wav_tfm_swa;
+            const int64_t n_frames   = params->codes ? (int64_t) params->codes->size() / n_codes : n_frames_w;
+            if (n_frames < n_frames_w) {
+                const size_t hop = out_audio.size() / n_frames_w;
+                out_audio.resize((size_t) n_frames * hop);
+            }
+        }
+    }
+    if (params->enc_state != nullptr && model.proj_type == PROJECTOR_TYPE_LFM3A) {
+        const auto & slots = params->enc_state->slots;
+        size_t i = 0;
+        for (const auto & slot : list_lfm3a_state_slots(hparams, model)) {
+            ggml_tensor * t = ggml_graph_get_tensor(gf, ("state_out_" + slot.name).c_str());
+            GGML_ASSERT(t != nullptr);
+            copy_gen_state_out(t, slots[i++]);
+        }
+    } else if (params->enc_state != nullptr && model.proj_type == PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE) {
+        mtmd_subctx_enc_state * st = params->enc_state;
+        ggml_tensor * chunk = ggml_graph_get_tensor(gf, "spk_chunk");
+        ggml_tensor * preds = ggml_graph_get_tensor(gf, "spk_preds");
+        GGML_ASSERT(chunk != nullptr && preds != nullptr);
+        st->spk_chunk.resize(ggml_nelements(chunk));
+        st->spk_preds.resize(ggml_nelements(preds));
+        ggml_backend_tensor_get(chunk, st->spk_chunk.data(), 0, ggml_nbytes(chunk));
+        ggml_backend_tensor_get(preds, st->spk_preds.data(), 0, ggml_nbytes(preds));
+    } else if (params->enc_state != nullptr) {
+        mtmd_subctx_enc_state * st = params->enc_state;
+        ggml_tensor * tail = ggml_graph_get_tensor(gf, "out_stem_tail");
+        GGML_ASSERT(tail != nullptr);
+        st->stem_tail.resize(ggml_nelements(tail));
+        ggml_backend_tensor_get(tail, st->stem_tail.data(), 0, ggml_nbytes(tail));
+
+        const int n_new = (imgs.entries[0].nx() - 2) / 2;
+        for (int i = 0; i < n_new; i++) {
+            st->slot_pos[(st->n_past + i) % st->n_ctx] = st->n_past + i;
+        }
+        st->n_past += n_new;
+    }
+
+    if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+        const auto & slots = params->gen_state->slots;
+        size_t i = 0;
+        for (const auto & slot : list_gen_state_slots(hparams, model)) {
+            if (slot.in_place) {
+                i++;
+                continue;
+            }
+            ggml_tensor * t = ggml_graph_get_tensor(gf, ("state_out_" + slot.name).c_str());
+            if (t == nullptr) {
+                GGML_ABORT("graph has no \"state_out_%s\" tensor", slot.name.c_str());
+            }
+            GGML_ASSERT(i < slots.size());
+            copy_gen_state_out(t, slots[i++]);
+        }
+    }
+
+    //
+    // Debug: dump final embeddings if MTMD_DEBUG_EMBEDDINGS is set
+    //
+
+    if (debug_output_embeddings && embeddings != nullptr) {
+        const int64_t n_embd = embeddings->ne[0];
+        const int64_t n_tokens = embeddings->ne[1];
+        std::vector<float> emb_data(ggml_nelements(embeddings));
+        ggml_backend_tensor_get(embeddings, emb_data.data(), 0, ggml_nbytes(embeddings));
+
+        LOG_INF("\n=== MTMD_DEBUG_EMBEDDINGS ===\n");
+        LOG_INF("Shape: [%lld, %lld]\n", (long long)n_embd, (long long)n_tokens);
+
+        // TEMP debugging (parity validation), will be removed before merge
+        // when the env var holds a path, dump the raw data: [int32 n_tokens][int32 n_embd][f32 data]
+        const char * dump_path = std::getenv("MTMD_DEBUG_EMBEDDINGS");
+        if (dump_path && strcmp(dump_path, "1") != 0) {
+            FILE * f = fopen(dump_path, "wb");
+            if (f) {
+                const int32_t hdr[2] = { (int32_t)n_tokens, (int32_t)n_embd };
+                fwrite(hdr, sizeof(hdr), 1, f);
+                fwrite(emb_data.data(), sizeof(float), emb_data.size(), f);
+                fclose(f);
+            }
+        }
+
+        // Print first few values of first token
+        LOG_INF("Token 0 (first 16 values): ");
+        for (int i = 0; i < std::min((int64_t)16, n_embd); i++) {
+            LOG_INF("%.6f ", emb_data[i]);
+        }
+        LOG_INF("\n");
+
+        // Print last few values of first token
+        if (n_embd > 16) {
+            LOG_INF("Token 0 (last 16 values):  ");
+            for (int64_t i = n_embd - 16; i < n_embd; i++) {
+                LOG_INF("%.6f ", emb_data[i]);
+            }
+            LOG_INF("\n");
+        }
+
+        // Compute and print statistics
+        float sum = 0.0f, sum_sq = 0.0f, min_val = emb_data[0], max_val = emb_data[0];
+        for (size_t i = 0; i < emb_data.size(); i++) {
+            sum += emb_data[i];
+            sum_sq += emb_data[i] * emb_data[i];
+            min_val = std::min(min_val, emb_data[i]);
+            max_val = std::max(max_val, emb_data[i]);
+        }
+        float mean = sum / emb_data.size();
+        float variance = (sum_sq / emb_data.size()) - (mean * mean);
+        LOG_INF("Stats: mean=%.6f, std=%.6f, min=%.6f, max=%.6f, sum=%.6f\n",
+                mean, sqrtf(variance), min_val, max_val, sum);
+        LOG_INF("=== END MTMD_DEBUG_EMBEDDINGS ===\n\n");
+    }
+
+    return true;
+}
+
+std::map<ggml_backend_dev_t, size_t> mtmd_subcontext::get_mem_usage() const {
+    std::map<ggml_backend_dev_t, size_t> result = model.mem_weights;
+    for (auto & [dev, size] : mem_compute) {
+        result[dev] += size;
+    }
+    return result;
+}
diff --git a/tools/mtmd/mtmd-subcontext.h b/tools/mtmd/mtmd-subcontext.h
new file mode 100644
index 000000000..d757c9b6e
--- /dev/null
+++ b/tools/mtmd/mtmd-subcontext.h
@@ -0,0 +1,202 @@
+#pragma once
+
+#include "ggml.h"
+#include "ggml-backend.h"
+#include "ggml-cpp.h"
+#include "mtmd-impl.h"
+#include "mtmd-model.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include <map>
+#include <memory>
+#include <random>
+#include <vector>
+
+// !!! Internal header, to be used by mtmd only !!!
+
+// a subcontext runs the graphs of one mtmd_model (one modality)
+// it owns the backends and the compute buffers, the weights are owned by the model
+
+enum mtmd_flash_attn_type {
+    MTMD_FLASH_ATTN_TYPE_AUTO     = -1,
+    MTMD_FLASH_ATTN_TYPE_DISABLED = 0,
+    MTMD_FLASH_ATTN_TYPE_ENABLED  = 1,
+};
+
+struct mtmd_subctx_params {
+    ggml_backend_dev_t device; // nullptr means CPU, see mtmd_select_device()
+    std::shared_ptr<ggml_threadpool> threadpool; // CPU threadpool shared by the subcontexts, can be nullptr
+    enum mtmd_flash_attn_type flash_attn_type;
+    bool warmup;
+    ggml_backend_sched_eval_callback cb_eval;
+    void * cb_eval_user_data;
+};
+
+// the device the weights and the graphs are placed on, nullptr means CPU
+ggml_backend_dev_t mtmd_select_device(bool use_gpu, ggml_backend_dev_t device);
+
+// nullptr if n_threads < 2 or the CPU backend has no threadpool support
+std::shared_ptr<ggml_threadpool> mtmd_threadpool_init(int n_threads);
+
+// compute the log-mel of n_frames frames, the waveform must already be padded and window must hold one whole frame
+// out has the layout [n_frames, n_mel]; like the encoders, must not run at the same time as another pass on config.sched
+struct mtmd_audio_mel_config;
+bool mtmd_audio_log_mel(const mtmd_audio_mel_config & config, const float * samples, const float * window, int64_t n_frames, float * out);
+
+enum mtmd_subctx_gen_process {
+    MTMD_SUBCTX_GEN_PROCESS_GEN_UNKNOWN,
+    MTMD_SUBCTX_GEN_PROCESS_GEN_CODE, // h_state to codes
+    MTMD_SUBCTX_GEN_PROCESS_GEN_WAV,  // codes to raw PCM audio
+    MTMD_SUBCTX_GEN_PROCESS_TTS,        // full utterance of codes to raw PCM audio
+    MTMD_SUBCTX_GEN_PROCESS_TTS_VOCODE, // internal: mel + source stft to istft input
+    MTMD_SUBCTX_GEN_PROCESS_TOKENIZE,   // raw PCM audio to semantic speech tokens
+};
+
+// persistent state of a causal audio encoder, for streaming input (one per stream)
+// it holds the sliding window of past keys/values, so each encode() call only takes the new frames
+struct mtmd_subctx_enc_state {
+    int32_t n_ctx  = 0; // ring slots: attention window + frames per call
+    int32_t n_past = 0; // absolute position of the next frame
+
+    std::vector<int32_t> slot_pos;  // absolute position held by each slot, -1 if empty
+    std::vector<float>   stem_tail; // last conv stem output frame, empty on a cold start
+
+    ggml_context_ptr        ctx_data;
+    ggml_backend_buffer_ptr buf;
+    std::vector<ggml_tensor *> k; // per layer [n_embd_kv, n_ctx]
+    std::vector<ggml_tensor *> v;
+
+    // lfm3a: same order as list_lfm3a_state_slots(), F32 [ne0, ne1], updated in place
+    std::vector<ggml_tensor *> slots;
+
+    // nemotron transcribe: the window's normalized log-mel, cache and FIFO rows in; its speaker rows and predictions out
+    std::vector<float> mel_norm;
+    std::vector<float> spk_prev;
+    std::vector<float> spk_chunk, spk_preds;
+};
+
+// per-sequence state of audio generation: RNG and the slots kept between GEN_WAV calls
+struct mtmd_subctx_gen_state {
+    std::mt19937 rng;
+
+    ggml_context_ptr        ctx_data;
+    ggml_backend_buffer_ptr buf;
+    std::vector<ggml_tensor *> slots; // same order as list_gen_state_slots(), F32 [ne0, ne1]
+
+    // clears the slots and keeps the RNG stream
+    void reset() {
+        if (buf) {
+            ggml_backend_buffer_clear(buf.get(), 0);
+        }
+    }
+};
+
+struct mtmd_subctx_encode_params {
+    int n_threads = 1;
+    const mtmd_image_f32_batch * imgs = nullptr;
+    std::vector<float> * out_embd = nullptr;
+
+    // for audio gen, imgs has exactly one entry: hidden state from backbone (GEN_CODE) or unused (GEN_WAV)
+    mtmd_subctx_gen_process gen_process = MTMD_SUBCTX_GEN_PROCESS_GEN_UNKNOWN;
+
+    // GEN_CODE: out_embd receives the embd to feed back to the backbone
+    int32_t code0 = 0; // semantic code sampled by the backbone
+    int32_t top_k = 50;
+    float   top_p = 1.0f;
+    std::vector<int32_t> * out_codes = nullptr; // this frame's 16 sampled codes
+    std::vector<float> * out_feats = nullptr; // continuous counterpart of out_codes
+    float   temp = 0.0f;                      // sampling temperature, noise scale for flow-matching decoders
+    bool * out_is_eos = nullptr;
+
+    // TOKENIZE: speech embedding rows of the produced codes
+    std::vector<float> * out_code_embd = nullptr;
+
+    // GEN_WAV
+    const std::vector<int32_t> * codes = nullptr;     // this frame's 16 RVQ codes
+    const std::vector<float> *   feats = nullptr;     // continuous counterpart of codes
+    std::vector<float> * out_audio = nullptr;         // decoded PCM samples, F32
+    const mtmd_gen_ref * ref = nullptr; // TTS reference conditioning of the voice
+    // TTS_VOCODE internal stage inputs
+    const std::vector<float> * mel_in   = nullptr;
+    const std::vector<float> * sstft_in = nullptr;
+    int vocode_n_mel  = 0;
+    int vocode_n_stft = 0;
+    std::vector<float> * out_spec = nullptr;
+
+    // audio gen: state of one sequence, encode() updates it in place
+    struct mtmd_subctx_gen_state * gen_state = nullptr;
+
+    // streaming audio input, updated in place by encode()
+    struct mtmd_subctx_enc_state * enc_state = nullptr;
+};
+
+struct mtmd_subcontext {
+    const mtmd_model & model;
+
+    std::vector<uint8_t> buf_compute_meta;
+
+    std::vector<ggml_backend_t> backend_ptrs;
+    std::vector<ggml_backend_buffer_type_t> backend_buft;
+
+    std::shared_ptr<ggml_threadpool> threadpool; // shared by the subcontexts of a model
+
+    ggml_backend_t backend = nullptr;
+    ggml_backend_t backend_cpu = nullptr;
+
+    int max_nodes = 8192;
+    ggml_backend_sched_ptr sched;
+    mtmd_flash_attn_type flash_attn_type = MTMD_FLASH_ATTN_TYPE_AUTO;
+    bool is_allocated = false;
+
+    bool debug_output_embeddings = false;
+
+    // for measuring memory usage
+    std::map<ggml_backend_dev_t, size_t> mem_compute;
+
+    bool support_batch = false;
+
+    // the model must outlive the subcontext
+    mtmd_subcontext(const mtmd_model & model, const mtmd_subctx_params & params);
+    ~mtmd_subcontext();
+
+    // reserve the compute buffers for the given batch
+    void warmup(const mtmd_image_f32_batch & batch);
+
+    bool encode(mtmd_subctx_encode_params * params);
+
+    // TODO: remove encode_image() and always use batched version
+    bool encode_image(int n_threads, const mtmd_image_f32 * img, std::vector<float> & out_vec);
+    bool encode_batch(int n_threads, const mtmd_image_f32_batch * imgs, std::vector<float> & out_batch_embd);
+
+    // n_frames_max is the maximum number of encoder frames per encode() call, free the result with delete
+    mtmd_subctx_enc_state * enc_state_init(int32_t n_frames_max);
+
+    // seed UINT32_MAX means random, free the result with delete
+    mtmd_subctx_gen_state * gen_state_init(uint32_t seed);
+
+    // weights and compute buffers
+    std::map<ggml_backend_dev_t, size_t> get_mem_usage() const;
+
+private:
+    struct support_info_op {
+        ggml_tensor * op;
+
+        // true if the op runs on the accelerated backend
+        bool is_accel = true;
+    };
+
+    struct support_info_graph {
+        // whether the backend supports flash attention
+        bool fattn = true;
+        ggml_tensor * fattn_op = nullptr; // for debugging
+
+        std::vector<support_info_op> ops;
+    };
+
+    mtmd_image_f32_batch get_dummy_batch() const;
+
+    // only initialize backend buffers, but do not allocate them yet
+    support_info_graph reserve_compute_meta(const mtmd_image_f32_batch & batch);
+};
diff --git a/tools/mtmd/mtmd.cpp b/tools/mtmd/mtmd.cpp
index 538f90013..6b4fc4d0a 100644
--- a/tools/mtmd/mtmd.cpp
+++ b/tools/mtmd/mtmd.cpp
@@ -1,5 +1,5 @@
-#include "clip.h"
-#include "clip-impl.h"
+#include "mtmd-subcontext.h"
+#include "mtmd-impl.h"
 #include "mtmd.h"
 #include "mtmd-internal.h"
 #include "mtmd-audio.h"
@@ -106,10 +106,10 @@ std::string mtmd_serialization::read<std::string>() {
     return str;
 }

-// only mtmd.cpp needs these, so they're implemented here rather than in clip-impl.h
-void clip_image_f32::serialize(mtmd_serialization & ser) const {
+// only mtmd.cpp needs these, so they're implemented here rather than in mtmd-impl.h
+void mtmd_image_f32::serialize(mtmd_serialization & ser) const {
     // remember to bump MTMD_SERIALIZATION_VERSION if this is changed
-    // note: buf is intentionally NOT serialized; the loaded clip_image_f32 will always be a placeholder
+    // note: buf is intentionally NOT serialized; the loaded mtmd_image_f32 will always be a placeholder
     ser.write(add_viewsep);
     ser.write(add_newline);
     ser.write(lead_pad);
@@ -121,16 +121,16 @@ void clip_image_f32::serialize(mtmd_serialization & ser) const {
     ser.write((int32_t)nx_);
     ser.write((int32_t)ny_);
 }
-void clip_image_f32::deserialize(mtmd_serialization & ser) {
+void mtmd_image_f32::deserialize(mtmd_serialization & ser) {
     add_viewsep = ser.read<bool>();
     add_newline = ser.read<bool>();
     lead_pad = ser.read<int32_t>();
     if (ser.version >= 3) {
         const int32_t suffix_raw = ser.read<int32_t>();
-        if (suffix_raw < 0 || suffix_raw >= CLIP_SUFFIX_COUNT) {
+        if (suffix_raw < 0 || suffix_raw >= MTMD_SUFFIX_COUNT) {
             throw std::runtime_error("invalid suffix type");
         }
-        suffix_type = (clip_suffix_type)suffix_raw;
+        suffix_type = (mtmd_suffix_type)suffix_raw;
         anyres.grid_x = ser.read<int32_t>();
         anyres.grid_y = ser.read<int32_t>();
         anyres.orig_nx = ser.read<int32_t>();
@@ -141,7 +141,7 @@ void clip_image_f32::deserialize(mtmd_serialization & ser) {
     buf.clear(); // always a placeholder after loading
 }

-void clip_image_f32_batch::serialize(mtmd_serialization & ser) const {
+void mtmd_image_f32_batch::serialize(mtmd_serialization & ser) const {
     // remember to bump MTMD_SERIALIZATION_VERSION if this is changed
     ser.write(is_audio);
     ser.write<uint64_t>(entries.size());
@@ -149,7 +149,7 @@ void clip_image_f32_batch::serialize(mtmd_serialization & ser) const {
         entry.serialize(ser);
     }
 }
-void clip_image_f32_batch::deserialize(mtmd_serialization & ser) {
+void mtmd_image_f32_batch::deserialize(mtmd_serialization & ser) {
     is_audio = ser.read<bool>();
     uint64_t n = ser.read<uint64_t>();
     constexpr size_t min_entry_bytes = sizeof(uint8_t) * 2 + sizeof(int32_t) * 2;
@@ -159,7 +159,7 @@ void clip_image_f32_batch::deserialize(mtmd_serialization & ser) {
     entries.clear();
     entries.reserve(n);
     for (uint64_t i = 0; i < n; i++) {
-        clip_image_f32 entry;
+        mtmd_image_f32 entry;
         entry.deserialize(ser);
         entries.push_back(std::move(entry));
     }
@@ -176,7 +176,7 @@ struct mtmd_bitmap {
     std::string id; // optional user-defined id, for ex: can be set to image hash, useful for KV cache tracking
     bool is_audio = false; // true if the bitmap is audio
     bool is_voice = false;
-    std::shared_ptr<const clip_gen_ref> gen_ref; // gen-side reference of a predefined voice, if any
+    std::shared_ptr<const mtmd_gen_ref> gen_ref; // gen-side reference of a predefined voice, if any
     bool mergeable = false; // [QWEN_VIDEO] set only on frames of the same video
     bool is_video  = false; // see mtmd_bitmap_set_is_video()

@@ -202,13 +202,13 @@ struct mtmd_bitmap {
         }
     }

-    static mtmd_bitmap * init_voice(const std::string & name, const clip_gen_voice & voice, uint32_t n_embd) {
+    static mtmd_bitmap * init_voice(const std::string & name, const mtmd_gen_voice & voice, uint32_t n_embd) {
         const float *  rows   = voice.rows.data();
         const uint32_t n_rows = voice.rows.size() / n_embd;
         auto * bitmap = new mtmd_bitmap(nullptr, n_embd, n_rows);
         bitmap->is_voice = true;
         if (!voice.ref.tokens.empty()) {
-            bitmap->gen_ref = std::make_shared<const clip_gen_ref>(voice.ref);
+            bitmap->gen_ref = std::make_shared<const mtmd_gen_ref>(voice.ref);
         }
         bitmap->id = name;
         bitmap->data.assign((const unsigned char *) rows, (const unsigned char *) (rows + (size_t) n_embd * n_rows));
@@ -268,7 +268,7 @@ struct mtmd_image_tokens {
             uint32_t n = 0;
             for (size_t k = 0; k < batch_f32.entries.size(); ++k) {
                 const auto [gw, gh] = canvas_tile_grid(k);
-                n += gw * gh + (uint32_t) clip_suffix_rows(batch_f32.entries[k].suffix_type).size();
+                n += gw * gh + (uint32_t) mtmd_suffix_rows(batch_f32.entries[k].suffix_type).size();
             }
             return n;
         }
@@ -284,7 +284,7 @@ struct mtmd_image_tokens {
         }
         return nx * ny * nz;
     }
-    clip_image_f32_batch batch_f32; // preprocessed image patches
+    mtmd_image_f32_batch batch_f32; // preprocessed image patches
     std::string id; // optional user-defined ID, useful for KV cache tracking

     // true if one of entries in batch_f32 is a placeholder
@@ -354,16 +354,16 @@ using mtmd_audio_source_ptr = std::shared_ptr<mtmd_audio_preproc_out>;
 // an audio track split across chunks (e.g. the soundtrack of a video): it is encoded once, as one clip, and every
 // chunk takes its rows of it
 struct mtmd_audio_track {
-    clip_image_f32_batch batch_f32; // the mel of the whole track
+    mtmd_image_f32_batch batch_f32; // the mel of the whole track
     mtmd_audio_source_ptr source;
     uint32_t n_tokens = 0;
     std::vector<float> embd;        // filled by the first encode
-    std::shared_ptr<const clip_gen_ref> gen_ref; // gen-side reference of a predefined voice, if any
+    std::shared_ptr<const mtmd_gen_ref> gen_ref; // gen-side reference of a predefined voice, if any
 };

 struct mtmd_audio_tokens {
     uint32_t n_tokens = 0; // number of tokens
-    clip_image_f32_batch batch_f32; // preprocessed image patches
+    mtmd_image_f32_batch batch_f32; // preprocessed image patches
     std::string id; // optional user-defined ID, useful for KV cache tracking

     mtmd_audio_source_ptr source; // if set, batch_f32 has the shape of the mel of chunk source_idx
@@ -554,13 +554,13 @@ const char * mtmd_default_marker() {
     return "<__media__>";
 }

-static clip_flash_attn_type mtmd_get_clip_flash_attn_type(enum llama_flash_attn_type flash_attn_type) {
+static mtmd_flash_attn_type mtmd_to_flash_attn_type(enum llama_flash_attn_type flash_attn_type) {
     switch (flash_attn_type) {
-        case LLAMA_FLASH_ATTN_TYPE_AUTO:     return CLIP_FLASH_ATTN_TYPE_AUTO;
-        case LLAMA_FLASH_ATTN_TYPE_DISABLED: return CLIP_FLASH_ATTN_TYPE_DISABLED;
-        case LLAMA_FLASH_ATTN_TYPE_ENABLED:  return CLIP_FLASH_ATTN_TYPE_ENABLED;
+        case LLAMA_FLASH_ATTN_TYPE_AUTO:     return MTMD_FLASH_ATTN_TYPE_AUTO;
+        case LLAMA_FLASH_ATTN_TYPE_DISABLED: return MTMD_FLASH_ATTN_TYPE_DISABLED;
+        case LLAMA_FLASH_ATTN_TYPE_ENABLED:  return MTMD_FLASH_ATTN_TYPE_ENABLED;
     }
-    return CLIP_FLASH_ATTN_TYPE_AUTO;
+    return MTMD_FLASH_ATTN_TYPE_AUTO;
 }

 mtmd_context_params mtmd_context_params_default() {
@@ -585,20 +585,23 @@ mtmd_context_params mtmd_context_params_default() {
 }

 // chatterbox multilingual has a perceiver in its conditioning encoder (speaker encoding context)
-static bool cbx_is_mtl(clip_ctx * ctx_a) {
-    return clip_cbx_read_tensor(ctx_a, "a.cenc.perceiver.pre_attention_query", nullptr, 0) > 0;
+static bool cbx_is_mtl(const mtmd_model & model) {
+    return model.cbx_read_tensor("a.cenc.perceiver.pre_attention_query", nullptr, 0) > 0;
 }

 struct mtmd_context {
-    struct clip_ctx * ctx_v; // vision
-    struct clip_ctx * ctx_a; // audio
+    // must outlive the subcontexts below
+    mtmd_models models;
+
+    std::unique_ptr<mtmd_subcontext> ctx_v; // vision
+    std::unique_ptr<mtmd_subcontext> ctx_a; // audio
     std::vector<float> out_embd; // image embedding vector

     // generation context
-    struct clip_ctx * ctx_gen_a; // audio
+    std::unique_ptr<mtmd_subcontext> ctx_gen_a; // audio

     // gen-side reference data of the last encoded chunk (chatterbox), see mtmd_gen_state_set_ref()
-    clip_gen_ref gen_ref;
+    mtmd_gen_ref gen_ref;

     bool print_timings;
     int n_threads;
@@ -683,34 +686,48 @@ struct mtmd_context {
             }
         }

-        clip_context_params ctx_clip_params {
-            /* use_gpu           */ ctx_params.use_gpu,
-            /* device            */ ctx_params.device,
-            /* flash_attn_type   */ mtmd_get_clip_flash_attn_type(ctx_params.flash_attn_type),
-            /* image_min_tokens  */ ctx_params.image_min_tokens,
-            /* image_max_tokens  */ ctx_params.image_max_tokens,
+        ggml_backend_dev_t device = mtmd_select_device(ctx_params.use_gpu, ctx_params.device);
+
+        mtmd_model_params model_params;
+        model_params.image_min_tokens            = ctx_params.image_min_tokens;
+        model_params.image_max_tokens            = ctx_params.image_max_tokens;
+        model_params.no_alloc                    = no_alloc;
+        model_params.progress_callback           = ctx_params.progress_callback;
+        model_params.progress_callback_user_data = ctx_params.progress_callback_user_data;
+
+        ggml_backend_buffer_type_t buft = device
+            ? ggml_backend_dev_buffer_type(device)
+            : ggml_backend_dev_buffer_type(ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU));
+        models = mtmd_model_load(mmproj_fname, model_params, buft);
+
+        // soprano and pocket-tts without voice cloning have no encoder
+        if (!models.v && !models.a && !models.gen_a) {
+            throw std::runtime_error(string_format("Failed to load mmproj model from %s\n", mmproj_fname));
+        }
+
+        mtmd_subctx_params sctx_params {
+            /* device            */ device,
+            // no_alloc only measures memory, nothing is computed
+            /* threadpool        */ mtmd_threadpool_init(no_alloc ? 1 : ctx_params.n_threads),
+            /* flash_attn_type   */ mtmd_to_flash_attn_type(ctx_params.flash_attn_type),
             /* warmup            */ ctx_params.warmup,
             /* cb_eval           */ ctx_params.cb_eval,
             /* cb_eval_user_data */ ctx_params.cb_eval_user_data,
-            /* no_alloc          */ no_alloc,
-            /* progress_callback */ ctx_params.progress_callback,
-            /* progress_callback_user_data */ ctx_params.progress_callback_user_data,
-            /* n_threads                   */ ctx_params.n_threads,
         };
-
-        auto res = clip_init(mmproj_fname, ctx_clip_params);
-        ctx_v = res.ctx_v;
-        ctx_a = res.ctx_a;
-        ctx_gen_a = res.ctx_gen_a;
-        // soprano and pocket-tts without voice cloning have no encoder
-        if (!ctx_v && !ctx_a && !ctx_gen_a) {
-            throw std::runtime_error(string_format("Failed to load CLIP model from %s\n", mmproj_fname));
+        if (models.v) {
+            ctx_v = std::make_unique<mtmd_subcontext>(*models.v, sctx_params);
+        }
+        if (models.a) {
+            ctx_a = std::make_unique<mtmd_subcontext>(*models.a, sctx_params);
+        }
+        if (models.gen_a) {
+            ctx_gen_a = std::make_unique<mtmd_subcontext>(*models.gen_a, sctx_params);
         }

         // if both vision and audio mmproj are present, we need to validate their n_embd
         if (ctx_v && ctx_a) {
-            int n_embd_v = clip_n_mmproj_embd(ctx_v);
-            int n_embd_a = clip_n_mmproj_embd(ctx_a);
+            int n_embd_v = ctx_v->model.n_mmproj_embd();
+            int n_embd_a = ctx_a->model.n_mmproj_embd();
             if (n_embd_v != n_embd_a) {
                 throw std::runtime_error(string_format(
                     "mismatch between vision and audio mmproj (n_embd_v = %d, n_embd_a = %d)\n",
@@ -721,15 +738,15 @@ struct mtmd_context {
         // since we already validate n_embd of vision and audio mmproj,
         // we can safely assume that they are the same
         // a gen-only mmproj is validated below
-        int n_embd_clip = ctx_v || ctx_a ? clip_n_mmproj_embd(ctx_v ? ctx_v : ctx_a) : n_embd_text;
-        if (n_embd_text > 0 && n_embd_text != n_embd_clip) {
+        int n_embd_mmproj = ctx_v || ctx_a ? (ctx_v ? ctx_v : ctx_a)->model.n_mmproj_embd() : n_embd_text;
+        if (n_embd_text > 0 && n_embd_text != n_embd_mmproj) {
             throw std::runtime_error(string_format(
                 "mismatch between text model (n_embd = %d) and mmproj (n_embd = %d)\n"
                 "hint: you may be using wrong mmproj\n",
-                n_embd_text, n_embd_clip));
+                n_embd_text, n_embd_mmproj));
         }
         if (ctx_gen_a) {
-            int n_embd_gen = clip_n_mmproj_embd(ctx_gen_a);
+            int n_embd_gen = ctx_gen_a->model.n_mmproj_embd();
             if (n_embd_text > 0 && n_embd_text != n_embd_gen) {
                 throw std::runtime_error(string_format(
                     "mismatch between text model (n_embd = %d) and gen-audio mmproj (n_embd = %d)\n"
@@ -749,7 +766,7 @@ struct mtmd_context {
         GGML_ASSERT(ctx_v != nullptr);
         image_preproc.reset();

-        projector_type proj = clip_get_projector_type(ctx_v);
+        projector_type proj = ctx_v->model.proj_type;

         switch (proj) {
             case PROJECTOR_TYPE_MLP:
@@ -760,16 +777,16 @@ struct mtmd_context {
             case PROJECTOR_TYPE_JANUS_PRO:
             case PROJECTOR_TYPE_GLM_EDGE:
                 {
-                    bool has_pinpoints = !clip_get_hparams(ctx_v)->image_res_candidates.empty();
+                    bool has_pinpoints = !ctx_v->model.hparams.image_res_candidates.empty();
                     if (has_pinpoints) {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(ctx_v);
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(models.v.get());
                     } else {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(ctx_v);
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(models.v.get());
                     }
                 } break;
             case PROJECTOR_TYPE_MINICPMV:
                 {
-                    int minicpmv_version = clip_get_hparams(ctx_v)->minicpmv_version;
+                    int minicpmv_version = ctx_v->model.hparams.minicpmv_version;
                     if (minicpmv_version == 2) {
                         // minicpmv 2.5 format:
                         // <image> (overview) </image><slice><image> (slice) </image><image> (slice) </image>\n ... </slice>
@@ -798,7 +815,7 @@ struct mtmd_context {
                     } else if (minicpmv_version != 0) {
                         throw std::runtime_error(string_format("unsupported minicpmv version: %d\n", minicpmv_version));
                     }
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_MINICPMV4_6:
             case PROJECTOR_TYPE_MINICPMV4_7:
@@ -813,10 +830,10 @@ struct mtmd_context {
                     tok_row_end_trail = false; // no trailing end-of-row token
                     ov_img_first      = true;
                     use_image_id      = true;
-                    if (clip_get_projector_type(ctx_v) == PROJECTOR_TYPE_MINICPM_OCR_1_0) {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_minicpm_ocr_1_0>(ctx_v);
+                    if (ctx_v->model.proj_type == PROJECTOR_TYPE_MINICPM_OCR_1_0) {
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_minicpm_ocr_1_0>(models.v.get());
                     } else {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_minicpmv>(ctx_v);
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_minicpmv>(models.v.get());
                     }
                 } break;
             case PROJECTOR_TYPE_QWEN3VL:
@@ -824,7 +841,7 @@ struct mtmd_context {
                     // <|vision_start|> ... (image embeddings) ... <|vision_end|>
                     img_beg = "<|vision_start|>";
                     img_end = "<|vision_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_qwen3vl>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_qwen3vl>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_QWEN2VL:
             case PROJECTOR_TYPE_QWEN25VL:
@@ -835,26 +852,26 @@ struct mtmd_context {
                     // <|vision_start|> ... (image embeddings) ... <|vision_end|>
                     img_beg = "<|vision_start|>";
                     img_end = "<|vision_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_MINIMAX_M3:
                 {
                     // ]<]start of image[>[ ... (image embeddings) ... ]<]end of image[>[
                     img_beg = "]<]start of image[>[";
                     img_end = "]<]end of image[>[";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_SENSENOVA_U1:
                 {
                     img_beg = "<img>";
                     img_end = "</img>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_MUSE_GLIMMER:
                 {
                     img_beg = "<|image_start|>";
                     img_end = "<|image_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_muse_glimmer>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_muse_glimmer>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_LFM3VL:
                 {
@@ -863,14 +880,14 @@ struct mtmd_context {
                     img_end = "<|vision_end|>";
                     vid_beg = img_beg;
                     vid_end = img_end;
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm3vl>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm3vl>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_YOUTUVL:
                 {
                     // <|vision_start|> ... (image embeddings) ... <|vision_end|>
                     img_beg = "<|vision_start|>";
                     img_end = "<|vision_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_youtuvl>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_youtuvl>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_YASA2:
                 {
@@ -883,7 +900,7 @@ struct mtmd_context {
                     // images. This will be implemented in a future PR (dispatch on has_pinpoints
                     // - see LDP/COGVLM branch above) and emit image_grid_pinpoints in the conversion
                     // script.
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_GEMMA3:
             case PROJECTOR_TYPE_GEMMA3NV:
@@ -891,7 +908,7 @@ struct mtmd_context {
                     // <start_of_image> ... (image embeddings) ... <end_of_image>
                     img_beg = "<start_of_image>";
                     img_end = "<end_of_image>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_IDEFICS3:
                 {
@@ -901,22 +918,22 @@ struct mtmd_context {
                     tok_ov_img_end     = {lookup_token("<fake_token_around_image>")};
                     tok_row_end        = {lookup_token("\n")};
                     sli_img_start_tmpl = "<fake_token_around_image><row_%d_col_%d>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_idefics3>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_idefics3>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_PIXTRAL:
                 {
                     // https://github.com/huggingface/transformers/blob/1cd110c6cb6a6237614130c470e9a902dbc1a4bd/docs/source/en/model_doc/pixtral.md
                     img_end = "[IMG_END]";
-                    if (clip_get_hparams(ctx_v)->image_longest_edge > 0) {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_longest_edge>(ctx_v);
+                    if (ctx_v->model.hparams.image_longest_edge > 0) {
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_longest_edge>(models.v.get());
                     } else {
-                        image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                        image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                     }
                 } break;
             case PROJECTOR_TYPE_PHI4:
                 {
                     // Phi-4 uses media marker insertion only. Keep image boundary text empty.
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_LLAMA4:
                 {
@@ -925,7 +942,7 @@ struct mtmd_context {
                     img_end = "<|image_end|>";
                     LOG_WRN("%s: llama 4 vision is known to have degraded quality:\n"
                             "    https://github.com/ggml-org/llama.cpp/pull/13282\n", __func__);
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_llava_uhd>(models.v.get());
                     ov_img_first = false;
                 } break;
             case PROJECTOR_TYPE_STEP3VL:
@@ -942,14 +959,14 @@ struct mtmd_context {
                     tok_row_end       = {lookup_token("<patch_newline>")};
                     tok_row_end_trail = false;
                     ov_img_first      = false; // patches first, overview last
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_step3vl>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_step3vl>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_INTERNVL:
                 {
                     // <img> ... (image embeddings) ... </img>
                     img_beg = "<img>";
                     img_end = "</img>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_internvl>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_internvl>(models.v.get());
                     ov_img_first = false;
                 } break;
             case PROJECTOR_TYPE_COHERE2V:
@@ -960,14 +977,14 @@ struct mtmd_context {
                     tok_sli_img_end = {lookup_token("<|IMG_LINE_BREAK|>")};
                     tok_ov_img_end  = tok_sli_img_end;
                     ov_img_first = false;
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_cohere2v>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_cohere2v>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_KIMIVL:
                 {
                     // <|media_start|> ... (image embeddings) ... <|media_end|>
                     img_beg = "<|media_start|>";
                     img_end = "<|media_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_KIMIK25:
                 {
@@ -982,24 +999,24 @@ struct mtmd_context {
                         img_beg = "<|media_begin|>";
                         img_end = "<|media_end|>";
                     }
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_LIGHTONOCR:
                 {
                     // <|im_start|> ... (image embeddings) ... <|im_end|>
                     img_beg = "<|im_start|>";
                     img_end = "<|im_end|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_longest_edge>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_longest_edge>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_DEEPSEEK4V:
                 {
                     // no vocab tokens are added; the start/end/newline markers are learned embeddings emitted by the encoder
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseek4v>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseek4v>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_DEEPSEEK41V:
                 {
                     // as deepseek4v: markers come from the encoder, the block is decoded causally
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseek41v>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseek41v>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_DOTS_OCR:
             case PROJECTOR_TYPE_DOTS3NOTE_V:
@@ -1007,17 +1024,17 @@ struct mtmd_context {
                     // <|img|> ... (image embeddings) ... <|endofimg|>
                     img_beg = "<|img|>";
                     img_end = "<|endofimg|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_NEMOTRON_V2_VL:
                 {
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_fixed_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_NEMOTRON_OMNI:
                 {
                     img_beg = "<img>";
                     img_end = "</img>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_LFM2:
                 {
@@ -1034,33 +1051,33 @@ struct mtmd_context {
                     sli_img_start_tmpl = "<|img_row_%d_col_%d|>";
                     tok_ov_img_start   = {lookup_token("<|img_thumbnail|>")};
                     ov_img_first       = false;
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm2>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm2>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_D1OMNI_V:
                 {
                     // same tiles and thumbnail as lfm2, without separator tokens
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm2>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_lfm2>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_GLM4V:
                 {
                     // <|begin_of_image|> ... (image embeddings) ... <|end_of_image|>
                     img_beg = "<|begin_of_image|>";
                     img_end = "<|end_of_image|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_GLM5V:
                 {
                     // <|begin_of_image|> ... (image embeddings) ... <|end_of_image|>
                     img_beg = "<|begin_of_image|>";
                     img_end = "<|end_of_image|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_glm5v>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_glm5v>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_PADDLEOCR:
                 {
                     // <|IMAGE_START|> ... (image embeddings) ... <|IMAGE_END|>
                     img_beg = "<|IMAGE_START|>";
                     img_end = "<|IMAGE_END|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_GEMMA4V:
             case PROJECTOR_TYPE_GEMMA4UV:
@@ -1068,13 +1085,13 @@ struct mtmd_context {
                     // <|image> ... (image embeddings) ... <image|>
                     img_beg = "<|image>";
                     img_end = "<image|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_DEEPSEEKOCR:
             case PROJECTOR_TYPE_DEEPSEEKOCR2:
                 {
                     img_end = "\n"; // prevent empty batch on llama-server
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseekocr>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_deepseekocr>(models.v.get());
                     ov_img_first = false;
                 } break;
             case PROJECTOR_TYPE_HUNYUANVL:
@@ -1082,21 +1099,21 @@ struct mtmd_context {
                     // note: these use fullwidth | (U+FF5C) and ▁ (U+2581) to match the tokenizer vocabulary
                     img_beg = "<|hy_place▁holder▁no▁100|>";
                     img_end = "<|hy_place▁holder▁no▁101|>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_EXAONE4_5:
                 {
                     // <vision> ... (image embeddings) ... </vision>
                     img_beg = "<vision>";
                     img_end = "</vision>";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_dyn_size>(models.v.get());
                 } break;
             case PROJECTOR_TYPE_GRANITE4_VISION:
                 {
                     // ... (image embeddings) \n ...
                     img_beg = "";
                     img_end = "\n";
-                    image_preproc = std::make_unique<mtmd_image_preprocessor_granite>(ctx_v);
+                    image_preproc = std::make_unique<mtmd_image_preprocessor_granite>(models.v.get());
                 } break;
             default:
                 throw std::runtime_error(string_format("%s: unexpected vision projector type %d\n", __func__, proj));
@@ -1109,7 +1126,7 @@ struct mtmd_context {
         GGML_ASSERT(ctx_a != nullptr);
         audio_preproc.reset();

-        projector_type proj = clip_get_projector_type(ctx_a);
+        projector_type proj = ctx_a->model.proj_type;

         LOG_WRN("%s: audio input is in experimental stage and may have reduced quality:\n"
                 "    https://github.com/ggml-org/llama.cpp/discussions/13759\n", __func__);
@@ -1122,31 +1139,31 @@ struct mtmd_context {
                     // <|audio_bos|> ... (embeddings) ... <|audio_eos|>
                     aud_beg = "<|audio_bos|>";
                     aud_end = "<|audio_eos|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
                 {
                     aud_beg = "<|soft_audio_start|>";
                     aud_end = "<|soft_audio_end|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_muse_spark_audio>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_muse_spark_audio>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_QWEN3A:
                 {
                     aud_beg = "<|audio_start|>";
                     aud_end = "<|audio_end|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_qwen3a>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_qwen3a>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_VOXTRAL:
                 {
                     // [BEGIN_AUDIO] ... (embeddings) ...
                     aud_beg = "[BEGIN_AUDIO]";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_MUSIC_FLAMINGO:
                 {
                     // <sound> ... (embeddings) ...
                     aud_beg = "<sound>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_VOXTRAL_RT:
                 {
@@ -1156,15 +1173,15 @@ struct mtmd_context {
             case PROJECTOR_TYPE_GLMA:
             case PROJECTOR_TYPE_MERALION:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_whisper>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_LFM2A:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_conformer>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_conformer>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_D1OMNI_A:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_d1omni>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_d1omni>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_LFM3A:
                 {
@@ -1173,56 +1190,56 @@ struct mtmd_context {
                         aud_beg = img_beg;
                         aud_end = img_end;
                     }
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_lfm3a>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_lfm3a>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_GRANITE_SPEECH:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_granite_speech>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_granite_speech>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_GEMMA4A:
                 {
                     aud_beg = "<|audio>";
                     aud_end = "<audio|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_gemma4a>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_gemma4a>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_PARAKEET:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_parakeet>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_parakeet>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_NEMOTRON_TRANSCRIBE:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_nemotron_transcribe>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_nemotron_transcribe>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_GEMMA4UA:
                 {
                     aud_beg = "<|audio>";
                     aud_end = "<audio|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_gemma4ua>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_gemma4ua>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_DOTS3NOTE_A:
                 {
                     // <|audio_comp_start|> ... (embeddings) ... <|audio_comp_end|>
                     aud_beg = "<|audio_comp_start|>";
                     aud_end = "<|audio_comp_end|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_dots3note>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_dots3note>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_MIMO_AUDIO:
                 {
                     aud_beg = "<|mimo_audio_start|>";
                     aud_end = "<|mimo_audio_end|>";
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_mimo_audio>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_mimo_audio>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_qwen3tts_spk>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_qwen3tts_spk>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_POCKETTTS_SPKENC:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_pockettts>(ctx_a);
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_pockettts>(models.a.get());
                 } break;
             case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
                 {
-                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_chatterbox_ref>(ctx_a, cbx_is_mtl(ctx_a));
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_chatterbox_ref>(models.a.get(), cbx_is_mtl(*models.a));
                 } break;
             default:
                 throw std::runtime_error(string_format("%s: unexpected audio projector type %d\n", __func__, proj));
@@ -1231,26 +1248,27 @@ struct mtmd_context {
         // initialize audio preprocessor
         if (audio_preproc) {
             audio_preproc->mel_config.n_threads = n_threads;
+            audio_preproc->mel_config.sched     = ctx_a->sched.get();
             audio_preproc->initialize();
         }
     }

-    // get clip ctx based on chunk type
-    clip_ctx * get_clip_ctx(const mtmd_input_chunk * chunk) const {
+    // get the subcontext based on chunk type
+    mtmd_subcontext * get_subctx(const mtmd_input_chunk * chunk) const {
         if (chunk->type == MTMD_INPUT_CHUNK_TYPE_IMAGE) {
-            return ctx_v;
+            return ctx_v.get();
         } else if (chunk->type == MTMD_INPUT_CHUNK_TYPE_AUDIO) {
-            return ctx_a;
+            return ctx_a.get();
         }
         GGML_ABORT("unknown chunk type");
     }

     projector_type proj_type_v() const {
-        return ctx_v ? clip_get_projector_type(ctx_v) : PROJECTOR_TYPE_UNKNOWN;
+        return ctx_v ? ctx_v->model.proj_type : PROJECTOR_TYPE_UNKNOWN;
     }

     projector_type proj_type_a() const {
-        return ctx_a ? clip_get_projector_type(ctx_a) : PROJECTOR_TYPE_UNKNOWN;
+        return ctx_a ? ctx_a->model.proj_type : PROJECTOR_TYPE_UNKNOWN;
     }

     // [LFM3_OMNI] the audio of a video is its soundtrack, see MTMD_VIDEO_TEMPLATE_LFM3_OMNI
@@ -1260,20 +1278,14 @@ struct mtmd_context {

     int64_t n_embd_out() const {
         if (ctx_v) {
-            return clip_n_mmproj_embd(ctx_v);
+            return ctx_v->model.n_mmproj_embd();
         } else if (ctx_a) {
-            return clip_n_mmproj_embd(ctx_a);
+            return ctx_a->model.n_mmproj_embd();
         } else {
-            throw std::runtime_error("no CLIP model loaded");
+            throw std::runtime_error("no mmproj model loaded");
         }
     }

-    ~mtmd_context() {
-        clip_free(ctx_a);
-        clip_free(ctx_v);
-        clip_free(ctx_gen_a);
-    }
-
 private:
     llama_token lookup_token(const std::string & token_text) {
         if (vocab == nullptr) {
@@ -1498,7 +1510,7 @@ struct mtmd_tokenizer {
         // [QWEN_VIDEO] handle frame merging for models that support it (i.e. qwen-vl)
         int n_merge_frames = 1;
         if (ctx->ctx_v) {
-            n_merge_frames = clip_model_n_temporal_merge(ctx->ctx_v);
+            n_merge_frames = ctx->ctx_v->model.n_temporal_merge();
             GGML_ASSERT(n_merge_frames <= 2 && "we only support merging maximum 2 images for now; open an issue if this model supports merging more");
         }

@@ -1624,8 +1636,8 @@ struct mtmd_tokenizer {
                     return 2;
                 }

-                // convert mtmd_bitmap to clip_image_u8
-                clip_image_u8 img_u8;
+                // convert mtmd_bitmap to mtmd_image_u8
+                mtmd_image_u8 img_u8;
                 img_u8.set_size(
                     {(int)bmp->nx, (int)bmp->ny},
                     bmp->is_placeholder());
@@ -1667,7 +1679,7 @@ struct mtmd_tokenizer {
                     add_text("<image_id>" + std::to_string(n_images_added) + "</image_id>", true);
                 }
                 add_text(ctx->tok_ov_img_start);
-                // the separators after <image> are appended by clip, see add_canvas_chunk()
+                // the separators after <image> are appended by the graph, see add_canvas_chunk()
                 add_canvas_chunk(std::move(preproc_out), bitmaps[0]->id);
             } else if (has_tiling_grid) {
                 // [QWEN_VIDEO] we do not support "frame merging" for llama-uhd style, so no batching for now
@@ -1762,8 +1774,8 @@ struct mtmd_tokenizer {

                 size_t n_tokens = 0;
                 for (auto & e : preproc_out.entries) {
-                    n_tokens += clip_n_output_tokens(ctx->ctx_v, &e);
-                    if (clip_model_n_temporal_merge(ctx->ctx_v) == 2) {
+                    n_tokens += ctx->ctx_v->model.n_output_tokens(&e);
+                    if (ctx->ctx_v->model.n_temporal_merge() == 2) {
                         // [QWEN_VIDEO] pair input is merged to the same embd, so only count as one image
                         break;
                     }
@@ -1772,12 +1784,12 @@ struct mtmd_tokenizer {
                 mtmd_image_tokens_ptr image_tokens(new mtmd_image_tokens);

                 // [QWEN_VIDEO] improve this in the future
-                image_tokens->n_temporal_merge = clip_model_n_temporal_merge(ctx->ctx_v);
+                image_tokens->n_temporal_merge = ctx->ctx_v->model.n_temporal_merge();

                 if (mtmd_decode_use_mrope(ctx)) {
                     // for Qwen2VL, we need this information for M-RoPE decoding positions
-                    image_tokens->nx = clip_n_output_tokens_x(ctx->ctx_v, &preproc_out.entries[0]);
-                    image_tokens->ny = clip_n_output_tokens_y(ctx->ctx_v, &preproc_out.entries[0]);
+                    image_tokens->nx = ctx->ctx_v->model.n_output_tokens_x(&preproc_out.entries[0]);
+                    image_tokens->ny = ctx->ctx_v->model.n_output_tokens_y(&preproc_out.entries[0]);
                 } else {
                     // other models, we only need the total number of tokens
                     image_tokens->nx = n_tokens;
@@ -1798,7 +1810,7 @@ struct mtmd_tokenizer {
                     GGML_ASSERT(n_tokens == (size_t)image_tokens->n_tokens());
                 }

-                clip_image_f32_batch batch_f32;
+                mtmd_image_f32_batch batch_f32;
                 batch_f32.is_audio = false;
                 batch_f32.is_video = bitmaps[0]->is_video_frame();
                 batch_f32.entries = std::move(preproc_out.entries);
@@ -1887,13 +1899,13 @@ struct mtmd_tokenizer {
             }

             // one chunk for the five entries of a chatterbox clip, one token per talker conditioning row
-            if (clip_get_projector_type(ctx->ctx_a) == PROJECTOR_TYPE_CHATTERBOX_SPKENC) {
+            if (ctx->ctx_a->model.proj_type == PROJECTOR_TYPE_CHATTERBOX_SPKENC) {
                 GGML_ASSERT(source->entries.size() == 5);
                 // mtl: spkr, perceiver query rows, emotion; turbo: spkr, t3 speech tokens
-                const size_t n_query  = clip_cbx_read_tensor(ctx->ctx_a, "a.cenc.perceiver.pre_attention_query", nullptr, 0) / ctx->n_embd_out();
-                const size_t n_tokens = cbx_is_mtl(ctx->ctx_a) ? 2 + n_query : 1 + (size_t) source->entries[2].nx() / 4;
+                const size_t n_query  = ctx->ctx_a->model.cbx_read_tensor("a.cenc.perceiver.pre_attention_query", nullptr, 0) / ctx->n_embd_out();
+                const size_t n_tokens = cbx_is_mtl(ctx->ctx_a->model) ? 2 + n_query : 1 + (size_t) source->entries[2].nx() / 4;

-                clip_image_f32_batch batch_f32;
+                mtmd_image_f32_batch batch_f32;
                 batch_f32.is_audio = true;
                 batch_f32.entries  = std::move(source->entries);

@@ -1913,11 +1925,11 @@ struct mtmd_tokenizer {
             // consider each mel_spec as a separate audio chunk
             // TODO: maybe support batching, but this may come with memory cost
             for (size_t i = 0; i < source->entries.size(); i++) {
-                clip_image_f32 mel_f32 = source->entries[i];
+                mtmd_image_f32 mel_f32 = source->entries[i];

-                size_t n_tokens = clip_n_output_tokens(ctx->ctx_a, &mel_f32);
+                size_t n_tokens = ctx->ctx_a->model.n_output_tokens(&mel_f32);

-                clip_image_f32_batch batch_f32;
+                mtmd_image_f32_batch batch_f32;
                 batch_f32.is_audio = true;
                 batch_f32.entries.push_back(std::move(mel_f32));

@@ -1995,9 +2007,9 @@ struct mtmd_tokenizer {
                 LOG_ERR("%s: unable to preprocess the soundtrack\n", __func__);
                 return 2;
             }
-            clip_image_f32 mel_f32 = source->entries[0];
+            mtmd_image_f32 mel_f32 = source->entries[0];
             st.track = std::make_shared<mtmd_audio_track>();
-            st.track->n_tokens = clip_n_output_tokens(ctx->ctx_a, &mel_f32);
+            st.track->n_tokens = ctx->ctx_a->model.n_output_tokens(&mel_f32);
             if (source->needs_compute()) {
                 st.track->source = source;
             }
@@ -2006,7 +2018,7 @@ struct mtmd_tokenizer {
         }

         // rows of [begin, end): round(sample / samples per row), the last piece takes the rest of the track
-        const double samples_per_row = 8.0 * clip_get_hparams(ctx->ctx_a)->audio_hop_len; // one row per 8 mel frames
+        const double samples_per_row = 8.0 * ctx->ctx_a->model.hparams.audio_hop_len; // one row per 8 mel frames
         const uint32_t n_tokens = st.track->n_tokens;
         st.n_samples += bitmap.nx;
         const uint32_t begin = st.n_assigned;
@@ -2031,7 +2043,7 @@ struct mtmd_tokenizer {
         return 0;
     }

-    // MiniCPM-V 4.7: the overview and all slices go in one chunk, clip appends the separators after each tile:
+    // MiniCPM-V 4.7: the overview and all slices go in one chunk, the graph appends the separators after each tile:
     //   [ov] </image><slice> [S00] </slice><slice> [S01] </slice>\n<slice> [S10] </slice><slice> [S11] </slice>
     void add_canvas_chunk(mtmd_image_preproc_out && preproc_out, const std::string & id) {
         const int n_col = preproc_out.grid_x;
@@ -2041,9 +2053,9 @@ struct mtmd_tokenizer {
         GGML_ASSERT((int) slices.size() == n_col * n_row);

         auto & ov = preproc_out.overview;
-        ov.suffix_type = CLIP_SUFFIX_MINICPMV_OV;
+        ov.suffix_type = MTMD_SUFFIX_MINICPMV_OV;
         if (!slices.empty()) {
-            ov.suffix_type = CLIP_SUFFIX_MINICPMV_OV_SLICE;
+            ov.suffix_type = MTMD_SUFFIX_MINICPMV_OV_SLICE;
             ov.anyres.grid_x = n_col;
             ov.anyres.grid_y = n_row;
         }
@@ -2051,11 +2063,11 @@ struct mtmd_tokenizer {
             for (int x = 0; x < n_col; x++) {
                 auto & suffix = slices[y * n_col + x].suffix_type;
                 if (y == n_row - 1 && x == n_col - 1) {
-                    suffix = CLIP_SUFFIX_MINICPMV_LAST;
+                    suffix = MTMD_SUFFIX_MINICPMV_LAST;
                 } else if (x == n_col - 1) {
-                    suffix = CLIP_SUFFIX_MINICPMV_ROW_END;
+                    suffix = MTMD_SUFFIX_MINICPMV_ROW_END;
                 } else {
-                    suffix = CLIP_SUFFIX_MINICPMV_SLICE;
+                    suffix = MTMD_SUFFIX_MINICPMV_SLICE;
                 }
             }
         }
@@ -2069,12 +2081,12 @@ struct mtmd_tokenizer {
             entries.push_back(std::move(slice));
         }
         // token grid of the last entry, the grids of the other entries are scaled from it
-        image_tokens->nx = clip_n_output_tokens_x(ctx->ctx_v, &entries.back());
-        image_tokens->ny = clip_n_output_tokens_y(ctx->ctx_v, &entries.back());
+        image_tokens->nx = ctx->ctx_v->model.n_output_tokens_x(&entries.back());
+        image_tokens->ny = ctx->ctx_v->model.n_output_tokens_y(&entries.back());

         size_t n_tokens = 0;
         for (const auto & entry : entries) {
-            n_tokens += clip_n_output_tokens(ctx->ctx_v, &entry);
+            n_tokens += ctx->ctx_v->model.n_output_tokens(&entry);
         }
         GGML_ASSERT(n_tokens == image_tokens->n_tokens());

@@ -2090,9 +2102,9 @@ struct mtmd_tokenizer {
     std::vector<mtmd_input_chunk> split_batch_to_chunk(mtmd_image_preproc_out && preproc_out, const std::string & id) {
         std::vector<mtmd_input_chunk> chunks;

-        auto process_chunk = [&](clip_image_f32 && img) {
+        auto process_chunk = [&](mtmd_image_f32 && img) {
             mtmd_image_tokens_ptr image_tokens(new mtmd_image_tokens);
-            image_tokens->nx = clip_n_output_tokens(ctx->ctx_v, &img);
+            image_tokens->nx = ctx->ctx_v->model.n_output_tokens(&img);
             image_tokens->ny = 1;
             image_tokens->batch_f32.entries.push_back(std::move(img));
             image_tokens->id = id;
@@ -2213,8 +2225,8 @@ int32_t mtmd_tokenize_from_parts(const mtmd_context * ctx,
 }

 static int32_t mtmd_encode_impl(mtmd_context * ctx, const mtmd_image_tokens * image_tokens, std::vector<float> & out_embd) {
-    clip_ctx * ctx_clip = ctx->ctx_v;
-    if (!ctx_clip) {
+    mtmd_subcontext * sctx = ctx->ctx_v.get();
+    if (!sctx) {
         LOG_ERR("%s: this API does not support non-vision input, please use mtmd_encode_chunk instead\n", __func__);
         return 1;
     }
@@ -2232,10 +2244,10 @@ static int32_t mtmd_encode_impl(mtmd_context * ctx, const mtmd_image_tokens * im
         // the tiles differ in size, encode them one by one
         size_t offset = 0;
         for (const auto & entry : image_tokens->batch_f32.entries) {
-            clip_image_f32_batch one;
+            mtmd_image_f32_batch one;
             one.entries.push_back(entry);
-            std::vector<float> embd((size_t) n_embd_out * clip_n_output_tokens(ctx_clip, &entry));
-            if (!clip_image_batch_encode(ctx_clip, ctx->n_threads, &one, embd)) {
+            std::vector<float> embd((size_t) n_embd_out * sctx->model.n_output_tokens(&entry));
+            if (!sctx->encode_batch(ctx->n_threads, &one, embd)) {
                 return 1;
             }
             GGML_ASSERT(offset + embd.size() <= out_embd.size());
@@ -2246,9 +2258,7 @@ static int32_t mtmd_encode_impl(mtmd_context * ctx, const mtmd_image_tokens * im
         return 0;
     }

-    bool ok = clip_image_batch_encode(
-        ctx_clip,
-        ctx->n_threads,
+    bool ok = sctx->encode_batch(ctx->n_threads,
         &image_tokens->batch_f32,
         out_embd);

@@ -2257,8 +2267,8 @@ static int32_t mtmd_encode_impl(mtmd_context * ctx, const mtmd_image_tokens * im

 static int32_t cbx_ref_encode(mtmd_context * ctx, const mtmd_audio_tokens * audio_tokens, std::vector<float> & out_embd);

-static bool mtmd_audio_batch_encode(mtmd_context * ctx, const clip_image_f32_batch & batch_f32, mtmd_audio_preproc_out * source, uint32_t idx, std::vector<float> & out_embd) {
-    clip_image_f32_batch batch = batch_f32.clone();
+static bool mtmd_audio_batch_encode(mtmd_context * ctx, const mtmd_image_f32_batch & batch_f32, mtmd_audio_preproc_out * source, uint32_t idx, std::vector<float> & out_embd) {
+    mtmd_image_f32_batch batch = batch_f32.clone();
     if (source) {
         if (!ctx->audio_preproc->compute(*source)) {
             LOG_ERR("%s: unable to compute the mel\n", __func__);
@@ -2267,7 +2277,7 @@ static bool mtmd_audio_batch_encode(mtmd_context * ctx, const clip_image_f32_bat
         GGML_ASSERT(idx < source->entries.size());
         batch.entries[0] = source->entries[idx];
     }
-    return clip_image_batch_encode(ctx->ctx_a, ctx->n_threads, &batch, out_embd);
+    return ctx->ctx_a->encode_batch(ctx->n_threads, &batch, out_embd);
 }

 static int32_t mtmd_encode_chunk_impl(mtmd_context * ctx, const mtmd_input_chunk * chunk, std::vector<float> & out_embd) {
@@ -2321,7 +2331,7 @@ static int32_t mtmd_encode_chunk_impl(mtmd_context * ctx, const mtmd_input_chunk
             }
             return 0;
         }
-        if (clip_get_projector_type(ctx->ctx_a) == PROJECTOR_TYPE_CHATTERBOX_SPKENC) {
+        if (ctx->ctx_a->model.proj_type == PROJECTOR_TYPE_CHATTERBOX_SPKENC) {
             return cbx_ref_encode(ctx, chunk->tokens_audio.get(), out_embd);
         }
         out_embd.resize((size_t)chunk->tokens_audio->n_tokens * ctx->n_embd_out());
@@ -2367,9 +2377,9 @@ mtmd_gen_audio_info mtmd_gen_audio_get_info(const mtmd_context * ctx) {
         info.type = MTMD_GEN_AUDIO_TYPE_NONE;
         return info;
     }
-    info.model_variant = clip_get_hparams(ctx->ctx_gen_a)->gen_model_variant.c_str();
-    info.n_voices      = (int32_t) clip_gen_get_voices(ctx->ctx_gen_a).size();
-    switch (clip_get_projector_type(ctx->ctx_gen_a)) {
+    info.model_variant = ctx->ctx_gen_a->model.hparams.gen_model_variant.c_str();
+    info.n_voices      = (int32_t) ctx->ctx_gen_a->model.gen_voices.size();
+    switch (ctx->ctx_gen_a->model.proj_type) {
         case PROJECTOR_TYPE_QWEN3TTS_GEN:
             info.type = MTMD_GEN_AUDIO_TYPE_QWEN3TTS;
             info.sample_rate = 24000;
@@ -2405,19 +2415,19 @@ size_t mtmd_gen_audio_read_tensor(mtmd_context * ctx, const char * name, float *
     if (!ctx->ctx_gen_a) {
         return 0;
     }
-    return clip_cbx_read_tensor(ctx->ctx_gen_a, name, out, n_max);
+    return ctx->ctx_gen_a->model.cbx_read_tensor(name, out, n_max);
 }

 // raw 192-dim CAMPPlus x-vector of the fbank entry
-static bool cbx_ref_xvec(mtmd_context * ctx, const clip_image_f32 & fbank, std::vector<float> & xvec) {
-    clip_image_f32 mel_img;
+static bool cbx_ref_xvec(mtmd_context * ctx, const mtmd_image_f32 & fbank, std::vector<float> & xvec) {
+    mtmd_image_f32 mel_img;
     mel_img.set_size(fbank.get_size(), false, true);
     mel_img.cpy_buf(fbank.get_ro_buf());
-    clip_image_f32_batch batch;
+    mtmd_image_f32_batch batch;
     batch.is_audio = true;
     batch.entries.push_back(std::move(mel_img));
     xvec.resize(192);
-    if (!clip_image_batch_encode(ctx->ctx_a, ctx->n_threads, &batch, xvec)) {
+    if (!ctx->ctx_a->encode_batch(ctx->n_threads, &batch, xvec)) {
         LOG_ERR("%s: speaker encoder failed\n", __func__);
         return false;
     }
@@ -2425,28 +2435,28 @@ static bool cbx_ref_xvec(mtmd_context * ctx, const clip_image_f32 & fbank, std::
 }

 // s3 speech tokens of a log-mel entry, rows optionally receives their speech embedding rows
-static int32_t cbx_ref_tokenize(mtmd_context * ctx, const clip_image_f32 & mel,
+static int32_t cbx_ref_tokenize(mtmd_context * ctx, const mtmd_image_f32 & mel,
                             std::vector<int32_t> & codes, std::vector<float> * rows) {
-    clip_image_f32 mel_img;
+    mtmd_image_f32 mel_img;
     mel_img.set_size(mel.get_size(), false, true);
     mel_img.cpy_buf(mel.get_ro_buf());

-    clip_image_f32_batch batch;
+    mtmd_image_f32_batch batch;
     batch.is_audio = true;
     batch.entries.push_back(std::move(mel_img));

     std::vector<int32_t> out_codes;
     std::vector<float>   out_embd;

-    clip_encode_params params;
+    mtmd_subctx_encode_params params;
     params.imgs        = &batch;
     params.n_threads   = ctx->n_threads;
-    params.gen_process = CLIP_GEN_PROCESS_TOKENIZE;
+    params.gen_process = MTMD_SUBCTX_GEN_PROCESS_TOKENIZE;
     params.out_codes     = &out_codes;
     params.out_code_embd = rows ? &out_embd : nullptr;

-    if (!clip_encode(ctx->ctx_gen_a, &params)) {
-        LOG_ERR("%s: clip_encode failed (tokenize)\n", __func__);
+    if (!ctx->ctx_gen_a->encode(&params)) {
+        LOG_ERR("%s: encode failed (tokenize)\n", __func__);
         return 1;
     }

@@ -2458,17 +2468,17 @@ static int32_t cbx_ref_tokenize(mtmd_context * ctx, const clip_image_f32 & mel,
 }

 // talker conditioning rows: the speaker row, then on mtl the perceiver block and the emotion row
-static int32_t cbx_ref_cond(mtmd_context * ctx, const clip_image_f32 & ve_mel,
+static int32_t cbx_ref_cond(mtmd_context * ctx, const mtmd_image_f32 & ve_mel,
                         const std::vector<float> & pse, std::vector<float> & out_rows) {
-    clip_ctx * ctx_clip = ctx->ctx_a;
+    mtmd_subcontext * sctx = ctx->ctx_a.get();
     // n_expect 0 accepts any non-empty size
     auto read_t = [&](const char * name, std::vector<float> & v, size_t n_expect) -> bool {
-        const size_t n = clip_cbx_read_tensor(ctx_clip, name, nullptr, 0);
+        const size_t n = sctx->model.cbx_read_tensor(name, nullptr, 0);
         if (n == 0 || (n_expect > 0 && n != n_expect)) {
             return false;
         }
         v.resize(n);
-        return clip_cbx_read_tensor(ctx_clip, name, v.data(), n) == n;
+        return sctx->model.cbx_read_tensor(name, v.data(), n) == n;
     };

     // voice encoder (embeds_from_wavs): 3-layer lstm per 160-frame partial, the normalized partial embeddings are averaged
@@ -2725,7 +2735,7 @@ static int32_t cbx_ref_encode(mtmd_context * ctx, const mtmd_audio_tokens * audi
     }

     // [spkr, perceiver block] on mtl, [spkr, reference speech rows] on turbo
-    const bool mtl = cbx_is_mtl(ctx->ctx_a);
+    const bool mtl = cbx_is_mtl(ctx->ctx_a->model);
     std::vector<float> pse;
     if (mtl) {
         pse = std::move(t3_rows);
@@ -2754,7 +2764,7 @@ const char * mtmd_gen_audio_voice_name(const mtmd_context * ctx, int32_t idx) {
     if (!ctx->ctx_gen_a || idx < 0) {
         return nullptr;
     }
-    const auto & voices = clip_gen_get_voices(ctx->ctx_gen_a);
+    const auto & voices = ctx->ctx_gen_a->model.gen_voices;
     return idx < (int32_t) voices.size() ? std::next(voices.begin(), idx)->first.c_str() : nullptr;
 }

@@ -2763,7 +2773,7 @@ mtmd_bitmap * mtmd_gen_audio_get_voice(const mtmd_context * ctx, const char * na
         return nullptr;
     }
     // the name is matched case-insensitively, as the lfm3a "tts" chat template lowers it
-    const auto & voices = clip_gen_get_voices(ctx->ctx_gen_a);
+    const auto & voices = ctx->ctx_gen_a->model.gen_voices;
     const auto it = std::find_if(voices.begin(), voices.end(), [name](const auto & voice) {
         return std::equal(voice.first.begin(), voice.first.end(), name, name + strlen(name),
                           [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); });
@@ -2771,7 +2781,7 @@ mtmd_bitmap * mtmd_gen_audio_get_voice(const mtmd_context * ctx, const char * na
     if (it == voices.end()) {
         return nullptr;
     }
-    return mtmd_bitmap::init_voice(it->first, it->second, clip_n_mmproj_embd(ctx->ctx_gen_a));
+    return mtmd_bitmap::init_voice(it->first, it->second, ctx->ctx_gen_a->model.n_mmproj_embd());
 }

 mtmd_gen_inp mtmd_gen_inp_default(const mtmd_context * ctx) {
@@ -2781,7 +2791,7 @@ mtmd_gen_inp mtmd_gen_inp_default(const mtmd_context * ctx) {
         return inp;
     }

-    switch (clip_get_projector_type(ctx->ctx_gen_a)) {
+    switch (ctx->ctx_gen_a->model.proj_type) {
         case PROJECTOR_TYPE_QWEN3TTS_GEN:
             // https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base/blob/main/generation_config.json
             inp.top_k = 50;
@@ -2806,7 +2816,7 @@ mtmd_gen_inp mtmd_gen_inp_default(const mtmd_context * ctx) {
         case PROJECTOR_TYPE_LFM3A_GEN:
             {
                 // MaskGIT sampling runs in the helper, with the settings of the checkpoint
-                const auto * hparams = clip_get_hparams(ctx->ctx_gen_a);
+                const auto * hparams = &ctx->ctx_gen_a->model.hparams;
                 inp.top_k          = hparams->gen_top_k;
                 inp.top_p          = 1.0f;
                 inp.temp           = hparams->gen_temp;
@@ -2820,8 +2830,8 @@ mtmd_gen_inp mtmd_gen_inp_default(const mtmd_context * ctx) {
 }

 struct mtmd_gen_state {
-    clip_ctx * ctx_clip = nullptr;
-    clip_gen_state * clip_state = nullptr;
+    mtmd_subcontext * sctx = nullptr;
+    mtmd_subctx_gen_state * gen_state = nullptr;

     std::vector<int32_t> out_codes; // this frame's 16 sampled codes (GEN_CODE)
     std::vector<float>   out_feats; // this frame's continuous features, if any (GEN_CODE)
@@ -2829,13 +2839,13 @@ struct mtmd_gen_state {
     std::vector<float>   out_audio; // decoded PCM samples for the current frame (GEN_WAV)

     // voice reference for GEN_WAV, from a speaker clip or a predefined voice (chatterbox)
-    clip_gen_ref ref;
+    mtmd_gen_ref ref;

     // lfm3a: the codec graph yields STFT frames, the ISTFT runs on the host
     std::unique_ptr<mtmd_audio_streaming_istft> istft;

     ~mtmd_gen_state() {
-        clip_gen_state_free(clip_state);
+        delete gen_state;
     }
 };

@@ -2851,13 +2861,13 @@ mtmd_gen_state * mtmd_gen_state_init(mtmd_context * ctx, mtmd_gen_state_params p
         return nullptr;
     }
     auto st = std::make_unique<mtmd_gen_state>();
-    st->ctx_clip   = ctx->ctx_gen_a;
-    st->clip_state = clip_gen_state_init(ctx->ctx_gen_a, params.seed);
-    if (!st->clip_state) {
+    st->sctx   = ctx->ctx_gen_a.get();
+    st->gen_state = ctx->ctx_gen_a->gen_state_init(params.seed);
+    if (!st->gen_state) {
         return nullptr;
     }
-    if (clip_get_projector_type(ctx->ctx_gen_a) == PROJECTOR_TYPE_LFM3A_GEN) {
-        const auto * hparams = clip_get_hparams(ctx->ctx_gen_a);
+    if (ctx->ctx_gen_a->model.proj_type == PROJECTOR_TYPE_LFM3A_GEN) {
+        const auto * hparams = &ctx->ctx_gen_a->model.hparams;
         st->istft = std::make_unique<mtmd_audio_streaming_istft>(hparams->audio_n_fft, hparams->audio_hop_len);
     }
     return st.release();
@@ -2868,17 +2878,17 @@ void mtmd_gen_state_free(mtmd_gen_state * state) {
 }

 void mtmd_gen_state_reset(mtmd_gen_state * state) {
-    clip_gen_state_reset(state->clip_state);
+    state->gen_state->reset();
     if (state->istft) {
-        const auto * hparams = clip_get_hparams(state->ctx_clip);
+        const auto * hparams = &state->sctx->model.hparams;
         state->istft = std::make_unique<mtmd_audio_streaming_istft>(hparams->audio_n_fft, hparams->audio_hop_len);
     }
 }

 // lfm3a GEN_WAV: ISTFT of the codec output, n_codes == 0 flushes and resets the state
 static int32_t mtmd_gen_wav_lfm3a(mtmd_context * ctx, mtmd_gen_state * state, const mtmd_gen_inp * inp, mtmd_gen_out * out) {
-    clip_ctx * ctx_clip = ctx->ctx_gen_a;
-    const auto & hparams = *clip_get_hparams(ctx_clip);
+    mtmd_subcontext * sctx = ctx->ctx_gen_a.get();
+    const auto & hparams = sctx->model.hparams;

     const int n_bins = hparams.audio_n_fft / 2 + 1;
     const int n_trim = (hparams.audio_n_fft - hparams.audio_hop_len) / 2;
@@ -2896,23 +2906,23 @@ static int32_t mtmd_gen_wav_lfm3a(mtmd_context * ctx, mtmd_gen_state * state, co
         std::vector<float>   feats;

         // no hidden-state input, the batch entry is an unused placeholder
-        clip_image_f32 dummy;
+        mtmd_image_f32 dummy;
         dummy.set_size({1, 1}, false, true);
         dummy.cpy_buf(std::vector<float>(1, 0.0f));

-        clip_image_f32_batch batch;
+        mtmd_image_f32_batch batch;
         batch.is_audio = true;
         batch.entries.push_back(std::move(dummy));

-        clip_encode_params params;
+        mtmd_subctx_encode_params params;
         params.imgs        = &batch;
         params.n_threads   = ctx->n_threads;
-        params.gen_process = CLIP_GEN_PROCESS_GEN_WAV;
+        params.gen_process = MTMD_SUBCTX_GEN_PROCESS_GEN_WAV;
         params.codes       = &in_codes;
         params.out_feats   = &feats;
-        params.gen_state   = state->clip_state;
-        if (!clip_encode(ctx_clip, &params)) {
-            LOG_ERR("%s: clip_encode failed (codec)\n", __func__);
+        params.gen_state   = state->gen_state;
+        if (!sctx->encode(&params)) {
+            LOG_ERR("%s: encode failed (codec)\n", __func__);
             return 1;
         }

@@ -2938,7 +2948,7 @@ static int32_t mtmd_gen_wav_lfm3a(mtmd_context * ctx, mtmd_gen_state * state, co
 }

 int32_t mtmd_gen_state_set_ref(mtmd_context * ctx, mtmd_gen_state * state) {
-    if (!state || state->ctx_clip != ctx->ctx_gen_a) {
+    if (!state || state->sctx != ctx->ctx_gen_a.get()) {
         LOG_ERR("%s: invalid gen state\n", __func__);
         return 1;
     }
@@ -2951,35 +2961,35 @@ int32_t mtmd_gen_state_set_ref(mtmd_context * ctx, mtmd_gen_state * state) {
 }

 static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state * state, const mtmd_gen_inp * inp, mtmd_gen_out * out) {
-    clip_ctx * ctx_clip = ctx->ctx_gen_a;
-    if (!ctx_clip) {
+    mtmd_subcontext * sctx = ctx->ctx_gen_a.get();
+    if (!sctx) {
         LOG_ERR("%s: model does not support audio generation\n", __func__);
         return 1;
     }
-    if (!state || state->ctx_clip != ctx_clip) {
+    if (!state || state->sctx != sctx) {
         LOG_ERR("%s: invalid gen state\n", __func__);
         return 1;
     }

     *out = {};

-    if (clip_get_projector_type(ctx_clip) == PROJECTOR_TYPE_SOPRANO) {
-        const size_t n_embd = (size_t) clip_n_mmproj_embd(ctx_clip);
+    if (sctx->model.proj_type == PROJECTOR_TYPE_SOPRANO) {
+        const size_t n_embd = (size_t) sctx->model.n_mmproj_embd();
         if (inp->type != MTMD_GEN_PROCESS_TYPE_GEN_WAV || !inp->feats || inp->n_feats < 2 * n_embd || inp->n_feats % n_embd != 0 || inp->codes) {
             LOG_ERR("%s: soprano requires at least 2 frames of features, without codes\n", __func__);
             return 1;
         }
     }

-    if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_WAV && clip_get_projector_type(ctx_clip) == PROJECTOR_TYPE_LFM3A_GEN) {
+    if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_WAV && sctx->model.proj_type == PROJECTOR_TYPE_LFM3A_GEN) {
         return mtmd_gen_wav_lfm3a(ctx, state, inp, out);
     }

     if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_CODE) {
-        const size_t n_embd = (size_t) clip_n_mmproj_embd(ctx_clip);
+        const size_t n_embd = (size_t) sctx->model.n_mmproj_embd();

         // the hidden state may be omitted when the graph does not use it
-        clip_image_f32 hidden_state;
+        mtmd_image_f32 hidden_state;
         hidden_state.set_size({(int) n_embd, 1}, false, true);
         hidden_state.cpy_buf(inp->embd ? std::vector<float>(inp->embd, inp->embd + n_embd) : std::vector<float>(n_embd, 0.0f));

@@ -2988,7 +2998,7 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
             in_codes.assign(inp->codes, inp->codes + inp->n_codes);
         }

-        clip_image_f32_batch batch;
+        mtmd_image_f32_batch batch;
         batch.is_audio = true;
         batch.entries.push_back(std::move(hidden_state));

@@ -2997,10 +3007,10 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
         std::vector<float>   out_feats;
         bool is_eos = false;

-        clip_encode_params params;
+        mtmd_subctx_encode_params params;
         params.imgs          = &batch;
         params.n_threads     = ctx->n_threads;
-        params.gen_process   = CLIP_GEN_PROCESS_GEN_CODE;
+        params.gen_process   = MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
         params.out_embd      = &out_embd;
         params.out_codes     = &out_codes;
         params.out_feats     = &out_feats;
@@ -3010,10 +3020,10 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
         params.temp          = inp->temp;
         params.codes         = inp->codes ? &in_codes : nullptr;
         params.out_is_eos    = &is_eos;
-        params.gen_state     = state->clip_state;
+        params.gen_state     = state->gen_state;

-        if (!clip_encode(ctx_clip, &params)) {
-            LOG_ERR("%s: clip_encode failed (gen_code)\n", __func__);
+        if (!sctx->encode(&params)) {
+            LOG_ERR("%s: encode failed (gen_code)\n", __func__);
             return 1;
         }

@@ -3031,7 +3041,7 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
     }

     // MTMD_GEN_PROCESS_TYPE_GEN_WAV
-    if (clip_get_projector_type(ctx_clip) == PROJECTOR_TYPE_CHATTERBOX) {
+    if (sctx->model.proj_type == PROJECTOR_TYPE_CHATTERBOX) {
         if (!inp->codes || inp->n_codes == 0) {
             LOG_ERR("%s: codes required for gen_wav\n", __func__);
             return 1;
@@ -3043,24 +3053,24 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
         std::vector<int32_t> in_codes(inp->codes, inp->codes + inp->n_codes);

         // the batch entry is unused, present to satisfy the encode interface
-        clip_image_f32 dummy;
+        mtmd_image_f32 dummy;
         dummy.set_size({1, 1}, false, true);
         dummy.cpy_buf(std::vector<float>(1, 0.0f));
-        clip_image_f32_batch batch;
+        mtmd_image_f32_batch batch;
         batch.is_audio = true;
         batch.entries.push_back(std::move(dummy));

-        clip_encode_params params;
+        mtmd_subctx_encode_params params;
         params.imgs        = &batch;
         params.n_threads   = ctx->n_threads;
-        params.gen_process = CLIP_GEN_PROCESS_TTS;
+        params.gen_process = MTMD_SUBCTX_GEN_PROCESS_TTS;
         params.codes       = &in_codes;
         params.out_audio   = &state->out_audio;
         params.ref         = &state->ref;
-        params.gen_state   = state->clip_state;
+        params.gen_state   = state->gen_state;

-        if (!clip_encode(ctx_clip, &params)) {
-            LOG_ERR("%s: clip_encode failed (gen_wav)\n", __func__);
+        if (!sctx->encode(&params)) {
+            LOG_ERR("%s: encode failed (gen_wav)\n", __func__);
             return 1;
         }

@@ -3085,25 +3095,25 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *

     // gen_wav has no hidden-state input, the batch entry is an unused placeholder
     // TODO @ngxson : some models in the future may require hidden-state input, need to update this code later
-    clip_image_f32 dummy;
+    mtmd_image_f32 dummy;
     dummy.set_size({1, 1}, false, true);
     dummy.cpy_buf(std::vector<float>(1, 0.0f));

-    clip_image_f32_batch batch;
+    mtmd_image_f32_batch batch;
     batch.is_audio = true;
     batch.entries.push_back(std::move(dummy));

-    clip_encode_params params;
+    mtmd_subctx_encode_params params;
     params.imgs        = &batch;
     params.n_threads   = ctx->n_threads;
-    params.gen_process = CLIP_GEN_PROCESS_GEN_WAV;
+    params.gen_process = MTMD_SUBCTX_GEN_PROCESS_GEN_WAV;
     params.codes       = has_codes ? &in_codes : nullptr;
     params.feats       = has_feats ? &in_feats : nullptr;
     params.out_audio   = &state->out_audio;
-    params.gen_state   = state->clip_state;
+    params.gen_state   = state->gen_state;

-    if (!clip_encode(ctx_clip, &params)) {
-        LOG_ERR("%s: clip_encode failed (code2wav)\n", __func__);
+    if (!sctx->encode(&params)) {
+        LOG_ERR("%s: encode failed (code2wav)\n", __func__);
         return 1;
     }

@@ -3138,7 +3148,7 @@ int32_t mtmd_batch_add_chunk(mtmd_batch * batch, const mtmd_input_chunk * chunk)
         return 1;
     }

-    auto * ctx = batch->ctx->get_clip_ctx(chunk);
+    auto * ctx = batch->ctx->get_subctx(chunk);
     if (!ctx) {
         LOG_ERR("%s: model does not support input chunk type %d\n", __func__, (int)chunk->type);
         return 1;
@@ -3150,7 +3160,7 @@ int32_t mtmd_batch_add_chunk(mtmd_batch * batch, const mtmd_input_chunk * chunk)
         return 0;
     }

-    if (!clip_support_batch(ctx)) {
+    if (!ctx->support_batch) {
         // if no batching support, batch can only have one single chunk
         return 2; // "batch too large" error code
     }
@@ -3260,7 +3270,7 @@ bool mtmd_decode_use_non_causal(const mtmd_context * ctx, const mtmd_input_chunk
             {
                 // E2B (n_embd = 1536) and E4B (n_embd = 2560) always use causal
                 // note: use mmproj n_embd, because text model may not be provided (e.g. mtmd_get_memory_usage)
-                const int n_embd = clip_n_mmproj_embd(ctx->ctx_v);
+                const int n_embd = ctx->ctx_v->model.n_mmproj_embd();
                 return n_embd != 1536 && n_embd != 2560;
             }
         case PROJECTOR_TYPE_GEMMA4UV:
@@ -3289,7 +3299,7 @@ int mtmd_get_audio_sample_rate(const mtmd_context * ctx) {
     if (!ctx->ctx_a) {
         return -1;
     }
-    return clip_get_hparams(ctx->ctx_a)->audio_sample_rate;
+    return ctx->ctx_a->model.hparams.audio_sample_rate;
 }

 mtmd_video_config mtmd_video_config_init(mtmd_video_template tmpl, const mtmd_image_preprocessor * preproc,
@@ -3312,7 +3322,7 @@ mtmd_video_config mtmd_video_config_init(mtmd_video_template tmpl, const mtmd_im
     n = std::min(n, src_n_frames);
     cfg.n_frames = n;

-    clip_image_size size;
+    mtmd_image_size size;
     if (preproc && preproc->get_video_frame_size(n, src_width, src_height, size)) {
         cfg.frame_width  = size.width;
         cfg.frame_height = size.height;
@@ -3324,7 +3334,7 @@ mtmd_video_config mtmd_get_video_config(const mtmd_context * ctx,
                                         int32_t src_width, int32_t src_height,
                                         int32_t src_n_frames, float src_fps) {
     mtmd_video_template tmpl = MTMD_VIDEO_TEMPLATE_DEFAULT;
-    switch (ctx->ctx_v ? clip_get_projector_type(ctx->ctx_v) : PROJECTOR_TYPE_UNKNOWN) {
+    switch (ctx->ctx_v ? ctx->ctx_v->model.proj_type : PROJECTOR_TYPE_UNKNOWN) {
         case PROJECTOR_TYPE_LFM3VL:
             tmpl = ctx->video_has_soundtrack() ? MTMD_VIDEO_TEMPLATE_LFM3_OMNI : MTMD_VIDEO_TEMPLATE_LFM3;
             break;
@@ -3650,7 +3660,7 @@ static mtmd_decoder_pos mtmd_canvas_decoder_pos(const mtmd_image_tokens * image_
         }
         i -= gw * gh;

-        const size_t n_sep = clip_suffix_rows(entries[k].suffix_type).size();
+        const size_t n_sep = mtmd_suffix_rows(entries[k].suffix_type).size();
         if (i < n_sep) {
             const uint32_t p = t + 1 + i;
             pos.t = p;
@@ -3752,7 +3762,7 @@ llama_pos mtmd_image_tokens_get_n_pos(const mtmd_image_tokens * image_tokens) {
                 // one position per tile, plus one per separator
                 llama_pos n_pos = 0;
                 for (const auto & entry : image_tokens->batch_f32.entries) {
-                    n_pos += 1 + (llama_pos) clip_suffix_rows(entry.suffix_type).size();
+                    n_pos += 1 + (llama_pos) mtmd_suffix_rows(entry.suffix_type).size();
                 }
                 return n_pos;
             }
@@ -3796,14 +3806,16 @@ mtmd_input_chunks * mtmd_test_create_input_chunks() {
     return chunks;
 }

+struct mtmd_logger_state g_logger_state = {mtmd_log_callback_default, NULL};
+
 void mtmd_log_set(ggml_log_callback log_callback, void * user_data) {
-    g_logger_state.log_callback = log_callback ? log_callback : clip_log_callback_default;
+    g_logger_state.log_callback = log_callback ? log_callback : mtmd_log_callback_default;
     g_logger_state.log_callback_user_data = user_data;
 }

 struct mtmd_caps mtmd_get_cap_from_file(const char * fname) {
     try {
-        auto tmp = clip_get_cap(fname);
+        auto tmp = mtmd_model_get_caps(fname);
         mtmd_caps cap;
         cap.inp_audio  = tmp.has_audio;
         cap.inp_vision = tmp.has_vision;
@@ -3819,14 +3831,12 @@ struct mtmd_caps mtmd_get_cap_from_file(const char * fname) {
 // Debugging API (NOT intended for public use)
 //

-static void mtmd_debug_encode_impl(mtmd_context * ctx, clip_ctx * ctx_clip, clip_image_f32 & image) {
-    clip_set_debug_output_embeddings(ctx_clip, true);
-    int n_mmproj_embd = clip_n_mmproj_embd(ctx_clip);
-    int n_tokens = clip_n_output_tokens(ctx_clip, &image);
+static void mtmd_debug_encode_impl(mtmd_context * ctx, mtmd_subcontext * sctx, mtmd_image_f32 & image) {
+    sctx->debug_output_embeddings = true;
+    int n_mmproj_embd = sctx->model.n_mmproj_embd();
+    int n_tokens = sctx->model.n_output_tokens(&image);
     std::vector<float> embd_output(n_tokens * n_mmproj_embd, 0.0f);
-    bool ok = clip_image_encode(
-        ctx_clip,
-        ctx->n_threads,
+    bool ok = sctx->encode_image(ctx->n_threads,
         &image,
         embd_output);
     if (!ok) {
@@ -3845,11 +3855,11 @@ void mtmd_debug_encode_image(mtmd_context * ctx, const std::vector<std::vector<f
     for (const auto & row : image) {
         img_buf.insert(img_buf.end(), row.begin(), row.end());
     }
-    clip_image_f32 inp_image;
+    mtmd_image_f32 inp_image;
     inp_image.set_size({img_sz, img_sz}, false, false);
     inp_image.cpy_buf(img_buf);
     LOG_INF("%s: created input image with nx=%d, ny=%d\n", __func__, img_sz, img_sz);
-    mtmd_debug_encode_impl(ctx, ctx->ctx_v, inp_image);
+    mtmd_debug_encode_impl(ctx, ctx->ctx_v.get(), inp_image);
 }

 void mtmd_debug_encode_audio(mtmd_context * ctx, const std::vector<float> & input) {
@@ -3857,7 +3867,7 @@ void mtmd_debug_encode_audio(mtmd_context * ctx, const std::vector<float> & inpu
         LOG_ERR("%s: model does not support audio input\n", __func__);
         return;
     }
-    int n_mel = clip_get_hparams(ctx->ctx_a)->n_mel_bins;
+    int n_mel = ctx->ctx_a->model.hparams.n_mel_bins;
     const int audio_nx = (int)input.size();
     std::vector<float> audio_buf(audio_nx * n_mel);
     for (int i = 0; i < audio_nx; i++) {
@@ -3865,11 +3875,11 @@ void mtmd_debug_encode_audio(mtmd_context * ctx, const std::vector<float> & inpu
             audio_buf[j * audio_nx + i] = input[i];
         }
     }
-    clip_image_f32 inp_audio;
+    mtmd_image_f32 inp_audio;
     inp_audio.set_size({audio_nx, n_mel}, false, true);
     inp_audio.cpy_buf(audio_buf);
     LOG_INF("%s: created input audio with nx=%d, ny=%d\n", __func__, audio_nx, n_mel);
-    mtmd_debug_encode_impl(ctx, ctx->ctx_a, inp_audio);
+    mtmd_debug_encode_impl(ctx, ctx->ctx_a.get(), inp_audio);
 }

 void mtmd_debug_preprocess_image(mtmd_context * ctx, const std::vector<uint8_t> & rgb_values, int nx, int ny) {
@@ -3877,13 +3887,13 @@ void mtmd_debug_preprocess_image(mtmd_context * ctx, const std::vector<uint8_t>
         LOG_ERR("%s: model does not support vision input\n", __func__);
         return;
     }
-    clip_image_u8 img_u8;
+    mtmd_image_u8 img_u8;
     img_u8.set_size({nx, ny}, false);
     img_u8.cpy_buf(rgb_values);
     GGML_ASSERT(ctx->image_preproc != nullptr);
     mtmd_image_preproc_out preproc_out = ctx->image_preproc->preprocess(img_u8);

-    clip_image_f32_batch batch_f32;
+    mtmd_image_f32_batch batch_f32;
     batch_f32.is_audio = false;
     for (auto & entry : preproc_out.entries) {
         batch_f32.entries.push_back(std::move(entry));
@@ -3938,23 +3948,23 @@ mtmd_memory_usage mtmd_get_memory_usage(const char * mmproj_fname,
         ctx.reset(new mtmd_context(mmproj_fname, nullptr, ctx_params, true));
         mtmd_log_set(saved_log_callback, saved_log_user_data); // restore log callback
         std::map<ggml_backend_dev_t, size_t> total_mem;
-        auto merge = [&](const struct clip_ctx * c) {
-            for (auto & [dev, size] : clip_get_mem_usage(c)) {
+        auto merge = [&](const struct mtmd_subcontext * c) {
+            for (auto & [dev, size] : c->get_mem_usage()) {
                 total_mem[dev] += size;
             }
         };
         if (ctx->ctx_v) {
-            merge(ctx->ctx_v);
+            merge(ctx->ctx_v.get());
         }
         if (ctx->ctx_a) {
-            merge(ctx->ctx_a);
+            merge(ctx->ctx_a.get());
         }
         if (ctx->ctx_gen_a) {
-            merge(ctx->ctx_gen_a);
+            merge(ctx->ctx_gen_a.get());
         }
         mtmd_memory_usage res;
         res.backend_mem_usage = std::move(total_mem);
-        res.image_max_tokens  = ctx->ctx_v ? clip_get_image_max_tokens(ctx->ctx_v) : -1;
+        res.image_max_tokens  = ctx->ctx_v ? ctx->ctx_v->model.image_max_tokens() : -1;
         res.use_non_causal    = ctx->ctx_v ? mtmd_decode_use_non_causal(ctx.get(), nullptr) : false;
         return res;
     } catch (const std::exception & e) {
@@ -3971,7 +3981,7 @@ mtmd_memory_usage mtmd_get_memory_usage(const char * mmproj_fname,
 // the model-independent part of a stream, the model-specific part is in the subclasses below
 struct mtmd_audio_stream {
     mtmd_context * ctx;
-    clip_ctx * ctx_clip;
+    mtmd_subcontext * sctx;

     // model constants
     int n_mel;
@@ -4015,10 +4025,10 @@ struct mtmd_audio_stream {

 // Voxtral Realtime: a row is an audio embedding plus the embedding of the previous token
 struct mtmd_audio_stream_voxtral_rt : mtmd_audio_stream {
-    clip_enc_state * enc_state = nullptr;
+    mtmd_subctx_enc_state * enc_state = nullptr;

     int n_fft;
-    int n_frames_max;    // encoder frames per clip_encode() call
+    int n_frames_max;    // encoder frames per encode() call
     int n_right_pad;     // silence tokens added by flush()
     float log_mel_max;   // global_log_mel_max in params.json

@@ -4030,7 +4040,7 @@ struct mtmd_audio_stream_voxtral_rt : mtmd_audio_stream {
     std::vector<float> tok_embd;

     ~mtmd_audio_stream_voxtral_rt() override {
-        clip_enc_state_free(enc_state);
+        delete enc_state;
     }

     bool    init(const mtmd_audio_stream_params & params) override;
@@ -4043,7 +4053,7 @@ struct mtmd_audio_stream_voxtral_rt : mtmd_audio_stream {
 // training samples are max 30 s: restart the sequence at a silent chunk after 15 s, always after 30 s; the encoder keeps its state
 struct mtmd_audio_stream_lfm3a : mtmd_audio_stream {
     int win; // samples per mel frame
-    clip_enc_state * enc_state = nullptr;
+    mtmd_subctx_enc_state * enc_state = nullptr;

     // live ASR
     static constexpr int64_t chunk_rows       = 12;
@@ -4073,7 +4083,7 @@ struct mtmd_audio_stream_lfm3a : mtmd_audio_stream {
     bool end_done = false;

     ~mtmd_audio_stream_lfm3a() override {
-        clip_enc_state_free(enc_state);
+        delete enc_state;
     }

     bool    init(const mtmd_audio_stream_params & params) override;
@@ -4088,7 +4098,7 @@ bool mtmd_support_audio_stream(const mtmd_context * ctx) {
     if (!ctx->ctx_a || !ctx->text_model) {
         return false;
     }
-    const projector_type proj = clip_get_projector_type(ctx->ctx_a);
+    const projector_type proj = ctx->ctx_a->model.proj_type;
     return proj == PROJECTOR_TYPE_VOXTRAL_RT || proj == PROJECTOR_TYPE_LFM3A;
 }

@@ -4105,19 +4115,19 @@ mtmd_audio_stream * mtmd_audio_stream_init(mtmd_context * ctx, mtmd_audio_stream
         LOG_ERR("%s: the model does not support streaming audio input\n", __func__);
         return nullptr;
     }
-    const clip_hparams * hparams = clip_get_hparams(ctx->ctx_a);
+    const mtmd_hparams * hparams = &ctx->ctx_a->model.hparams;

     std::unique_ptr<mtmd_audio_stream> s;
-    switch (clip_get_projector_type(ctx->ctx_a)) {
+    switch (ctx->ctx_a->model.proj_type) {
         case PROJECTOR_TYPE_VOXTRAL_RT: s = std::make_unique<mtmd_audio_stream_voxtral_rt>(); break;
         case PROJECTOR_TYPE_LFM3A:      s = std::make_unique<mtmd_audio_stream_lfm3a>();      break;
         default:                        GGML_ABORT("unsupported projector");
     }
     s->ctx      = ctx;
-    s->ctx_clip = ctx->ctx_a;
+    s->sctx = ctx->ctx_a.get();
     s->n_mel    = hparams->n_mel_bins;
     s->hop      = hparams->audio_hop_len;
-    s->n_embd   = clip_n_mmproj_embd(ctx->ctx_a);
+    s->n_embd   = ctx->ctx_a->model.n_mmproj_embd();
     if (s->n_embd != ctx->n_embd_text) {
         LOG_ERR("%s: mmproj embedding size %d does not match the text model (%d)\n", __func__, s->n_embd, ctx->n_embd_text);
         return nullptr;
@@ -4166,7 +4176,7 @@ bool mtmd_audio_stream_is_seq_start(const mtmd_audio_stream * s) {
 //

 bool mtmd_audio_stream_voxtral_rt::init(const mtmd_audio_stream_params & params) {
-    const clip_hparams * hparams = clip_get_hparams(ctx_clip);
+    const mtmd_hparams * hparams = &sctx->model.hparams;
     const llama_vocab * vocab = ctx->vocab;

     n_fft = hparams->audio_n_fft;
@@ -4180,7 +4190,7 @@ bool mtmd_audio_stream_voxtral_rt::init(const mtmd_audio_stream_params & params)
     // the encoder works in whole decoder rows
     int n_step = (int) ((int64_t) params.step_ms * hparams->audio_sample_rate / 1000 / hop);
     n_step = std::max(n_step, frames_per_tok);
-    n_step = CLIP_ALIGN(n_step, frames_per_tok);
+    n_step = MTMD_ALIGN(n_step, frames_per_tok);
     n_step_mel = std::min(n_step, 2 * n_frames_max);

     if (params.n_prompt > 0) {
@@ -4209,7 +4219,7 @@ bool mtmd_audio_stream_voxtral_rt::init(const mtmd_audio_stream_params & params)
     cache.fill_hann_window(hparams->audio_window_len, true);
     cache.fill_mel_filterbank_matrix(n_mel, n_fft, hparams->audio_sample_rate);

-    enc_state = clip_enc_state_init(ctx_clip, n_frames_max);
+    enc_state = sctx->enc_state_init(n_frames_max);
     if (!enc_state) {
         return false;
     }
@@ -4293,20 +4303,20 @@ static bool mtmd_audio_stream_encode(mtmd_audio_stream_voxtral_rt * s, bool forc
             }
         }

-        clip_image_f32 img;
+        mtmd_image_f32 img;
         img.set_size({n_in, s->n_mel}, false, /* is_audio */ true);
         img.cpy_buf(data);
-        clip_image_f32_batch batch;
+        mtmd_image_f32_batch batch;
         batch.is_audio = true;
         batch.entries.push_back(std::move(img));

         std::vector<float> res((size_t) s->n_embd * (n / s->frames_per_tok));
-        clip_encode_params params;
+        mtmd_subctx_encode_params params;
         params.n_threads = s->ctx->n_threads;
         params.imgs      = &batch;
         params.out_embd  = &res;
         params.enc_state = s->enc_state;
-        if (!clip_encode(s->ctx_clip, &params)) {
+        if (!s->sctx->encode(&params)) {
             LOG_ERR("%s: failed to encode audio\n", __func__);
             return false;
         }
@@ -4385,7 +4395,7 @@ int32_t mtmd_audio_stream_voxtral_rt::step(llama_token last_token) {
 //

 bool mtmd_audio_stream_lfm3a::init(const mtmd_audio_stream_params & params) {
-    const clip_hparams * hparams = clip_get_hparams(ctx_clip);
+    const mtmd_hparams * hparams = &sctx->model.hparams;

     // one row per 8 mel frames (three stride-2 convs), mel frames are not centered
     win             = hparams->audio_window_len;
@@ -4393,7 +4403,7 @@ bool mtmd_audio_stream_lfm3a::init(const mtmd_audio_stream_params & params) {
     samples_per_tok = frames_per_tok * hop;

     const int n_step = (int) ((int64_t) params.step_ms * hparams->audio_sample_rate / 1000 / hop);
-    n_step_mel = CLIP_ALIGN(std::max(n_step, frames_per_tok), frames_per_tok);
+    n_step_mel = MTMD_ALIGN(std::max(n_step, frames_per_tok), frames_per_tok);

     if (params.n_prompt > 0) {
         // live ASR, a chunk per encoder call
@@ -4416,7 +4426,7 @@ bool mtmd_audio_stream_lfm3a::init(const mtmd_audio_stream_params & params) {
         max_seq_rows  = std::lround(30.0 * hparams->audio_sample_rate / samples_per_tok);
     }

-    enc_state = clip_enc_state_init(ctx_clip, n_step_mel);
+    enc_state = sctx->enc_state_init(n_step_mel);
     if (!enc_state) {
         return false;
     }
@@ -4508,22 +4518,22 @@ static bool mtmd_audio_stream_encode(mtmd_audio_stream_lfm3a * s, bool flush) {
             data[(size_t) m * n + t] = s->mel[(size_t) t * s->n_mel + m];
         }
     }
-    clip_image_f32 img;
+    mtmd_image_f32 img;
     img.set_size({n, s->n_mel}, false, /* is_audio */ true);
     img.cpy_buf(data);
-    const int n_rows = clip_n_output_tokens(s->ctx_clip, &img);
+    const int n_rows = s->sctx->model.n_output_tokens(&img);

-    clip_image_f32_batch batch;
+    mtmd_image_f32_batch batch;
     batch.is_audio = true;
     batch.entries.push_back(std::move(img));

     std::vector<float> res((size_t) s->n_embd * n_rows);
-    clip_encode_params params;
+    mtmd_subctx_encode_params params;
     params.n_threads = s->ctx->n_threads;
     params.imgs      = &batch;
     params.out_embd  = &res;
     params.enc_state = s->enc_state;
-    if (!clip_encode(s->ctx_clip, &params)) {
+    if (!s->sctx->encode(&params)) {
         LOG_ERR("%s: failed to encode audio\n", __func__);
         return false;
     }