Skip to main content

cortiq_engine/
egemma2_mm.rs

1//! EmbeddingGemma 2 multimodal inputs: text, images, video and audio —
2//! alone or interleaved in one sequence — embedded into the one 768-d space.
3//!
4//! An input is a text that may hold placeholders (`<|image|>`, `<|video|>`,
5//! `<|audio|>`) and the media that fill them, in order. Exactly as
6//! `EmbeddingGemma2Processor` lays it out:
7//!
8//! * each `<|image|>` becomes `<|image>` + N × `<|image|>` + `<image|>`,
9//!   N = the image's soft tokens (256 for a square photo at the default
10//!   budget of 280);
11//! * each `<|video|>` becomes, per sampled frame, `<|image>` + M ×
12//!   `<|video|>` + `<image|>` (M = 120 for a 16:9 frame at 140);
13//! * each `<|audio|>` becomes `<|audio>` + K × `<|audio|>` + `<audio|>`,
14//!   K = 25 per second of the clip (mono 16 kHz, cut at 30 s);
15//! * the whole is `[BOS] … [EOS]`, at most 8192 tokens (media are never
16//!   cut: an input that does not fit is an error);
17//! * media given without placeholders in the text come first — images, then
18//!   videos, then audio clips, separated by spaces — then the text (the
19//!   processor's own layout for a text-less input is `"<|image|> <|image|>"`).
20//!
21//! The text is tokenized with the placeholders in it (they are added
22//! tokens, so they split it exactly where the processor's expansion
23//! would), then each placeholder id is expanded. The token rows are
24//! `E[id]·sqrt(512)`; the placeholder rows are replaced by the towers' soft
25//! tokens (vision for images and frames, audio for clips); the text model
26//! runs over the merged rows. Every input of a call goes through each tower
27//! together, then through the text model packed up to the context.
28//!
29//! Task prompts follow sentence-transformers: they prefix text-only
30//! inputs. An input with media gets a prompt only when one is set on that
31//! input itself (then it prefixes its text, placeholders included).
32
33use crate::egemma2::{EmbeddingGemma2, TextInput};
34use crate::egemma2_audio::{self as ea, AudioTower};
35use crate::egemma2_vision::{VisionInput, VisionProcessor, VisionTower};
36use crate::media::RgbFrame;
37use crate::pool::Pool;
38use cortiq_core::CmfModel;
39use serde_json::Value;
40use std::sync::{Arc, OnceLock};
41
42pub const IMAGE_PLACEHOLDER: &str = "<|image|>";
43pub const VIDEO_PLACEHOLDER: &str = "<|video|>";
44pub const AUDIO_PLACEHOLDER: &str = ea::PLACEHOLDER;
45
46/// The special token ids of the layout (`config.json`).
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct SpecialIds {
49    pub image: u32,
50    pub video: u32,
51    pub boi: u32,
52    pub eoi: u32,
53    pub audio: u32,
54    pub boa: u32,
55    pub eoa: u32,
56}
57
58impl Default for SpecialIds {
59    fn default() -> Self {
60        SpecialIds {
61            image: 258_880,
62            video: 258_884,
63            boi: 255_999,
64            eoi: 258_882,
65            audio: ea::AUDIO_TOKEN,
66            boa: ea::BOA_TOKEN,
67            eoa: ea::EOA_TOKEN,
68        }
69    }
70}
71
72impl SpecialIds {
73    pub fn from_config(cfg: &Value) -> Self {
74        let d = SpecialIds::default();
75        let g = |k: &str, v: u32| cfg.get(k).and_then(|x| x.as_u64()).map_or(v, |x| x as u32);
76        SpecialIds {
77            image: g("image_token_id", d.image),
78            video: g("video_token_id", d.video),
79            boi: g("boi_token_id", d.boi),
80            eoi: g("eoi_token_id", d.eoi),
81            audio: g("audio_token_id", d.audio),
82            boa: g("boa_token_id", d.boa),
83            // config.json spells this one `eoa_token_index`
84            eoa: g("eoa_token_id", g("eoa_token_index", d.eoa)),
85        }
86    }
87}
88
89/// One media item, preprocessed.
90#[derive(Clone, Debug)]
91pub enum Media {
92    Image(VisionInput),
93    /// the sampled frames, each at the video budget
94    Video(Vec<VisionInput>),
95    /// a clip, mono 16 kHz (cut at 30 s by the tower)
96    Audio(Vec<f32>),
97}
98
99/// The three kinds of placeholder, in the processor's text-less order.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
101enum Kind {
102    Image,
103    Video,
104    Audio,
105}
106
107impl Kind {
108    const ALL: [Kind; 3] = [Kind::Image, Kind::Video, Kind::Audio];
109
110    fn placeholder(self) -> &'static str {
111        match self {
112            Kind::Image => IMAGE_PLACEHOLDER,
113            Kind::Video => VIDEO_PLACEHOLDER,
114            Kind::Audio => AUDIO_PLACEHOLDER,
115        }
116    }
117}
118
119impl Media {
120    fn kind(&self) -> Kind {
121        match self {
122            Media::Image(_) => Kind::Image,
123            Media::Video(_) => Kind::Video,
124            Media::Audio(_) => Kind::Audio,
125        }
126    }
127
128    /// images / frames this medium sends through the vision tower
129    fn vision_rows(&self) -> usize {
130        match self {
131            Media::Image(_) => 1,
132            Media::Video(f) => f.len(),
133            Media::Audio(_) => 0,
134        }
135    }
136}
137
138/// One input: text (may hold placeholders), its media, and its prompt.
139#[derive(Clone, Debug, Default)]
140pub struct MixedInput {
141    pub text: String,
142    pub media: Vec<Media>,
143    pub prompt_name: Option<String>,
144    pub title: Option<String>,
145    pub prompt: Option<String>,
146}
147
148impl MixedInput {
149    pub fn text(t: TextInput) -> Self {
150        MixedInput {
151            text: t.text,
152            media: Vec::new(),
153            prompt_name: t.prompt_name,
154            title: t.title,
155            prompt: t.prompt,
156        }
157    }
158
159    pub fn image(img: VisionInput) -> Self {
160        MixedInput {
161            media: vec![Media::Image(img)],
162            ..Default::default()
163        }
164    }
165
166    pub fn video(frames: Vec<VisionInput>) -> Self {
167        MixedInput {
168            media: vec![Media::Video(frames)],
169            ..Default::default()
170        }
171    }
172
173    /// A clip (mono 16 kHz) on its own.
174    pub fn audio(wave: Vec<f32>) -> Self {
175        MixedInput {
176            media: vec![Media::Audio(wave)],
177            ..Default::default()
178        }
179    }
180}
181
182/// The text with every medium's placeholder in place: the text as given
183/// when its placeholders match the media (per kind, in order); otherwise —
184/// no placeholders at all — the media's placeholders first (images, videos,
185/// then audio, as the processor lays out a text-less input), space
186/// separated, then the text.
187pub fn layout_text(text: &str, media: &[Media]) -> Result<String, String> {
188    let found: Vec<usize> = Kind::ALL
189        .iter()
190        .map(|k| text.matches(k.placeholder()).count())
191        .collect();
192    let want: Vec<usize> = Kind::ALL
193        .iter()
194        .map(|&k| media.iter().filter(|m| m.kind() == k).count())
195        .collect();
196    if found.iter().all(|&n| n == 0) {
197        let mut kinds: Vec<Kind> = media.iter().map(Media::kind).collect();
198        kinds.sort();
199        let mut parts: Vec<&str> = kinds.iter().map(|k| k.placeholder()).collect();
200        if !text.is_empty() {
201            parts.push(text);
202        }
203        return Ok(parts.join(" "));
204    }
205    if found != want {
206        return Err(format!(
207            "the text holds {} {IMAGE_PLACEHOLDER}, {} {VIDEO_PLACEHOLDER} and {} \
208             {AUDIO_PLACEHOLDER} but {} image(s), {} video(s) and {} audio clip(s) were given",
209            found[0], found[1], found[2], want[0], want[1], want[2]
210        ));
211    }
212    Ok(text.to_string())
213}
214
215/// The kinds of the placeholders in `text`, in the order they occur.
216fn placeholder_order(text: &str) -> Vec<Kind> {
217    let mut out = Vec::new();
218    let mut rest = text;
219    loop {
220        let next = Kind::ALL
221            .iter()
222            .filter_map(|&k| rest.find(k.placeholder()).map(|at| (at, k)))
223            .min();
224        let Some((at, k)) = next else { break };
225        out.push(k);
226        rest = &rest[at + k.placeholder().len()..];
227    }
228    out
229}
230
231/// Where an input's soft tokens come from, in sequence order: the vision
232/// rows (its images and frames, numbered in `media` order, a video's
233/// frames consecutively) and the audio clips (numbered in `media` order).
234#[derive(Clone, Debug, Default, PartialEq)]
235pub struct Placement {
236    pub vision: Vec<usize>,
237    pub audio: Vec<usize>,
238}
239
240/// The text encoder with the vision and audio towers beside it (each
241/// loaded on first use).
242pub struct MediaEncoder {
243    pub text: EmbeddingGemma2,
244    pub proc: VisionProcessor,
245    pub ids: SpecialIds,
246    model: Arc<CmfModel>,
247    pool: Option<Arc<Pool>>,
248    vision: OnceLock<Result<VisionTower, String>>,
249    audio: OnceLock<Result<AudioTower, String>>,
250}
251
252impl MediaEncoder {
253    pub fn load(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
254        let text = EmbeddingGemma2::load(model, pool.clone())?;
255        let prov = model.header.provenance.clone().unwrap_or(Value::Null);
256        let eg = &prov["embedding_gemma2"];
257        Ok(MediaEncoder {
258            text,
259            proc: VisionProcessor::from_config(&eg["processor"]),
260            ids: SpecialIds::from_config(&eg["config"]),
261            model: model.clone(),
262            pool,
263            vision: OnceLock::new(),
264            audio: OnceLock::new(),
265        })
266    }
267
268    /// Does the file carry the vision tower?
269    pub fn has_vision(&self) -> bool {
270        VisionTower::present(&self.model)
271    }
272
273    /// The vision tower, loaded on first use (~0.7 GB of f32 weights).
274    pub fn vision(&self) -> Result<&VisionTower, String> {
275        self.vision
276            .get_or_init(|| VisionTower::load(&self.model, self.pool.clone()))
277            .as_ref()
278            .map_err(|e| e.clone())
279    }
280
281    /// Load the tower now (a server does it at startup).
282    pub fn warm_vision(&self) -> Result<(), String> {
283        self.vision().map(|_| ())
284    }
285
286    /// Does the file carry the audio tower?
287    pub fn has_audio(&self) -> bool {
288        ea::has_audio(&self.model)
289    }
290
291    /// The audio tower, loaded on first use (~1.2 GB of f32 weights).
292    pub fn audio(&self) -> Result<&AudioTower, String> {
293        self.audio
294            .get_or_init(|| {
295                if !self.has_audio() {
296                    return Err("this file carries no audio tower (a text-only pack?)".into());
297                }
298                AudioTower::load(&self.model, self.pool.clone())
299            })
300            .as_ref()
301            .map_err(|e| e.clone())
302    }
303
304    /// Load the audio tower now.
305    pub fn warm_audio(&self) -> Result<(), String> {
306        self.audio().map(|_| ())
307    }
308
309    /// Load the towers the inputs need (their one-time load stays out of
310    /// a timed forward).
311    pub fn warm_for(&self, inputs: &[MixedInput]) -> Result<(), String> {
312        let media = || inputs.iter().flat_map(|x| x.media.iter());
313        if media().any(|m| m.vision_rows() > 0) {
314            self.warm_vision()?;
315        }
316        if media().any(|m| matches!(m, Media::Audio(_))) {
317            self.warm_audio()?;
318        }
319        Ok(())
320    }
321
322    /// An image at `budget` soft tokens (`None`: the processor's default).
323    pub fn prepare_image(&self, img: &RgbFrame, budget: Option<usize>) -> Result<Media, String> {
324        Ok(Media::Image(self.proc.prepare_image(
325            img,
326            budget.unwrap_or(self.proc.image_budget),
327        )?))
328    }
329
330    /// Sampled video frames at `budget` soft tokens a frame.
331    pub fn prepare_frames(
332        &self,
333        frames: &[RgbFrame],
334        budget: Option<usize>,
335    ) -> Result<Media, String> {
336        Ok(Media::Video(self.proc.prepare_frames(
337            frames,
338            budget.unwrap_or(self.proc.video_budget),
339        )?))
340    }
341
342    /// Token ids of one input, every placeholder expanded.
343    pub fn input_ids(&self, input: &MixedInput) -> Result<Vec<u32>, String> {
344        Ok(self.layout(input)?.0)
345    }
346
347    /// Token ids of one input with every placeholder expanded, and where
348    /// its soft tokens come from, in sequence order.
349    pub fn layout(&self, input: &MixedInput) -> Result<(Vec<u32>, Placement), String> {
350        if input.media.is_empty() {
351            let ids = self.text.input_ids(&TextInput {
352                text: input.text.clone(),
353                prompt_name: input.prompt_name.clone(),
354                title: input.title.clone(),
355                prompt: input.prompt.clone(),
356            })?;
357            return Ok((ids, Placement::default()));
358        }
359        let k = self.proc.pool_k;
360        let laid = layout_text(&input.text, &input.media)?;
361        let full = if input.prompt_name.is_some() || input.title.is_some() || input.prompt.is_some()
362        {
363            self.text.prompts().format(
364                &laid,
365                input.prompt_name.as_deref(),
366                input.title.as_deref(),
367                input.prompt.as_deref(),
368            )?
369        } else {
370            laid
371        };
372        // first vision row / audio clip of each medium
373        let mut first = Vec::with_capacity(input.media.len());
374        let (mut r, mut a) = (0usize, 0usize);
375        for m in &input.media {
376            match m {
377                Media::Audio(_) => {
378                    first.push(a);
379                    a += 1;
380                }
381                _ => {
382                    first.push(r);
383                    r += m.vision_rows();
384                }
385            }
386        }
387        // media of each kind, in the order their placeholders occur
388        let mut of_kind: Vec<_> = Kind::ALL
389            .iter()
390            .map(|&kd| {
391                (0..input.media.len())
392                    .filter(move |&i| input.media[i].kind() == kd)
393                    .collect::<Vec<_>>()
394                    .into_iter()
395            })
396            .collect();
397        let mut seq_media: Vec<usize> = Vec::new();
398        for kd in placeholder_order(&full) {
399            let m = of_kind[kd as usize].next();
400            seq_media.push(m.ok_or("a placeholder without a medium")?);
401        }
402        if of_kind.iter_mut().any(|it| it.next().is_some()) {
403            return Err("a medium without a placeholder".into());
404        }
405        let raw = self.text.tokenizer().encode(&full);
406        let mut ids = Vec::with_capacity(raw.len() + 512);
407        let mut place = Placement::default();
408        ids.push(self.text.bos);
409        let mut next = seq_media.iter();
410        for &t in &raw {
411            if t == self.ids.image || t == self.ids.video || t == self.ids.audio {
412                let m = *next.next().ok_or("more placeholder tokens than media")?;
413                let want = match input.media[m].kind() {
414                    Kind::Image => self.ids.image,
415                    Kind::Video => self.ids.video,
416                    Kind::Audio => self.ids.audio,
417                };
418                if t != want {
419                    return Err("a placeholder did not tokenize to its token".into());
420                }
421                match &input.media[m] {
422                    Media::Image(x) => {
423                        ids.push(self.ids.boi);
424                        ids.extend(std::iter::repeat_n(self.ids.image, x.n_soft(k)));
425                        ids.push(self.ids.eoi);
426                        place.vision.push(first[m]);
427                    }
428                    Media::Video(frames) => {
429                        for (j, f) in frames.iter().enumerate() {
430                            ids.push(self.ids.boi);
431                            ids.extend(std::iter::repeat_n(self.ids.video, f.n_soft(k)));
432                            ids.push(self.ids.eoi);
433                            place.vision.push(first[m] + j);
434                        }
435                    }
436                    Media::Audio(w) => {
437                        ids.push(self.ids.boa);
438                        ids.extend(std::iter::repeat_n(self.ids.audio, ea::num_tokens(w.len())));
439                        ids.push(self.ids.eoa);
440                        place.audio.push(first[m]);
441                    }
442                }
443            } else {
444                ids.push(t);
445            }
446        }
447        if next.next().is_some() {
448            return Err("a placeholder did not tokenize to its token".into());
449        }
450        ids.push(self.text.eos);
451        if ids.len() > self.text.max_tokens {
452            return Err(format!(
453                "{} tokens > the model's {} token context (media are not truncated: \
454                 lower the image budget, the number of frames or the audio length)",
455                ids.len(),
456                self.text.max_tokens
457            ));
458        }
459        Ok((ids, place))
460    }
461
462    /// Embed inputs: unit-length vectors of the model's width, in order.
463    pub fn embed(&self, inputs: &[MixedInput]) -> Result<Vec<Vec<f32>>, String> {
464        let laid: Vec<(Vec<u32>, Placement)> = inputs
465            .iter()
466            .enumerate()
467            .map(|(i, x)| self.layout(x).map_err(|e| format!("input {i}: {e}")))
468            .collect::<Result<_, _>>()?;
469        let seqs: Vec<Vec<u32>> = laid.iter().map(|(s, _)| s.clone()).collect();
470        if inputs.iter().all(|x| x.media.is_empty()) {
471            return self.text.embed_ids(&seqs);
472        }
473        // every image / frame of every input through the vision tower, and
474        // every clip through the audio tower, each packed
475        let mut flat: Vec<VisionInput> = Vec::new();
476        let mut clips: Vec<&[f32]> = Vec::new();
477        let mut base = Vec::with_capacity(inputs.len());
478        for x in inputs {
479            base.push((flat.len(), clips.len()));
480            for m in &x.media {
481                match m {
482                    Media::Image(v) => flat.push(v.clone()),
483                    Media::Video(f) => flat.extend(f.iter().cloned()),
484                    Media::Audio(w) => clips.push(w),
485                }
486            }
487        }
488        let d = self.text.hidden();
489        let soft = if flat.is_empty() {
490            Vec::new()
491        } else {
492            self.vision()?.encode(&flat)?
493        };
494        drop(flat);
495        let asoft = if clips.is_empty() {
496            Vec::new()
497        } else {
498            let tower = self.audio()?;
499            if tower.out_dim != d {
500                return Err(format!(
501                    "audio soft tokens are {}-d, the text encoder is {d}-d",
502                    tower.out_dim
503                ));
504            }
505            tower.soft_tokens(&clips)
506        };
507        let (image, video, audio) = (self.ids.image, self.ids.video, self.ids.audio);
508        let mut out: Vec<Option<Vec<f32>>> = vec![None; inputs.len()];
509        // pack sequences up to the context into one forward
510        let mut start = 0usize;
511        while start < inputs.len() {
512            let mut end = start;
513            let mut toks = 0usize;
514            while end < inputs.len()
515                && (end == start || toks + seqs[end].len() <= self.text.max_tokens)
516            {
517                toks += seqs[end].len();
518                end += 1;
519            }
520            let mut x = self.text.embed_rows(&seqs[start..end]);
521            let mut row = 0usize;
522            for i in start..end {
523                let (ids, place) = &laid[i];
524                let (vb, ab) = base[i];
525                let mut vsrc = place
526                    .vision
527                    .iter()
528                    .flat_map(|&j| soft[vb + j].chunks_exact(d));
529                let mut asrc = place
530                    .audio
531                    .iter()
532                    .flat_map(|&j| asoft[ab + j].chunks_exact(d));
533                for &t in ids {
534                    let src = if t == image || t == video {
535                        Some(vsrc.next().ok_or("fewer soft tokens than placeholders")?)
536                    } else if t == audio {
537                        Some(asrc.next().ok_or("fewer audio tokens than placeholders")?)
538                    } else {
539                        None
540                    };
541                    if let Some(s) = src {
542                        x[row * d..(row + 1) * d].copy_from_slice(s);
543                    }
544                    row += 1;
545                }
546                if vsrc.next().is_some() || asrc.next().is_some() {
547                    return Err("more soft tokens than placeholders".into());
548                }
549            }
550            let lens: Vec<usize> = seqs[start..end].iter().map(|s| s.len()).collect();
551            for (j, v) in self.text.embed_merged(x, &lens).into_iter().enumerate() {
552                out[start + j] = Some(v);
553            }
554            start = end;
555        }
556        Ok(out.into_iter().map(|v| v.unwrap()).collect())
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    fn img(pw: usize, ph: usize) -> Media {
565        Media::Image(VisionInput {
566            pw,
567            ph,
568            pixels: Vec::new(),
569        })
570    }
571
572    fn vid(frames: usize) -> Media {
573        Media::Video(
574            (0..frames)
575                .map(|_| VisionInput {
576                    pw: 45,
577                    ph: 24,
578                    pixels: Vec::new(),
579                })
580                .collect(),
581        )
582    }
583
584    #[test]
585    fn layout_places_media_without_placeholders_first() {
586        assert_eq!(layout_text("", &[img(3, 3)]).unwrap(), "<|image|>");
587        assert_eq!(
588            layout_text("", &[img(3, 3), img(3, 3)]).unwrap(),
589            "<|image|> <|image|>"
590        );
591        assert_eq!(
592            layout_text("a fox", &[img(3, 3), vid(2)]).unwrap(),
593            "<|image|> <|video|> a fox"
594        );
595        assert_eq!(
596            layout_text("A <|image|> b", &[img(3, 3)]).unwrap(),
597            "A <|image|> b"
598        );
599        assert!(layout_text("A <|image|> <|image|>", &[img(3, 3)]).is_err());
600        assert!(layout_text("A <|video|>", &[img(3, 3)]).is_err());
601        // the processor's text-less order: images, videos, audio
602        let clip = Media::Audio(vec![0.0; 1600]);
603        assert_eq!(
604            layout_text("", &[clip.clone(), vid(1), img(3, 3)]).unwrap(),
605            "<|image|> <|video|> <|audio|>"
606        );
607        assert_eq!(
608            layout_text("said: <|audio|>", std::slice::from_ref(&clip)).unwrap(),
609            "said: <|audio|>"
610        );
611        assert!(layout_text("said: <|audio|> <|audio|>", &[clip]).is_err());
612    }
613
614    #[test]
615    fn placeholder_order_interleaves_kinds() {
616        assert_eq!(
617            placeholder_order("x <|video|> y <|image|><|audio|><|image|> <|video|>"),
618            vec![
619                Kind::Video,
620                Kind::Image,
621                Kind::Audio,
622                Kind::Image,
623                Kind::Video
624            ]
625        );
626        assert!(placeholder_order("plain").is_empty());
627    }
628}