1use sha2::{Digest, Sha256};
4
5#[derive(Debug, Clone, PartialEq, thiserror::Error)]
7pub enum MediaRequestError {
8 #[error("multimodal input request is empty")]
10 EmptyRequest,
11 #[error("RGB8 image shape {width}x{height} requires {expected} bytes, got {actual}")]
13 InvalidRgbImage {
14 width: u32,
16 height: u32,
18 expected: usize,
20 actual: usize,
22 },
23 #[error("decoded audio is invalid: {0}")]
25 InvalidAudio(String),
26 #[error("decoded video is invalid: {0}")]
28 InvalidVideo(String),
29 #[error("chat media binding {index} has an empty placeholder")]
31 EmptyPlaceholder {
32 index: usize,
34 },
35 #[error(
37 "rendered chat contains {actual} occurrence(s) of media placeholder {placeholder:?}, but {expected} binding(s) were supplied"
38 )]
39 PlaceholderCount {
40 placeholder: String,
42 expected: usize,
44 actual: usize,
46 },
47 #[error(
49 "chat media binding {index} placeholder {placeholder:?} does not occur after the preceding binding"
50 )]
51 PlaceholderOrder {
52 index: usize,
54 placeholder: String,
56 },
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct RgbImage {
62 pixels: Vec<u8>,
63 width: u32,
64 height: u32,
65}
66
67impl RgbImage {
68 pub fn new(pixels: Vec<u8>, width: u32, height: u32) -> Result<Self, MediaRequestError> {
70 let expected = usize::try_from(width)
71 .ok()
72 .zip(usize::try_from(height).ok())
73 .and_then(|(width, height)| width.checked_mul(height))
74 .and_then(|pixels| pixels.checked_mul(3))
75 .unwrap_or(usize::MAX);
76 if width == 0 || height == 0 || pixels.len() != expected {
77 return Err(MediaRequestError::InvalidRgbImage {
78 width,
79 height,
80 expected,
81 actual: pixels.len(),
82 });
83 }
84 Ok(Self {
85 pixels,
86 width,
87 height,
88 })
89 }
90
91 pub fn pixels(&self) -> &[u8] {
93 &self.pixels
94 }
95
96 pub const fn width(&self) -> u32 {
98 self.width
99 }
100
101 pub const fn height(&self) -> u32 {
103 self.height
104 }
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub struct Audio {
110 samples: Vec<f32>,
111 sample_rate: u32,
112}
113
114impl Audio {
115 pub fn new(samples: Vec<f32>, sample_rate: u32) -> Result<Self, MediaRequestError> {
117 if sample_rate == 0 {
118 return Err(MediaRequestError::InvalidAudio(
119 "sample rate must be positive".into(),
120 ));
121 }
122 if samples.is_empty() {
123 return Err(MediaRequestError::InvalidAudio(
124 "waveform must contain at least one sample".into(),
125 ));
126 }
127 if samples.iter().any(|sample| !sample.is_finite()) {
128 return Err(MediaRequestError::InvalidAudio(
129 "waveform samples must be finite".into(),
130 ));
131 }
132 Ok(Self {
133 samples,
134 sample_rate,
135 })
136 }
137
138 pub fn samples(&self) -> &[f32] {
140 &self.samples
141 }
142
143 pub const fn sample_rate(&self) -> u32 {
145 self.sample_rate
146 }
147}
148
149#[derive(Debug, Clone, Copy, Default, PartialEq)]
151pub enum VideoSampling {
152 #[default]
154 ProcessorDefault,
155 Fps(f64),
157 FrameCount(usize),
159 All,
161}
162
163#[derive(Debug, Clone, PartialEq)]
165pub struct Video {
166 frames: Vec<RgbImage>,
167 source_fps: Option<f64>,
168 sampling: VideoSampling,
169}
170
171impl Video {
172 pub fn new(
174 frames: Vec<RgbImage>,
175 source_fps: Option<f64>,
176 sampling: VideoSampling,
177 ) -> Result<Self, MediaRequestError> {
178 if frames.is_empty() {
179 return Err(MediaRequestError::InvalidVideo(
180 "video must contain at least one frame".into(),
181 ));
182 }
183 if source_fps.is_some_and(|fps| !fps.is_finite() || fps <= 0.0) {
184 return Err(MediaRequestError::InvalidVideo(
185 "source frame rate must be finite and positive".into(),
186 ));
187 }
188 match sampling {
189 VideoSampling::Fps(fps) if !fps.is_finite() || fps <= 0.0 => {
190 return Err(MediaRequestError::InvalidVideo(
191 "sampling frame rate must be finite and positive".into(),
192 ));
193 }
194 VideoSampling::FrameCount(0) => {
195 return Err(MediaRequestError::InvalidVideo(
196 "sampling frame count must be positive".into(),
197 ));
198 }
199 _ => {}
200 }
201 Ok(Self {
202 frames,
203 source_fps,
204 sampling,
205 })
206 }
207
208 pub fn frames(&self) -> &[RgbImage] {
210 &self.frames
211 }
212
213 pub const fn source_fps(&self) -> Option<f64> {
215 self.source_fps
216 }
217
218 pub const fn sampling(&self) -> VideoSampling {
220 self.sampling
221 }
222}
223
224#[derive(Debug, Clone, PartialEq)]
226pub enum Media {
227 Image(RgbImage),
229 Video(Video),
231 Audio(Audio),
233}
234
235#[derive(Debug, Clone, PartialEq)]
237pub enum MultimodalSegment {
238 Text(String),
240 TokenIds(Vec<u32>),
242 Media(Media),
244}
245
246#[derive(Debug, Clone, PartialEq)]
248pub struct MultimodalRequest {
249 segments: Vec<MultimodalSegment>,
250}
251
252impl MultimodalRequest {
253 pub fn new(segments: Vec<MultimodalSegment>) -> Result<Self, MediaRequestError> {
255 if segments.is_empty() {
256 return Err(MediaRequestError::EmptyRequest);
257 }
258 Ok(Self { segments })
259 }
260
261 pub fn from_chat(
263 rendered_prompt: &str,
264 bindings: &[MediaBinding],
265 ) -> Result<Self, MediaRequestError> {
266 validate_bindings(rendered_prompt, bindings)?;
267 let mut segments = Vec::with_capacity(bindings.len().saturating_mul(2) + 1);
268 let mut cursor = 0;
269 for (index, binding) in bindings.iter().enumerate() {
270 let remainder = &rendered_prompt[cursor..];
271 let relative = remainder.find(binding.placeholder()).ok_or_else(|| {
272 MediaRequestError::PlaceholderOrder {
273 index,
274 placeholder: binding.placeholder().into(),
275 }
276 })?;
277 let start = cursor + relative;
278 if start > cursor {
279 segments.push(MultimodalSegment::Text(
280 rendered_prompt[cursor..start].into(),
281 ));
282 }
283 segments.push(MultimodalSegment::Media(binding.media().clone()));
284 cursor = start + binding.placeholder().len();
285 }
286 if cursor < rendered_prompt.len() {
287 segments.push(MultimodalSegment::Text(rendered_prompt[cursor..].into()));
288 }
289 Self::new(segments)
290 }
291
292 pub fn segments(&self) -> &[MultimodalSegment] {
294 &self.segments
295 }
296
297 pub fn tokenize<E>(
299 &self,
300 mut encode: impl FnMut(&str) -> Result<Vec<u32>, E>,
301 ) -> Result<TokenizedMultimodalRequest, E> {
302 let mut segments = Vec::with_capacity(self.segments.len());
303 for segment in &self.segments {
304 segments.push(match segment {
305 MultimodalSegment::Text(text) => {
306 TokenizedMultimodalSegment::TokenIds(encode(text)?)
307 }
308 MultimodalSegment::TokenIds(ids) => {
309 TokenizedMultimodalSegment::TokenIds(ids.clone())
310 }
311 MultimodalSegment::Media(media) => TokenizedMultimodalSegment::Media(media.clone()),
312 });
313 }
314 Ok(TokenizedMultimodalRequest { segments })
315 }
316}
317
318#[derive(Debug, Clone, PartialEq)]
320pub struct MediaBinding {
321 placeholder: String,
322 media: Media,
323}
324
325impl MediaBinding {
326 pub fn new(placeholder: impl Into<String>, media: Media) -> Self {
328 Self {
329 placeholder: placeholder.into(),
330 media,
331 }
332 }
333
334 pub fn placeholder(&self) -> &str {
336 &self.placeholder
337 }
338
339 pub const fn media(&self) -> &Media {
341 &self.media
342 }
343}
344
345#[derive(Debug, Clone, PartialEq)]
347pub enum TokenizedMultimodalSegment {
348 TokenIds(Vec<u32>),
350 Media(Media),
352}
353
354#[derive(Debug, Clone, PartialEq)]
356pub struct TokenizedMultimodalRequest {
357 segments: Vec<TokenizedMultimodalSegment>,
358}
359
360impl TokenizedMultimodalRequest {
361 pub fn segments(&self) -> &[TokenizedMultimodalSegment] {
363 &self.segments
364 }
365
366 pub fn semantic_content_fingerprint(&self) -> String {
368 let mut digest = Sha256::new();
369 digest.update(b"eredu-tokenized-multimodal-input-v1\0");
370 digest.update((self.segments.len() as u64).to_le_bytes());
371 for segment in &self.segments {
372 match segment {
373 TokenizedMultimodalSegment::TokenIds(tokens) => {
374 digest.update([0]);
375 digest.update((tokens.len() as u64).to_le_bytes());
376 for token in tokens {
377 digest.update(token.to_le_bytes());
378 }
379 }
380 TokenizedMultimodalSegment::Media(media) => hash_media(&mut digest, media),
381 }
382 }
383 let bytes = digest.finalize();
384 let mut encoded = String::with_capacity(71);
385 encoded.push_str("sha256:");
386 for byte in bytes {
387 use std::fmt::Write as _;
388 write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
389 }
390 encoded
391 }
392}
393
394fn hash_image(digest: &mut Sha256, image: &RgbImage) {
395 digest.update(image.width.to_le_bytes());
396 digest.update(image.height.to_le_bytes());
397 digest.update((image.pixels.len() as u64).to_le_bytes());
398 digest.update(&image.pixels);
399}
400
401fn hash_media(digest: &mut Sha256, media: &Media) {
402 match media {
403 Media::Image(image) => {
404 digest.update([1]);
405 hash_image(digest, image);
406 }
407 Media::Video(video) => {
408 digest.update([2]);
409 digest.update((video.frames.len() as u64).to_le_bytes());
410 match video.source_fps {
411 Some(fps) => {
412 digest.update([1]);
413 digest.update(fps.to_bits().to_le_bytes());
414 }
415 None => digest.update([0]),
416 }
417 match video.sampling {
418 VideoSampling::ProcessorDefault => digest.update([0]),
419 VideoSampling::Fps(fps) => {
420 digest.update([1]);
421 digest.update(fps.to_bits().to_le_bytes());
422 }
423 VideoSampling::FrameCount(count) => {
424 digest.update([2]);
425 digest.update((count as u64).to_le_bytes());
426 }
427 VideoSampling::All => digest.update([3]),
428 }
429 for frame in &video.frames {
430 hash_image(digest, frame);
431 }
432 }
433 Media::Audio(audio) => {
434 digest.update([3]);
435 digest.update(audio.sample_rate.to_le_bytes());
436 digest.update((audio.samples.len() as u64).to_le_bytes());
437 for sample in &audio.samples {
438 digest.update(sample.to_bits().to_le_bytes());
439 }
440 }
441 }
442}
443
444fn validate_bindings(
445 rendered_prompt: &str,
446 bindings: &[MediaBinding],
447) -> Result<(), MediaRequestError> {
448 for (index, binding) in bindings.iter().enumerate() {
449 if binding.placeholder.is_empty() {
450 return Err(MediaRequestError::EmptyPlaceholder { index });
451 }
452 if bindings[..index]
453 .iter()
454 .any(|earlier| earlier.placeholder == binding.placeholder)
455 {
456 continue;
457 }
458 let expected = bindings
459 .iter()
460 .filter(|candidate| candidate.placeholder == binding.placeholder)
461 .count();
462 let actual = rendered_prompt.matches(&binding.placeholder).count();
463 if actual != expected {
464 return Err(MediaRequestError::PlaceholderCount {
465 placeholder: binding.placeholder.clone(),
466 expected,
467 actual,
468 });
469 }
470 }
471 Ok(())
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn image(value: u8) -> Media {
479 Media::Image(RgbImage::new(vec![value; 3], 1, 1).unwrap())
480 }
481
482 #[test]
483 fn chat_composition_and_tokenization_preserve_exact_order() {
484 let request = MultimodalRequest::from_chat(
485 "before<image>middle<image>after",
486 &[
487 MediaBinding::new("<image>", image(1)),
488 MediaBinding::new("<image>", image(2)),
489 ],
490 )
491 .unwrap();
492 let tokenized = request
493 .tokenize::<std::convert::Infallible>(|text| {
494 Ok(text
495 .as_bytes()
496 .iter()
497 .map(|byte| u32::from(*byte))
498 .collect())
499 })
500 .unwrap();
501 assert_eq!(tokenized.segments().len(), 5);
502 assert!(matches!(
503 &tokenized.segments()[0],
504 TokenizedMultimodalSegment::TokenIds(ids)
505 if ids == &[98, 101, 102, 111, 114, 101]
506 ));
507 assert!(matches!(
508 &tokenized.segments()[1],
509 TokenizedMultimodalSegment::Media(Media::Image(image)) if image.pixels() == [1, 1, 1]
510 ));
511 assert!(matches!(
512 &tokenized.segments()[3],
513 TokenizedMultimodalSegment::Media(Media::Image(image)) if image.pixels() == [2, 2, 2]
514 ));
515 }
516
517 #[test]
518 fn validation_rejects_bad_media_and_placeholder_contracts() {
519 assert!(matches!(
520 RgbImage::new(vec![0; 2], 1, 1),
521 Err(MediaRequestError::InvalidRgbImage { .. })
522 ));
523 assert!(Audio::new(vec![f32::NAN], 16_000).is_err());
524 assert!(Video::new(Vec::new(), None, VideoSampling::All).is_err());
525 assert!(matches!(
526 MultimodalRequest::from_chat(
527 "<image>",
528 &[
529 MediaBinding::new("<image>", image(1)),
530 MediaBinding::new("<image>", image(2)),
531 ],
532 ),
533 Err(MediaRequestError::PlaceholderCount { .. })
534 ));
535 }
536
537 #[test]
538 fn request_preserves_text_only_segments() {
539 let request = MultimodalRequest::new(vec![
540 MultimodalSegment::TokenIds(vec![1, 2]),
541 MultimodalSegment::Text("tail".into()),
542 ])
543 .unwrap();
544
545 assert_eq!(request.segments().len(), 2);
546 assert!(matches!(
547 &request.segments()[0],
548 MultimodalSegment::TokenIds(ids) if ids == &[1, 2]
549 ));
550 }
551
552 #[test]
553 fn semantic_fingerprint_is_stable_and_distinguishes_equal_geometry_payloads() {
554 fn request(image_value: u8, audio_value: f32) -> TokenizedMultimodalRequest {
555 MultimodalRequest::new(vec![
556 MultimodalSegment::TokenIds(vec![7, 11]),
557 MultimodalSegment::Media(Media::Image(
558 RgbImage::new(vec![image_value; 12], 2, 2).unwrap(),
559 )),
560 MultimodalSegment::Media(Media::Audio(
561 Audio::new(vec![audio_value; 4], 16_000).unwrap(),
562 )),
563 ])
564 .unwrap()
565 .tokenize::<std::convert::Infallible>(|_| unreachable!())
566 .unwrap()
567 }
568
569 let first = request(3, 0.25);
570 let same = request(3, 0.25);
571 let changed_image = request(4, 0.25);
572 let changed_audio = request(3, 0.5);
573
574 let fingerprint = first.semantic_content_fingerprint();
575 assert_eq!(fingerprint, same.semantic_content_fingerprint());
576 assert_eq!(fingerprint, first.semantic_content_fingerprint());
577 assert_ne!(fingerprint, changed_image.semantic_content_fingerprint());
578 assert_ne!(fingerprint, changed_audio.semantic_content_fingerprint());
579 assert_eq!(fingerprint.len(), 71);
580 assert!(fingerprint.starts_with("sha256:"));
581 }
582}