Skip to main content

oximedia_edit/
render_source.rs

1//! Render source resolution for the timeline rendering pipeline.
2//!
3//! `RenderSource` abstracts over all input media types that can feed into the
4//! renderer: decoded image files (PNG, JPEG), decoded WAV audio, deterministic
5//! test patterns (SMPTE colour bars, 1 kHz sine), and unsupported/unknown
6//! sources.
7//!
8//! # Caching
9//!
10//! `from_path` returns an `Arc<RenderSource>` so that multiple clips that
11//! reference the same file share a single decoded copy.  The per-`TimelineRenderer`
12//! `source_cache: HashMap<PathBuf, Arc<RenderSource>>` prevents repeated
13//! I/O and decode work.
14
15use std::io::Cursor;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use crate::error::{EditError, EditResult};
20
21// ─── Decoded image data ───────────────────────────────────────────────────────
22
23/// Decoded image data in RGBA8 packed format (`width * height * 4` bytes).
24#[derive(Debug, Clone)]
25pub struct DecodedImageData {
26    /// RGBA8 pixel bytes, row-major.
27    pub pixels: Vec<u8>,
28    /// Image width in pixels.
29    pub width: u32,
30    /// Image height in pixels.
31    pub height: u32,
32}
33
34impl DecodedImageData {
35    /// Create a new decoded image (validates dimensions vs. buffer length).
36    pub fn new(pixels: Vec<u8>, width: u32, height: u32) -> EditResult<Self> {
37        let expected = (width as usize) * (height as usize) * 4;
38        if pixels.len() != expected {
39            return Err(EditError::InvalidOperation(format!(
40                "DecodedImageData: expected {expected} bytes for {width}x{height} RGBA8, got {}",
41                pixels.len()
42            )));
43        }
44        Ok(Self {
45            pixels,
46            width,
47            height,
48        })
49    }
50}
51
52// ─── Decoded WAV data ─────────────────────────────────────────────────────────
53
54/// Decoded WAV audio data.
55#[derive(Debug, Clone)]
56pub struct WavData {
57    /// Interleaved f32 samples.  Length = frame_count * channels.
58    pub samples: Vec<f32>,
59    /// Sample rate reported by the WAV header.
60    pub sample_rate: u32,
61    /// Number of channels reported by the WAV header.
62    pub channels: u16,
63}
64
65// ─── RenderSource ─────────────────────────────────────────────────────────────
66
67/// A resolved and decoded media source used by the render pipeline.
68///
69/// Cheaply cloneable via `Arc`.
70#[derive(Debug)]
71pub enum RenderSource {
72    /// A decoded still image (PNG or JPEG) in RGBA8.
73    Image(DecodedImageData),
74    /// Decoded WAV audio.
75    Wav(WavData),
76    /// Deterministic test pattern (SMPTE colour bars / 1 kHz sine).
77    TestPattern,
78    /// Source file exists but its format is not supported.
79    Unsupported {
80        /// Path to the unsupported file.
81        path: PathBuf,
82    },
83}
84
85impl RenderSource {
86    /// Resolve a file-system path to a `RenderSource`.
87    ///
88    /// Recognised extensions (case-insensitive): `.png`, `.jpg`/`.jpeg`,
89    /// `.wav`.  Everything else becomes [`RenderSource::Unsupported`].
90    ///
91    /// Returns an `Arc` so callers can share the decoded data cheaply.
92    pub fn from_path(path: &Path) -> EditResult<Arc<Self>> {
93        let ext = path
94            .extension()
95            .and_then(|e| e.to_str())
96            .map(|e| e.to_ascii_lowercase());
97
98        match ext.as_deref() {
99            Some("png") => {
100                let data = std::fs::read(path).map_err(|e| {
101                    EditError::InvalidOperation(format!("RenderSource: cannot read {path:?}: {e}"))
102                })?;
103                let source = decode_png(&data)?;
104                Ok(Arc::new(RenderSource::Image(source)))
105            }
106            Some("jpg") | Some("jpeg") => {
107                let data = std::fs::read(path).map_err(|e| {
108                    EditError::InvalidOperation(format!("RenderSource: cannot read {path:?}: {e}"))
109                })?;
110                let source = decode_jpeg(&data)?;
111                Ok(Arc::new(RenderSource::Image(source)))
112            }
113            Some("wav") => {
114                let data = std::fs::read(path).map_err(|e| {
115                    EditError::InvalidOperation(format!("RenderSource: cannot read {path:?}: {e}"))
116                })?;
117                let source = decode_wav(&data)?;
118                Ok(Arc::new(RenderSource::Wav(source)))
119            }
120            _ => Ok(Arc::new(RenderSource::Unsupported {
121                path: path.to_path_buf(),
122            })),
123        }
124    }
125
126    /// Produce an RGBA8 video frame of size `width × height` at `source_pts`.
127    ///
128    /// * For [`RenderSource::Image`] the image is scaled (nearest-neighbour) to
129    ///   fill the requested dimensions.
130    /// * For [`RenderSource::TestPattern`] a deterministic SMPTE colour bar
131    ///   pattern is generated.
132    /// * All other variants return a black frame.
133    ///
134    /// The returned buffer is `width * height * 4` bytes.
135    #[must_use]
136    pub fn sample_video(&self, _source_pts: i64, width: u32, height: u32) -> Vec<u8> {
137        match self {
138            RenderSource::Image(img) => scale_nearest_rgba8(img, width, height),
139            RenderSource::TestPattern => generate_smpte_bars(width, height),
140            _ => vec![0u8; (width as usize) * (height as usize) * 4],
141        }
142    }
143
144    /// Produce interleaved f32 audio samples for `num_samples` stereo frames
145    /// starting at `source_pts`.
146    ///
147    /// * For [`RenderSource::Wav`] the decoded samples are sliced/zero-padded at
148    ///   the requested offset.  Channel count mismatches are handled by up-mixing
149    ///   mono→stereo or truncating to the requested channel count.
150    /// * For [`RenderSource::TestPattern`] a 1 kHz sine wave is generated.
151    /// * All other variants return silence.
152    ///
153    /// `num_samples` is per-channel frame count.  The returned buffer has
154    /// `num_samples * channels` elements.
155    #[must_use]
156    pub fn sample_audio(
157        &self,
158        source_pts: i64,
159        num_samples: usize,
160        channels: u16,
161        sample_rate: u32,
162    ) -> Vec<f32> {
163        match self {
164            RenderSource::Wav(wav) => {
165                slice_wav_samples(wav, source_pts, num_samples, channels, sample_rate)
166            }
167            RenderSource::TestPattern => {
168                generate_sine(source_pts, num_samples, channels, sample_rate)
169            }
170            _ => vec![0.0_f32; num_samples * channels as usize],
171        }
172    }
173}
174
175// ─── PNG decode helper ────────────────────────────────────────────────────────
176
177fn decode_png(data: &[u8]) -> EditResult<DecodedImageData> {
178    use oximedia_image::png::PngDecoder;
179
180    let decoder = PngDecoder::new();
181    let img = decoder
182        .decode(data)
183        .map_err(|e| EditError::InvalidOperation(format!("PNG decode error: {e:?}")))?;
184
185    let w = img.width;
186    let h = img.height;
187    let pixels_rgba8 = convert_png_to_rgba8(&img.pixels, img.color_type, w, h)?;
188    DecodedImageData::new(pixels_rgba8, w, h)
189}
190
191/// Convert raw PNG pixel bytes to RGBA8.
192fn convert_png_to_rgba8(
193    raw: &[u8],
194    color_type: oximedia_image::png::PngColorType,
195    width: u32,
196    height: u32,
197) -> EditResult<Vec<u8>> {
198    use oximedia_image::png::PngColorType;
199
200    let pixel_count = (width as usize) * (height as usize);
201    let mut out = Vec::with_capacity(pixel_count * 4);
202
203    match color_type {
204        PngColorType::Rgba => {
205            // Already RGBA8 — just clone.
206            if raw.len() >= pixel_count * 4 {
207                out.extend_from_slice(&raw[..pixel_count * 4]);
208            } else {
209                out.extend_from_slice(raw);
210                // zero-pad if short
211                out.resize(pixel_count * 4, 0);
212            }
213        }
214        PngColorType::Rgb => {
215            // RGB → RGBA8 (alpha = 255).
216            let src_len = raw.len().min(pixel_count * 3);
217            let src_pixels = src_len / 3;
218            for i in 0..src_pixels {
219                out.push(raw[i * 3]);
220                out.push(raw[i * 3 + 1]);
221                out.push(raw[i * 3 + 2]);
222                out.push(255);
223            }
224            // Pad remaining pixels with opaque black.
225            for _ in src_pixels..pixel_count {
226                out.extend_from_slice(&[0, 0, 0, 255]);
227            }
228        }
229        PngColorType::Grayscale => {
230            // L → RGBA8.
231            let src_len = raw.len().min(pixel_count);
232            for i in 0..src_len {
233                let v = raw[i];
234                out.extend_from_slice(&[v, v, v, 255]);
235            }
236            for _ in src_len..pixel_count {
237                out.extend_from_slice(&[0, 0, 0, 255]);
238            }
239        }
240        PngColorType::GrayscaleAlpha => {
241            let src_len = raw.len().min(pixel_count * 2);
242            let src_pixels = src_len / 2;
243            for i in 0..src_pixels {
244                let v = raw[i * 2];
245                let a = raw[i * 2 + 1];
246                out.extend_from_slice(&[v, v, v, a]);
247            }
248            for _ in src_pixels..pixel_count {
249                out.extend_from_slice(&[0, 0, 0, 255]);
250            }
251        }
252        PngColorType::Indexed => {
253            // No palette expansion here; treat each byte as a luma value.
254            let src_len = raw.len().min(pixel_count);
255            for i in 0..src_len {
256                let v = raw[i];
257                out.extend_from_slice(&[v, v, v, 255]);
258            }
259            for _ in src_len..pixel_count {
260                out.extend_from_slice(&[0, 0, 0, 255]);
261            }
262        }
263    }
264
265    Ok(out)
266}
267
268// ─── JPEG decode helper ───────────────────────────────────────────────────────
269
270fn decode_jpeg(data: &[u8]) -> EditResult<DecodedImageData> {
271    use oximedia_image::jpeg::JpegDecoder;
272
273    let decoder = JpegDecoder::new();
274    let frame = decoder
275        .decode(data)
276        .map_err(|e| EditError::InvalidOperation(format!("JPEG decode error: {e:?}")))?;
277
278    let w = frame.width;
279    let h = frame.height;
280    let pixel_count = (w as usize) * (h as usize);
281    let mut out = Vec::with_capacity(pixel_count * 4);
282
283    match frame.components {
284        3 => {
285            // RGB → RGBA8.
286            let src_len = frame.pixels.len().min(pixel_count * 3);
287            let src_pixels = src_len / 3;
288            for i in 0..src_pixels {
289                out.push(frame.pixels[i * 3]);
290                out.push(frame.pixels[i * 3 + 1]);
291                out.push(frame.pixels[i * 3 + 2]);
292                out.push(255);
293            }
294            for _ in src_pixels..pixel_count {
295                out.extend_from_slice(&[0, 0, 0, 255]);
296            }
297        }
298        1 => {
299            // Grayscale → RGBA8.
300            let src_len = frame.pixels.len().min(pixel_count);
301            for i in 0..src_len {
302                let v = frame.pixels[i];
303                out.extend_from_slice(&[v, v, v, 255]);
304            }
305            for _ in src_len..pixel_count {
306                out.extend_from_slice(&[0, 0, 0, 255]);
307            }
308        }
309        4 => {
310            // CMYK — convert to RGBA8 (rough approximation).
311            let src_len = frame.pixels.len().min(pixel_count * 4);
312            let src_pixels = src_len / 4;
313            for i in 0..src_pixels {
314                let c = frame.pixels[i * 4] as f32 / 255.0;
315                let m = frame.pixels[i * 4 + 1] as f32 / 255.0;
316                let y = frame.pixels[i * 4 + 2] as f32 / 255.0;
317                let k = frame.pixels[i * 4 + 3] as f32 / 255.0;
318                #[allow(clippy::cast_possible_truncation)]
319                #[allow(clippy::cast_sign_loss)]
320                let r = ((1.0 - c) * (1.0 - k) * 255.0).round().clamp(0.0, 255.0) as u8;
321                #[allow(clippy::cast_possible_truncation)]
322                #[allow(clippy::cast_sign_loss)]
323                let g = ((1.0 - m) * (1.0 - k) * 255.0).round().clamp(0.0, 255.0) as u8;
324                #[allow(clippy::cast_possible_truncation)]
325                #[allow(clippy::cast_sign_loss)]
326                let b = ((1.0 - y) * (1.0 - k) * 255.0).round().clamp(0.0, 255.0) as u8;
327                out.extend_from_slice(&[r, g, b, 255]);
328            }
329            for _ in src_pixels..pixel_count {
330                out.extend_from_slice(&[0, 0, 0, 255]);
331            }
332        }
333        _ => {
334            // Unknown component count — black frame.
335            out.extend(std::iter::repeat(0u8).take(pixel_count * 4));
336        }
337    }
338
339    DecodedImageData::new(out, w, h)
340}
341
342// ─── WAV decode helper ────────────────────────────────────────────────────────
343
344fn decode_wav(data: &[u8]) -> EditResult<WavData> {
345    use oximedia_audio::wav::WavReader;
346
347    let cursor = Cursor::new(data);
348    let mut reader = WavReader::new(cursor)
349        .map_err(|e| EditError::InvalidOperation(format!("WAV read error: {e:?}")))?;
350
351    let spec = reader.spec();
352    let samples = reader
353        .read_samples_f32()
354        .map_err(|e| EditError::InvalidOperation(format!("WAV sample read error: {e:?}")))?;
355
356    Ok(WavData {
357        samples,
358        sample_rate: spec.sample_rate,
359        channels: spec.channels,
360    })
361}
362
363// ─── Nearest-neighbour image scaler ──────────────────────────────────────────
364
365/// Scale RGBA8 image to target dimensions using nearest-neighbour.
366fn scale_nearest_rgba8(img: &DecodedImageData, target_w: u32, target_h: u32) -> Vec<u8> {
367    if img.width == 0 || img.height == 0 {
368        return vec![0u8; (target_w as usize) * (target_h as usize) * 4];
369    }
370
371    let src_w = img.width as usize;
372    let src_h = img.height as usize;
373    let dst_w = target_w as usize;
374    let dst_h = target_h as usize;
375    let mut out = vec![0u8; dst_w * dst_h * 4];
376
377    for dy in 0..dst_h {
378        let sy = (dy * src_h / dst_h).min(src_h - 1);
379        for dx in 0..dst_w {
380            let sx = (dx * src_w / dst_w).min(src_w - 1);
381            let src_idx = (sy * src_w + sx) * 4;
382            let dst_idx = (dy * dst_w + dx) * 4;
383            out[dst_idx..dst_idx + 4].copy_from_slice(&img.pixels[src_idx..src_idx + 4]);
384        }
385    }
386
387    out
388}
389
390// ─── SMPTE colour bar generator ──────────────────────────────────────────────
391
392/// SMPTE EG 1-1990 colour bars (75% saturation, 100% amplitude).
393///
394/// Returns RGBA8 packed bytes of size `width * height * 4`.
395#[must_use]
396pub fn generate_smpte_bars(width: u32, height: u32) -> Vec<u8> {
397    // Eight standard SMPTE bar colours (RGBA8, linear gamma approximation).
398    // Order: grey, yellow, cyan, green, magenta, red, blue, black (bottom row)
399    const BARS: [[u8; 4]; 8] = [
400        [192, 192, 192, 255], // 75% grey
401        [192, 192, 0, 255],   // 75% yellow
402        [0, 192, 192, 255],   // 75% cyan
403        [0, 192, 0, 255],     // 75% green
404        [192, 0, 192, 255],   // 75% magenta
405        [192, 0, 0, 255],     // 75% red
406        [0, 0, 192, 255],     // 75% blue
407        [0, 0, 0, 255],       // black
408    ];
409
410    let w = width as usize;
411    let h = height as usize;
412    let mut out = vec![0u8; w * h * 4];
413
414    // Top 7/8 of frame — seven colour bars.
415    let top_rows = (h * 7) / 8;
416    for y in 0..top_rows {
417        for x in 0..w {
418            let bar = (x * 7 / w).min(6);
419            let idx = (y * w + x) * 4;
420            out[idx..idx + 4].copy_from_slice(&BARS[bar]);
421        }
422    }
423    // Bottom 1/8 — four sub-bars: -I, white, +Q, black.
424    let bottom_colours: [[u8; 4]; 4] = [
425        [0, 0, 128, 255],     // -I (dark blue)
426        [255, 255, 255, 255], // 100% white
427        [19, 0, 77, 255],     // +Q (dark purple)
428        [0, 0, 0, 255],       // black
429    ];
430    for y in top_rows..h {
431        for x in 0..w {
432            let seg = (x * 4 / w).min(3);
433            let idx = (y * w + x) * 4;
434            out[idx..idx + 4].copy_from_slice(&bottom_colours[seg]);
435        }
436    }
437
438    out
439}
440
441// ─── 1 kHz sine generator ─────────────────────────────────────────────────────
442
443/// Generate `num_samples` interleaved f32 samples of a 1 kHz sine wave.
444///
445/// `source_pts` is used as the starting sample offset (for determinism across
446/// calls for the same clip at different seek positions).
447///
448/// The returned buffer has `num_samples * channels` elements.
449#[must_use]
450pub fn generate_sine(
451    source_pts: i64,
452    num_samples: usize,
453    channels: u16,
454    sample_rate: u32,
455) -> Vec<f32> {
456    use std::f64::consts::TAU;
457
458    let sr = sample_rate as f64;
459    let freq = 1000.0_f64;
460    let amplitude = 0.25_f64; // -12 dBFS to avoid clipping
461    let ch = channels as usize;
462    let mut out = Vec::with_capacity(num_samples * ch);
463    let start_sample = source_pts.max(0) as u64;
464
465    for i in 0..num_samples {
466        let t = (start_sample + i as u64) as f64 / sr;
467        #[allow(clippy::cast_possible_truncation)]
468        let sample = (TAU * freq * t).sin() * amplitude;
469        #[allow(clippy::cast_possible_truncation)]
470        let sample_f32 = sample as f32;
471        for _ in 0..ch {
472            out.push(sample_f32);
473        }
474    }
475
476    out
477}
478
479// ─── WAV sample slicer ────────────────────────────────────────────────────────
480
481/// Extract a contiguous slice of audio from a decoded WAV, handling:
482/// - Offset past end-of-file (returns silence).
483/// - Mono → stereo up-mix.
484/// - Sample-rate mismatch (no resampling; just shifts offset scaling).
485fn slice_wav_samples(
486    wav: &WavData,
487    source_pts: i64,
488    num_samples: usize,
489    target_channels: u16,
490    _target_sample_rate: u32,
491) -> Vec<f32> {
492    let src_ch = wav.channels as usize;
493    let tgt_ch = target_channels as usize;
494    let total_frames = wav.samples.len() / src_ch.max(1);
495    let start_frame = source_pts.max(0) as usize;
496    let out_len = num_samples * tgt_ch;
497
498    if start_frame >= total_frames || src_ch == 0 {
499        return vec![0.0_f32; out_len];
500    }
501
502    let available = (total_frames - start_frame).min(num_samples);
503    let mut out = vec![0.0_f32; out_len];
504
505    for i in 0..available {
506        let src_frame = start_frame + i;
507        for c in 0..tgt_ch {
508            let src_c = if src_ch == 1 {
509                // Mono up-mix: replicate channel 0.
510                0
511            } else {
512                c.min(src_ch - 1)
513            };
514            let src_idx = src_frame * src_ch + src_c;
515            let dst_idx = i * tgt_ch + c;
516            if src_idx < wav.samples.len() {
517                out[dst_idx] = wav.samples[src_idx];
518            }
519        }
520    }
521
522    out
523}
524
525// ─── Unit tests ───────────────────────────────────────────────────────────────
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_smpte_bars_dimensions() {
533        let bars = generate_smpte_bars(16, 8);
534        assert_eq!(bars.len(), 16 * 8 * 4);
535    }
536
537    #[test]
538    fn test_smpte_bars_not_all_black() {
539        let bars = generate_smpte_bars(8, 4);
540        let all_zero = bars.iter().all(|&b| b == 0);
541        assert!(!all_zero, "SMPTE bars should not be all black");
542    }
543
544    #[test]
545    fn test_generate_sine_length() {
546        let samples = generate_sine(0, 480, 2, 48_000);
547        assert_eq!(samples.len(), 480 * 2);
548    }
549
550    #[test]
551    fn test_generate_sine_deterministic() {
552        let a = generate_sine(0, 48, 1, 48_000);
553        let b = generate_sine(0, 48, 1, 48_000);
554        assert_eq!(a, b);
555    }
556
557    #[test]
558    fn test_generate_sine_amplitude_bounded() {
559        let samples = generate_sine(0, 4800, 1, 48_000);
560        for s in &samples {
561            assert!(s.abs() <= 0.26, "sample {s} exceeds expected amplitude");
562        }
563    }
564
565    #[test]
566    fn test_render_source_test_pattern_video() {
567        let src = RenderSource::TestPattern;
568        let frame = src.sample_video(0, 8, 4);
569        assert_eq!(frame.len(), 8 * 4 * 4);
570    }
571
572    #[test]
573    fn test_render_source_test_pattern_audio() {
574        let src = RenderSource::TestPattern;
575        let audio = src.sample_audio(0, 48, 2, 48_000);
576        assert_eq!(audio.len(), 48 * 2);
577    }
578
579    #[test]
580    fn test_render_source_unsupported_video_is_black() {
581        let src = RenderSource::Unsupported {
582            path: PathBuf::from("foo.xyz"),
583        };
584        let frame = src.sample_video(0, 4, 2);
585        assert!(
586            frame.iter().all(|&b| b == 0),
587            "unsupported source should produce black frame"
588        );
589    }
590
591    #[test]
592    fn test_render_source_unsupported_audio_is_silence() {
593        let src = RenderSource::Unsupported {
594            path: PathBuf::from("foo.xyz"),
595        };
596        let audio = src.sample_audio(0, 48, 2, 48_000);
597        assert!(
598            audio.iter().all(|&s| s == 0.0),
599            "unsupported source should produce silence"
600        );
601    }
602
603    #[test]
604    fn test_scale_nearest_identity() {
605        let img = DecodedImageData {
606            pixels: vec![255, 0, 0, 255, 0, 255, 0, 255],
607            width: 2,
608            height: 1,
609        };
610        let out = scale_nearest_rgba8(&img, 2, 1);
611        assert_eq!(out, img.pixels);
612    }
613
614    #[test]
615    fn test_scale_nearest_upscale() {
616        let img = DecodedImageData {
617            pixels: vec![255, 0, 0, 255],
618            width: 1,
619            height: 1,
620        };
621        let out = scale_nearest_rgba8(&img, 2, 2);
622        assert_eq!(out.len(), 2 * 2 * 4);
623        // All pixels should be the same red.
624        for chunk in out.chunks_exact(4) {
625            assert_eq!(chunk, &[255, 0, 0, 255]);
626        }
627    }
628
629    #[test]
630    fn test_slice_wav_silence_when_offset_past_end() {
631        let wav = WavData {
632            samples: vec![0.5; 16],
633            sample_rate: 48_000,
634            channels: 1,
635        };
636        let out = slice_wav_samples(&wav, 9999, 48, 2, 48_000);
637        assert!(out.iter().all(|&s| s == 0.0));
638    }
639
640    #[test]
641    fn test_slice_wav_mono_to_stereo_upmix() {
642        let wav = WavData {
643            samples: vec![1.0; 8],
644            sample_rate: 48_000,
645            channels: 1,
646        };
647        let out = slice_wav_samples(&wav, 0, 4, 2, 48_000);
648        // Each stereo frame: [1.0, 1.0]
649        assert_eq!(out.len(), 8);
650        for &s in &out {
651            assert!((s - 1.0).abs() < 1e-6);
652        }
653    }
654}