Skip to main content

oximedia_edit/
render.rs

1//! Timeline rendering.
2//!
3//! Renders the timeline to a stream of video and audio frames.
4
5use bytes::Bytes;
6use oximedia_audio::{AudioBuffer, AudioFrame, ChannelLayout};
7use oximedia_codec::VideoFrame;
8use oximedia_core::{PixelFormat, Rational, SampleFormat, Timestamp};
9use std::collections::{HashMap, VecDeque};
10use std::path::PathBuf;
11use std::sync::Arc;
12#[cfg(not(target_arch = "wasm32"))]
13use tokio::sync::mpsc;
14
15use crate::clip::Clip;
16use crate::error::EditResult;
17use crate::frame_prefetch::{PrefetchConfig, PrefetchEngine};
18use crate::incremental_render::DirtyRegion;
19use crate::parallel_render::{
20    render_tracks_parallel, ClipWithSource, TrackKind, TrackRenderInput, TrackRenderOutput,
21};
22use crate::render_source::RenderSource;
23use crate::timeline::{Timeline, TimelineConfig, TrackType};
24use crate::transition::Transition;
25
26/// Timeline renderer.
27pub struct TimelineRenderer {
28    /// Timeline to render.
29    timeline: Arc<Timeline>,
30    /// Render configuration.
31    config: RenderConfig,
32    /// Frame cache.
33    cache: FrameCache,
34    /// Raw byte-buffer cache for source frames, keyed by `(clip_id, source_pts)`.
35    raw_frame_cache: RawFrameCache,
36    /// Per-path decoded source cache.  Shared via `Arc` so clips pointing at the
37    /// same file decode it only once.
38    source_cache: HashMap<PathBuf, Arc<RenderSource>>,
39    /// Dirty regions: only frames in these ranges need re-rendering.
40    /// An empty list means everything is clean (or no incremental tracking active).
41    dirty_regions: Vec<DirtyRegion>,
42    /// Predictive prefetch engine.  Advances after each render call to warm the
43    /// cache for upcoming frames.
44    prefetch: PrefetchEngine,
45    /// When `true` multi-track decode is routed through `render_tracks_parallel`
46    /// instead of the sequential compositor loop.
47    use_parallel: bool,
48}
49
50impl TimelineRenderer {
51    /// Create a new timeline renderer.
52    #[must_use]
53    pub fn new(timeline: Arc<Timeline>, config: RenderConfig) -> Self {
54        let cache_size = config.cache_size;
55        let max_pos = timeline.duration.max(0) as i64;
56        let prefetch_config = PrefetchConfig::for_playback(30.0, 1.0);
57        Self {
58            timeline,
59            config,
60            cache: FrameCache::new(cache_size),
61            raw_frame_cache: RawFrameCache::new(RAW_FRAME_CACHE_CAPACITY),
62            source_cache: HashMap::new(),
63            dirty_regions: Vec::new(),
64            prefetch: PrefetchEngine::new(prefetch_config, max_pos),
65            use_parallel: false,
66        }
67    }
68
69    // ─── Dirty-region tracking API ────────────────────────────────────────────
70
71    /// Mark a frame range `[start_frame, end_frame)` as dirty (needs re-render).
72    ///
73    /// Overlapping or adjacent regions are coalesced automatically.
74    pub fn mark_dirty(&mut self, start_frame: u64, end_frame: u64) {
75        self.dirty_regions
76            .push(DirtyRegion::new(start_frame, end_frame));
77        self.coalesce_dirty();
78    }
79
80    /// Clear all dirty regions (mark entire timeline as clean).
81    pub fn clear_dirty(&mut self) {
82        self.dirty_regions.clear();
83    }
84
85    /// Mark the entire timeline as dirty, forcing a full re-render.
86    pub fn force_full_redraw(&mut self) {
87        let total = self.timeline.duration.unsigned_abs();
88        self.dirty_regions = vec![DirtyRegion::new(0, total.max(1))];
89    }
90
91    /// Returns `true` when the given frame position overlaps any dirty region.
92    ///
93    /// If no dirty regions are tracked (empty list) the frame is always
94    /// considered dirty so the renderer behaves as if all regions need updating.
95    #[must_use]
96    pub fn is_position_dirty(&self, position: i64) -> bool {
97        if self.dirty_regions.is_empty() {
98            return true;
99        }
100        let frame = position.unsigned_abs();
101        self.dirty_regions.iter().any(|r| r.contains(frame))
102    }
103
104    /// Expose the prefetch engine's last known playhead position for tests.
105    #[must_use]
106    pub fn prefetch_playhead(&self) -> i64 {
107        self.prefetch.playhead()
108    }
109
110    /// Enable or disable parallel multi-track rendering.
111    pub fn set_use_parallel(&mut self, enabled: bool) {
112        self.use_parallel = enabled;
113    }
114
115    /// Return whether parallel rendering is active.
116    #[must_use]
117    pub fn use_parallel(&self) -> bool {
118        self.use_parallel
119    }
120
121    // ── Internal dirty-region coalesce ────────────────────────────────────────
122
123    fn coalesce_dirty(&mut self) {
124        if self.dirty_regions.len() <= 1 {
125            return;
126        }
127        self.dirty_regions.sort_by_key(|r| r.start_frame);
128        let mut merged: Vec<DirtyRegion> = Vec::with_capacity(self.dirty_regions.len());
129        for region in &self.dirty_regions {
130            if let Some(last) = merged.last_mut() {
131                if last.end_frame >= region.start_frame {
132                    last.end_frame = last.end_frame.max(region.end_frame);
133                    continue;
134                }
135            }
136            merged.push(*region);
137        }
138        self.dirty_regions = merged;
139    }
140
141    /// Render a frame at a specific timeline position.
142    pub async fn render_frame_at(&mut self, position: i64) -> EditResult<RenderFrame> {
143        // Check cache first
144        if let Some(frame) = self.cache.get(position) {
145            return Ok(frame);
146        }
147
148        // Collect active clips — clone so we don't hold a borrow on `self.timeline`
149        // across the mutable render calls below.
150        let clips_at_pos: Vec<(usize, Clip)> = self
151            .timeline
152            .get_clips_at(position)
153            .into_iter()
154            .map(|(ti, c)| (ti, c.clone()))
155            .collect();
156        let clips_refs: Vec<(usize, &Clip)> = clips_at_pos.iter().map(|(ti, c)| (*ti, c)).collect();
157
158        // Render video layers
159        let video_frame = if self.config.render_video {
160            self.render_video_at(position, &clips_refs).await?
161        } else {
162            None
163        };
164
165        // Render audio
166        let audio_frame = if self.config.render_audio {
167            self.render_audio_at(position, &clips_refs).await?
168        } else {
169            None
170        };
171
172        let frame = RenderFrame {
173            position,
174            timestamp: Timestamp::new(position, self.timeline.timebase),
175            video: video_frame,
176            audio: audio_frame,
177        };
178
179        // Cache the frame
180        self.cache.put(position, frame.clone());
181
182        // Advance the prefetch engine so it warms the cache for upcoming frames.
183        let _ = self.prefetch.update(position);
184
185        Ok(frame)
186    }
187
188    /// Render video frame at position.
189    async fn render_video_at(
190        &mut self,
191        position: i64,
192        clips: &[(usize, &Clip)],
193    ) -> EditResult<Option<VideoFrame>> {
194        let video_clips: Vec<(usize, Clip)> = clips
195            .iter()
196            .filter(|(_, clip)| clip.is_video())
197            .map(|(ti, c)| (*ti, (*c).clone()))
198            .collect();
199
200        if video_clips.is_empty() {
201            return Ok(None);
202        }
203
204        // ── Incremental skip: skip entirely if no dirty region overlaps this position ──
205        if !self.is_position_dirty(position) {
206            return Ok(None);
207        }
208
209        let w = self.config.width;
210        let h = self.config.height;
211
212        // ── Parallel multi-track path ──────────────────────────────────────────
213        if self.use_parallel {
214            return self.render_video_at_parallel(position, w, h);
215        }
216
217        // Collect active transitions for this position (clone so we don't hold
218        // a borrow on `self.timeline` while calling `&mut self` methods later).
219        let active_transitions: Vec<(usize, Transition)> = video_clips
220            .iter()
221            .flat_map(|(track_idx, _)| {
222                self.timeline
223                    .transitions
224                    .get_active_at(*track_idx, position)
225                    .into_iter()
226                    .map(|t| (*track_idx, t.clone()))
227            })
228            .collect();
229
230        // Build the set of clip IDs involved in active transitions so we can
231        // replace them with a single blended layer.
232        // map: clip_a_id → (frame_a, frame_b, transition, progress)
233        let mut in_transition_pair: HashMap<u64, (VideoFrame, VideoFrame, Transition, f64)> =
234            HashMap::new();
235        let mut transitioned_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
236
237        for (_, transition) in &active_transitions {
238            let clip_a_id = transition.clip_a;
239            let clip_b_id = transition.clip_b;
240
241            let clip_a = video_clips
242                .iter()
243                .find(|(_, c)| c.id == clip_a_id)
244                .map(|(_, c)| c.clone());
245            let clip_b = video_clips
246                .iter()
247                .find(|(_, c)| c.id == clip_b_id)
248                .map(|(_, c)| c.clone());
249
250            if let (Some(ca), Some(cb)) = (clip_a, clip_b) {
251                let pos_a = ca.timeline_to_source(position);
252                let pos_b = cb.timeline_to_source(position);
253                let frame_a = self.get_source_frame(&ca, pos_a)?;
254                let frame_b = self.get_source_frame(&cb, pos_b)?;
255                let progress = transition.progress_at(position);
256                transitioned_ids.insert(clip_a_id);
257                transitioned_ids.insert(clip_b_id);
258                // Only store once per transition (first time we see clip_a).
259                in_transition_pair.entry(clip_a_id).or_insert((
260                    frame_a,
261                    frame_b,
262                    transition.clone(),
263                    progress,
264                ));
265            }
266        }
267
268        // Compositor: bottom-to-top (rev() order mirrors layer stack).
269        use oximedia_graphics::hdr_composite::HdrCompositor;
270
271        let mut compositor = HdrCompositor::new(w, h, 1000.0);
272
273        // Iterate bottom-to-top.
274        for (_, clip) in video_clips.iter().rev() {
275            if clip.muted {
276                continue;
277            }
278
279            let source_pos = clip.timeline_to_source(position);
280
281            if transitioned_ids.contains(&clip.id) {
282                // This clip is part of a transition pair.
283                if let Some((fa, fb, trans, progress)) = in_transition_pair.remove(&clip.id) {
284                    let blended = TransitionRenderer::blend_video(&fa, &fb, &trans, progress);
285                    let layer = video_frame_to_hdr_layer(&blended, clip.opacity);
286                    compositor.add_layer(layer);
287                }
288                // Clip B's entry was already consumed with clip A; skip.
289                continue;
290            }
291
292            let source_frame = self.get_source_frame(clip, source_pos)?;
293            let layer = video_frame_to_hdr_layer(&source_frame, clip.opacity);
294            compositor.add_layer(layer);
295        }
296
297        // Flatten compositor to RGBA f32 → pack into output VideoFrame.
298        let rgba_f32 = compositor.composite();
299        let mut output = VideoFrame::new(self.config.pixel_format, w, h);
300        output.allocate();
301        output.timestamp = Timestamp::new(position, self.timeline.timebase);
302
303        // Write RGBA f32 → luma plane (BT.709 coefficients).
304        fill_output_frame_from_rgba_f32(&mut output, &rgba_f32, w, h);
305
306        Ok(Some(output))
307    }
308
309    /// Parallel variant of `render_video_at`.
310    ///
311    /// Builds one [`TrackRenderInput`] per video track, fans out to
312    /// `render_tracks_parallel`, then composites the RGBA8 layers
313    /// bottom-to-top into the output `VideoFrame`.
314    fn render_video_at_parallel(
315        &mut self,
316        position: i64,
317        w: u32,
318        h: u32,
319    ) -> EditResult<Option<VideoFrame>> {
320        // Build per-track inputs for every Video track.
321        let config_clone = self.config.clone();
322        let timeline_clone = self.timeline.clone();
323
324        let inputs: Vec<TrackRenderInput> = timeline_clone
325            .tracks
326            .iter()
327            .filter(|t| !t.muted && matches!(t.track_type, TrackType::Video))
328            .map(|track| {
329                let active_clips: Vec<ClipWithSource> = track
330                    .clips
331                    .iter()
332                    .filter(|c| c.contains(position) && !c.muted)
333                    .map(|c| {
334                        let source = self.resolve_source(c);
335                        ClipWithSource {
336                            clip: c.clone(),
337                            source,
338                        }
339                    })
340                    .collect();
341                TrackRenderInput::video(track.index, active_clips, position, w, h)
342            })
343            .collect();
344
345        if inputs.is_empty() {
346            return Ok(None);
347        }
348
349        // Fan-out: all tracks rendered in parallel.
350        let outputs: Vec<TrackRenderOutput> = render_tracks_parallel(&inputs);
351
352        // Composite bottom-to-top: output with the *lowest* track index is rendered
353        // first (bottom layer); higher indices sit on top.  `render_tracks_parallel`
354        // preserves input order, so outputs[0] corresponds to inputs[0].
355        use oximedia_graphics::hdr_composite::HdrCompositor;
356
357        let mut compositor = HdrCompositor::new(w, h, 1000.0);
358        for out in outputs.iter().rev() {
359            if out.kind != TrackKind::Video || out.video_rgba8.is_empty() {
360                continue;
361            }
362            // Convert RGBA8 → HdrLayer (opacity = 1.0 per track; per-clip opacity
363            // was already applied inside render_track_frame_stateless).
364            use oximedia_graphics::hdr_composite::HdrLayer;
365            let pixel_count = (w as usize) * (h as usize);
366            let mut layer = HdrLayer::new(w, h);
367            layer.opacity = 1.0;
368            for i in 0..pixel_count {
369                let base = i * 4;
370                if base + 3 < out.video_rgba8.len() {
371                    layer.pixels[base] = out.video_rgba8[base] as f32 / 255.0;
372                    layer.pixels[base + 1] = out.video_rgba8[base + 1] as f32 / 255.0;
373                    layer.pixels[base + 2] = out.video_rgba8[base + 2] as f32 / 255.0;
374                    layer.pixels[base + 3] = out.video_rgba8[base + 3] as f32 / 255.0;
375                }
376            }
377            compositor.add_layer(layer);
378        }
379
380        let rgba_f32 = compositor.composite();
381        let mut output = VideoFrame::new(config_clone.pixel_format, w, h);
382        output.allocate();
383        output.timestamp = Timestamp::new(position, self.timeline.timebase);
384        fill_output_frame_from_rgba_f32(&mut output, &rgba_f32, w, h);
385
386        Ok(Some(output))
387    }
388
389    /// Render audio frame at position.
390    async fn render_audio_at(
391        &mut self,
392        position: i64,
393        clips: &[(usize, &Clip)],
394    ) -> EditResult<Option<AudioFrame>> {
395        let audio_clips: Vec<(usize, Clip)> = clips
396            .iter()
397            .filter(|(_, clip)| clip.is_audio())
398            .map(|(ti, c)| (*ti, (*c).clone()))
399            .collect();
400
401        if audio_clips.is_empty() {
402            return Ok(None);
403        }
404
405        let ch_count = self.config.channels.count();
406        // Produce 1024 samples per render call (approx. 21 ms at 48 kHz).
407        let num_samples: usize = 1024;
408        let mut mix_buf = vec![0.0_f32; num_samples * ch_count];
409
410        // Collect active CrossFade transitions (clone so we don't hold a borrow
411        // on `self.timeline` while calling `&mut self` decode methods).
412        use crate::transition::TransitionType;
413
414        let crossfade_transitions: Vec<(usize, Transition)> = audio_clips
415            .iter()
416            .flat_map(|(track_idx, _)| {
417                self.timeline
418                    .transitions
419                    .get_active_at(*track_idx, position)
420                    .into_iter()
421                    .filter(|t| matches!(t.transition_type, TransitionType::CrossFade))
422                    .map(|t| (*track_idx, t.clone()))
423            })
424            .collect();
425
426        // Identify audio clips involved in CrossFade transitions.
427        let mut crossfade_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
428        let mut crossfade_pairs: Vec<(AudioFrame, AudioFrame, Transition, f64)> = Vec::new();
429
430        for (_, transition) in &crossfade_transitions {
431            let ca_id = transition.clip_a;
432            let cb_id = transition.clip_b;
433
434            let ca = audio_clips
435                .iter()
436                .find(|(_, c)| c.id == ca_id)
437                .map(|(_, c)| (*c).clone());
438            let cb = audio_clips
439                .iter()
440                .find(|(_, c)| c.id == cb_id)
441                .map(|(_, c)| (*c).clone());
442            if let (Some(ca), Some(cb)) = (ca, cb) {
443                let fa = self.get_source_audio_frame(&ca, ca.timeline_to_source(position))?;
444                let fb = self.get_source_audio_frame(&cb, cb.timeline_to_source(position))?;
445                let progress = transition.progress_at(position);
446                crossfade_ids.insert(ca_id);
447                crossfade_ids.insert(cb_id);
448                crossfade_pairs.push((fa, fb, transition.clone(), progress));
449            }
450        }
451
452        // Mix crossfade pairs.
453        for (fa, fb, trans, progress) in &crossfade_pairs {
454            let blended = TransitionRenderer::mix_audio(fa, fb, trans, *progress);
455            accumulate_audio_frame_into(&mut mix_buf, &blended, 1.0, ch_count, num_samples);
456        }
457
458        // Mix non-transitioned clips.
459        for (_, clip) in audio_clips.iter() {
460            if clip.muted || crossfade_ids.contains(&clip.id) {
461                continue;
462            }
463            let source_pos = clip.timeline_to_source(position);
464            let src_frame = self.get_source_audio_frame(clip, source_pos)?;
465            let gain = clip.opacity; // opacity == volume for audio clips
466            accumulate_audio_frame_into(&mut mix_buf, &src_frame, gain, ch_count, num_samples);
467        }
468
469        // Clamp mix to [-1, 1].
470        for s in &mut mix_buf {
471            *s = s.clamp(-1.0, 1.0);
472        }
473
474        // Pack into output AudioFrame.
475        let bytes: Vec<u8> = mix_buf.iter().flat_map(|s| s.to_ne_bytes()).collect();
476
477        let mut output = AudioFrame {
478            format: self.config.sample_format,
479            sample_rate: self.config.sample_rate,
480            channels: self.config.channels.clone(),
481            samples: oximedia_audio::AudioBuffer::Interleaved(Bytes::from(bytes)),
482            timestamp: Timestamp::new(position, self.timeline.timebase),
483        };
484        output.timestamp = Timestamp::new(position, self.timeline.timebase);
485
486        Ok(Some(output))
487    }
488
489    /// Resolve the `RenderSource` for a clip, using the source cache.
490    fn resolve_source(&mut self, clip: &Clip) -> Arc<RenderSource> {
491        match &clip.source {
492            None => Arc::new(RenderSource::TestPattern),
493            Some(path) => {
494                if let Some(cached) = self.source_cache.get(path) {
495                    return cached.clone();
496                }
497                let resolved = RenderSource::from_path(path)
498                    .unwrap_or_else(|_| Arc::new(RenderSource::TestPattern));
499                self.source_cache.insert(path.clone(), resolved.clone());
500                resolved
501            }
502        }
503    }
504
505    /// Get a decoded video frame for `clip` at `source_pts`, using the raw
506    /// frame byte cache to avoid redundant decoding.
507    fn get_source_frame(&mut self, clip: &Clip, source_pts: i64) -> EditResult<VideoFrame> {
508        let w = self.config.width;
509        let h = self.config.height;
510
511        // Compound cache key: high 32 bits = clip id (truncated), low 32 bits = pts hash.
512        // Using wrapping arithmetic to avoid overflow; collisions are acceptable for a
513        // preview cache (a cache miss just re-decodes).
514        let key = (clip.id.wrapping_mul(0x9e3779b9)).wrapping_add(source_pts.unsigned_abs())
515            ^ (source_pts.signum() as u64);
516
517        let source = self.resolve_source(clip);
518
519        // Borrow-checker dance: we need to call get_or_render, which needs
520        // `source` to be captured in the closure but also needs `&mut self.raw_frame_cache`.
521        // We clone the Arc so the closure doesn't borrow `self`.
522        let source_clone = source.clone();
523        let rgba8 = self
524            .raw_frame_cache
525            .get_or_render(key, || source_clone.sample_video(source_pts, w, h))
526            .to_vec();
527
528        let mut frame = VideoFrame::new(self.config.pixel_format, w, h);
529        frame.allocate();
530        fill_output_frame_from_rgba8(&mut frame, &rgba8, w, h);
531        Ok(frame)
532    }
533
534    /// Get decoded audio samples for `clip` at `source_pts`.
535    fn get_source_audio_frame(&mut self, clip: &Clip, source_pts: i64) -> EditResult<AudioFrame> {
536        let ch_count = self.config.channels.count() as u16;
537        let sample_rate = self.config.sample_rate;
538        let num_samples: usize = 1024;
539
540        let source = self.resolve_source(clip);
541        let samples = source.sample_audio(source_pts, num_samples, ch_count, sample_rate);
542
543        let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_ne_bytes()).collect();
544
545        Ok(AudioFrame {
546            format: self.config.sample_format,
547            sample_rate,
548            channels: self.config.channels.clone(),
549            samples: oximedia_audio::AudioBuffer::Interleaved(Bytes::from(bytes)),
550            timestamp: Timestamp::new(source_pts, self.timeline.timebase),
551        })
552    }
553
554    /// Start background rendering.
555    pub fn start_background_render(&mut self) -> BackgroundRenderer {
556        BackgroundRenderer::new(self.timeline.clone(), self.config.clone())
557    }
558
559    /// Clear frame cache.
560    pub fn clear_cache(&mut self) {
561        self.cache.clear();
562    }
563}
564
565// ─── Render pipeline helper functions ─────────────────────────────────────────
566
567/// Convert a decoded `VideoFrame` (any format, already allocated) into an
568/// [`HdrLayer`] for the HDR compositor.
569///
570/// The frame data is interpreted as RGBA8 when all planes are concatenated.
571/// For planar YUV formats the luma plane is broadcast to R, G, B channels.
572fn video_frame_to_hdr_layer(
573    frame: &VideoFrame,
574    opacity: f32,
575) -> oximedia_graphics::hdr_composite::HdrLayer {
576    use oximedia_graphics::hdr_composite::HdrLayer;
577
578    let w = frame.width;
579    let h = frame.height;
580    let pixel_count = (w as usize) * (h as usize);
581    let mut layer = HdrLayer::new(w, h);
582    layer.opacity = opacity.clamp(0.0, 1.0);
583
584    // Best-effort pixel extraction: use the luma plane (plane 0) for all
585    // colour channels when no better interleaved data is available.
586    if let Some(plane) = frame.planes.first() {
587        for i in 0..pixel_count {
588            let idx = i * 4;
589            let luma = if i < plane.data.len() {
590                plane.data[i] as f32 / 255.0
591            } else {
592                0.0_f32
593            };
594            layer.pixels[idx] = luma;
595            layer.pixels[idx + 1] = luma;
596            layer.pixels[idx + 2] = luma;
597            layer.pixels[idx + 3] = 1.0;
598        }
599    }
600
601    layer
602}
603
604/// Write RGBA f32 linear-light compositor output into the first plane of a
605/// `VideoFrame`.  Values are tone-mapped to `[0, 255]` (divide by peak_nits,
606/// clamp, scale).
607fn fill_output_frame_from_rgba_f32(frame: &mut VideoFrame, rgba: &[f32], w: u32, h: u32) {
608    let pixel_count = (w as usize) * (h as usize);
609    if let Some(plane) = frame.planes.first_mut() {
610        let out_len = plane.data.len().min(pixel_count);
611        for i in 0..out_len {
612            let base = i * 4;
613            if base + 2 < rgba.len() {
614                // Luma: 0.2126*R + 0.7152*G + 0.0722*B  (BT.709)
615                let luma =
616                    (0.2126 * rgba[base] + 0.7152 * rgba[base + 1] + 0.0722 * rgba[base + 2])
617                        .clamp(0.0, 1.0);
618                #[allow(clippy::cast_possible_truncation)]
619                #[allow(clippy::cast_sign_loss)]
620                let y = (luma * 255.0).round() as u8;
621                plane.data[i] = y;
622            }
623        }
624    }
625}
626
627/// Write RGBA8 pixel bytes into the first plane of a `VideoFrame` as luma.
628fn fill_output_frame_from_rgba8(frame: &mut VideoFrame, rgba8: &[u8], w: u32, h: u32) {
629    let pixel_count = (w as usize) * (h as usize);
630    if let Some(plane) = frame.planes.first_mut() {
631        let out_len = plane.data.len().min(pixel_count);
632        for i in 0..out_len {
633            let base = i * 4;
634            if base + 2 < rgba8.len() {
635                // Luma (integer approximation of BT.601).
636                let r = rgba8[base] as u32;
637                let g = rgba8[base + 1] as u32;
638                let b = rgba8[base + 2] as u32;
639                let y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
640                plane.data[i] = y.min(255) as u8;
641            }
642        }
643    }
644}
645
646/// Accumulate samples from an `AudioFrame` (F32 interleaved) into `mix_buf`
647/// with the given `gain`.
648fn accumulate_audio_frame_into(
649    mix_buf: &mut [f32],
650    frame: &AudioFrame,
651    gain: f32,
652    ch_count: usize,
653    num_samples: usize,
654) {
655    use oximedia_mixer::simd_audio::mix_and_gain_simd;
656
657    let expected = num_samples * ch_count;
658    let src_samples: Vec<f32> = match &frame.samples {
659        oximedia_audio::AudioBuffer::Interleaved(bytes) => bytes
660            .chunks_exact(4)
661            .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
662            .take(expected)
663            .collect(),
664        oximedia_audio::AudioBuffer::Planar(planes) => {
665            // Interleave planes.
666            let frames_per_plane = if planes.is_empty() {
667                0
668            } else {
669                (planes[0].len() / 4).min(num_samples)
670            };
671            let mut interleaved = vec![0.0_f32; frames_per_plane * ch_count];
672            for (c, plane) in planes.iter().enumerate().take(ch_count) {
673                for f in 0..frames_per_plane {
674                    let base = f * 4;
675                    if base + 3 < plane.len() {
676                        interleaved[f * ch_count + c] = f32::from_ne_bytes([
677                            plane[base],
678                            plane[base + 1],
679                            plane[base + 2],
680                            plane[base + 3],
681                        ]);
682                    }
683                }
684            }
685            interleaved
686        }
687    };
688
689    if src_samples.is_empty() {
690        return;
691    }
692
693    let dst_len = mix_buf.len().min(expected);
694    let src_len = src_samples.len().min(dst_len);
695    mix_and_gain_simd(&mut mix_buf[..dst_len], &src_samples[..src_len], gain);
696}
697
698/// Rendered frame containing video and audio.
699#[derive(Clone, Debug)]
700pub struct RenderFrame {
701    /// Timeline position.
702    pub position: i64,
703    /// Timestamp.
704    pub timestamp: Timestamp,
705    /// Video frame.
706    pub video: Option<VideoFrame>,
707    /// Audio frame.
708    pub audio: Option<AudioFrame>,
709}
710
711impl RenderFrame {
712    /// Check if frame has video.
713    #[must_use]
714    pub fn has_video(&self) -> bool {
715        self.video.is_some()
716    }
717
718    /// Check if frame has audio.
719    #[must_use]
720    pub fn has_audio(&self) -> bool {
721        self.audio.is_some()
722    }
723}
724
725/// Render configuration.
726#[derive(Clone, Debug)]
727pub struct RenderConfig {
728    /// Render video.
729    pub render_video: bool,
730    /// Render audio.
731    pub render_audio: bool,
732    /// Video width.
733    pub width: u32,
734    /// Video height.
735    pub height: u32,
736    /// Pixel format.
737    pub pixel_format: PixelFormat,
738    /// Sample rate.
739    pub sample_rate: u32,
740    /// Sample format.
741    pub sample_format: SampleFormat,
742    /// Audio channels.
743    pub channels: ChannelLayout,
744    /// Frame cache size.
745    pub cache_size: usize,
746    /// Number of render threads.
747    pub num_threads: usize,
748    /// Quality preset.
749    pub quality: RenderQuality,
750}
751
752impl Default for RenderConfig {
753    fn default() -> Self {
754        Self {
755            render_video: true,
756            render_audio: true,
757            width: 1920,
758            height: 1080,
759            pixel_format: PixelFormat::Yuv420p,
760            sample_rate: 48000,
761            sample_format: SampleFormat::F32,
762            channels: ChannelLayout::Stereo,
763            cache_size: 30,
764            num_threads: 4,
765            quality: RenderQuality::High,
766        }
767    }
768}
769
770impl RenderConfig {
771    /// Create config from timeline config.
772    #[must_use]
773    pub fn from_timeline_config(config: &TimelineConfig) -> Self {
774        Self {
775            width: config.width,
776            height: config.height,
777            sample_rate: config.sample_rate,
778            channels: ChannelLayout::from_count(config.channels as usize),
779            ..Default::default()
780        }
781    }
782}
783
784/// Render quality preset.
785#[derive(Clone, Copy, Debug, PartialEq, Eq)]
786pub enum RenderQuality {
787    /// Draft quality (fast, low quality).
788    Draft,
789    /// Preview quality (balanced).
790    Preview,
791    /// High quality (slow, high quality).
792    High,
793    /// Maximum quality (very slow, maximum quality).
794    Maximum,
795}
796
797impl RenderQuality {
798    /// Get quality factor (0.0 to 1.0).
799    #[must_use]
800    pub fn factor(&self) -> f32 {
801        match self {
802            Self::Draft => 0.25,
803            Self::Preview => 0.5,
804            Self::High => 0.75,
805            Self::Maximum => 1.0,
806        }
807    }
808}
809
810/// Frame cache for rendered frames.
811#[derive(Debug)]
812struct FrameCache {
813    /// Cache storage.
814    frames: VecDeque<(i64, RenderFrame)>,
815    /// Maximum cache size.
816    capacity: usize,
817}
818
819impl FrameCache {
820    /// Create a new frame cache.
821    fn new(capacity: usize) -> Self {
822        Self {
823            frames: VecDeque::with_capacity(capacity),
824            capacity,
825        }
826    }
827
828    /// Get a frame from cache.
829    fn get(&self, position: i64) -> Option<RenderFrame> {
830        self.frames
831            .iter()
832            .find(|(pos, _)| *pos == position)
833            .map(|(_, frame)| frame.clone())
834    }
835
836    /// Put a frame into cache.
837    fn put(&mut self, position: i64, frame: RenderFrame) {
838        // Remove oldest if at capacity
839        if self.frames.len() >= self.capacity {
840            self.frames.pop_front();
841        }
842        self.frames.push_back((position, frame));
843    }
844
845    /// Clear the cache.
846    fn clear(&mut self) {
847        self.frames.clear();
848    }
849}
850
851/// Background renderer for non-blocking rendering.
852#[cfg(not(target_arch = "wasm32"))]
853pub struct BackgroundRenderer {
854    /// Timeline to render.
855    timeline: Arc<Timeline>,
856    /// Render configuration.
857    config: RenderConfig,
858    /// Render task handle.
859    handle: Option<tokio::task::JoinHandle<()>>,
860}
861
862#[cfg(not(target_arch = "wasm32"))]
863impl BackgroundRenderer {
864    /// Create a new background renderer.
865    #[must_use]
866    pub fn new(timeline: Arc<Timeline>, config: RenderConfig) -> Self {
867        Self {
868            timeline,
869            config,
870            handle: None,
871        }
872    }
873
874    /// Start rendering in the background.
875    pub fn start(&mut self, start: i64, end: i64) -> mpsc::Receiver<RenderFrame> {
876        let (tx, rx) = mpsc::channel(100);
877        let timeline = self.timeline.clone();
878        let config = self.config.clone();
879
880        let handle = tokio::spawn(async move {
881            let mut renderer = TimelineRenderer::new(timeline, config);
882
883            for position in start..end {
884                match renderer.render_frame_at(position).await {
885                    Ok(frame) => {
886                        if tx.send(frame).await.is_err() {
887                            break;
888                        }
889                    }
890                    Err(_) => break,
891                }
892            }
893        });
894
895        self.handle = Some(handle);
896        rx
897    }
898
899    /// Stop background rendering.
900    pub async fn stop(&mut self) {
901        if let Some(handle) = self.handle.take() {
902            handle.abort();
903            let _ = handle.await;
904        }
905    }
906
907    /// Check if rendering is complete.
908    #[must_use]
909    pub fn is_complete(&self) -> bool {
910        self.handle
911            .as_ref()
912            .map_or(true, tokio::task::JoinHandle::is_finished)
913    }
914}
915
916/// Real-time preview renderer.
917pub struct PreviewRenderer {
918    /// Timeline renderer.
919    renderer: TimelineRenderer,
920    /// Target frame rate.
921    frame_rate: Rational,
922    /// Current position.
923    position: i64,
924    /// Playing state.
925    playing: bool,
926}
927
928impl PreviewRenderer {
929    /// Create a new preview renderer.
930    #[must_use]
931    pub fn new(timeline: Arc<Timeline>, config: RenderConfig) -> Self {
932        let frame_rate = timeline.frame_rate;
933        Self {
934            renderer: TimelineRenderer::new(timeline, config),
935            frame_rate,
936            position: 0,
937            playing: false,
938        }
939    }
940
941    /// Start playback.
942    pub fn play(&mut self) {
943        self.playing = true;
944    }
945
946    /// Pause playback.
947    pub fn pause(&mut self) {
948        self.playing = false;
949    }
950
951    /// Stop playback and reset.
952    pub fn stop(&mut self) {
953        self.playing = false;
954        self.position = 0;
955    }
956
957    /// Get next preview frame.
958    pub async fn next_frame(&mut self) -> EditResult<Option<RenderFrame>> {
959        if !self.playing {
960            return Ok(None);
961        }
962
963        let frame = self.renderer.render_frame_at(self.position).await?;
964
965        // Advance position
966        #[allow(clippy::cast_possible_truncation)]
967        #[allow(clippy::cast_precision_loss)]
968        let frame_duration = (1000.0 / self.frame_rate.to_f64()) as i64;
969        self.position += frame_duration;
970
971        // Check if we've reached the end
972        if self.position >= self.renderer.timeline.duration {
973            self.stop();
974        }
975
976        Ok(Some(frame))
977    }
978
979    /// Seek to position.
980    pub fn seek(&mut self, position: i64) {
981        self.position = position.clamp(0, self.renderer.timeline.duration);
982    }
983
984    /// Get current position.
985    #[must_use]
986    pub fn position(&self) -> i64 {
987        self.position
988    }
989
990    /// Check if playing.
991    #[must_use]
992    pub fn is_playing(&self) -> bool {
993        self.playing
994    }
995}
996
997/// Export renderer for final output.
998pub struct ExportRenderer {
999    /// Timeline renderer.
1000    renderer: TimelineRenderer,
1001    /// Export settings.
1002    settings: ExportSettings,
1003}
1004
1005impl ExportRenderer {
1006    /// Create a new export renderer.
1007    #[must_use]
1008    pub fn new(timeline: Arc<Timeline>, settings: ExportSettings) -> Self {
1009        let config = RenderConfig {
1010            render_video: settings.video_enabled,
1011            render_audio: settings.audio_enabled,
1012            width: settings.width,
1013            height: settings.height,
1014            pixel_format: settings.pixel_format,
1015            sample_rate: settings.sample_rate,
1016            sample_format: settings.sample_format,
1017            channels: settings.channels.clone(),
1018            quality: settings.quality,
1019            ..Default::default()
1020        };
1021
1022        Self {
1023            renderer: TimelineRenderer::new(timeline, config),
1024            settings,
1025        }
1026    }
1027
1028    /// Export timeline to frames.
1029    pub async fn export(&mut self) -> EditResult<Vec<RenderFrame>> {
1030        let mut frames = Vec::new();
1031        let start = self.settings.start.unwrap_or(0);
1032        let end = self.settings.end.unwrap_or(self.renderer.timeline.duration);
1033
1034        for position in start..end {
1035            let frame = self.renderer.render_frame_at(position).await?;
1036            frames.push(frame);
1037        }
1038
1039        Ok(frames)
1040    }
1041
1042    /// Export timeline as a stream.
1043    pub fn export_stream(&mut self) -> ExportStream {
1044        let start = self.settings.start.unwrap_or(0);
1045        let end = self.settings.end.unwrap_or(self.renderer.timeline.duration);
1046
1047        ExportStream {
1048            renderer: self.renderer.clone_for_stream(),
1049            current: start,
1050            end,
1051        }
1052    }
1053}
1054
1055/// Export settings.
1056#[derive(Clone, Debug)]
1057pub struct ExportSettings {
1058    /// Export video.
1059    pub video_enabled: bool,
1060    /// Export audio.
1061    pub audio_enabled: bool,
1062    /// Video width.
1063    pub width: u32,
1064    /// Video height.
1065    pub height: u32,
1066    /// Pixel format.
1067    pub pixel_format: PixelFormat,
1068    /// Sample rate.
1069    pub sample_rate: u32,
1070    /// Sample format.
1071    pub sample_format: SampleFormat,
1072    /// Audio channels.
1073    pub channels: ChannelLayout,
1074    /// Quality preset.
1075    pub quality: RenderQuality,
1076    /// Start position (None = beginning).
1077    pub start: Option<i64>,
1078    /// End position (None = end of timeline).
1079    pub end: Option<i64>,
1080}
1081
1082impl Default for ExportSettings {
1083    fn default() -> Self {
1084        Self {
1085            video_enabled: true,
1086            audio_enabled: true,
1087            width: 1920,
1088            height: 1080,
1089            pixel_format: PixelFormat::Yuv420p,
1090            sample_rate: 48000,
1091            sample_format: SampleFormat::F32,
1092            channels: ChannelLayout::Stereo,
1093            quality: RenderQuality::High,
1094            start: None,
1095            end: None,
1096        }
1097    }
1098}
1099
1100/// Stream of exported frames.
1101pub struct ExportStream {
1102    renderer: TimelineRenderer,
1103    current: i64,
1104    end: i64,
1105}
1106
1107// Note: This is a stub implementation. The actual Stream trait requires proper
1108// async support with a stored future, not creating a new future in poll_next.
1109// Consider using `tokio::stream::StreamExt` or `futures::stream::unfold` for proper implementation.
1110#[allow(dead_code)]
1111impl ExportStream {
1112    /// Create an async stream from the export stream.
1113    /// This should be used instead of directly implementing Stream.
1114    pub fn into_stream(self) -> impl futures::stream::Stream<Item = EditResult<RenderFrame>> {
1115        futures::stream::unfold(self, |mut state| async move {
1116            if state.current >= state.end {
1117                return None;
1118            }
1119            let position = state.current;
1120            state.current += 1;
1121            let result = state.renderer.render_frame_at(position).await;
1122            Some((result, state))
1123        })
1124    }
1125}
1126
1127impl TimelineRenderer {
1128    /// Clone renderer for streaming.
1129    fn clone_for_stream(&self) -> Self {
1130        let max_pos = self.timeline.duration.max(0) as i64;
1131        let prefetch_config = PrefetchConfig::for_playback(30.0, 1.0);
1132        Self {
1133            timeline: self.timeline.clone(),
1134            config: self.config.clone(),
1135            cache: FrameCache::new(self.config.cache_size),
1136            raw_frame_cache: RawFrameCache::new(RAW_FRAME_CACHE_CAPACITY),
1137            source_cache: HashMap::new(),
1138            dirty_regions: Vec::new(),
1139            prefetch: PrefetchEngine::new(prefetch_config, max_pos),
1140            use_parallel: self.use_parallel,
1141        }
1142    }
1143}
1144
1145// ─────────────────────────────────────────────────────────────────────────────
1146// RawFrameCache — byte-buffer frame cache with LRU eviction
1147// ─────────────────────────────────────────────────────────────────────────────
1148
1149/// Maximum number of raw frames kept in [`RawFrameCache`] before LRU eviction.
1150pub const RAW_FRAME_CACHE_CAPACITY: usize = 32;
1151
1152/// A cache that stores raw pixel byte buffers (e.g. decoded video frames) keyed
1153/// by frame number.
1154///
1155/// When the cache reaches [`RAW_FRAME_CACHE_CAPACITY`] entries the least-recently
1156/// used frame is evicted before inserting the new one.
1157///
1158/// # Example
1159///
1160/// ```
1161/// use oximedia_edit::render::RawFrameCache;
1162///
1163/// let mut cache = RawFrameCache::new(4);
1164/// let data = cache.get_or_render(0, || vec![0u8; 1024]);
1165/// assert_eq!(data.len(), 1024);
1166/// ```
1167pub struct RawFrameCache {
1168    /// Frame data keyed by frame number.
1169    store: HashMap<u64, Vec<u8>>,
1170    /// Insertion-order tracking for LRU eviction (front = oldest).
1171    order: VecDeque<u64>,
1172    /// Maximum number of frames to retain.
1173    capacity: usize,
1174}
1175
1176impl RawFrameCache {
1177    /// Create a new cache with the given capacity (clamped to at least 1).
1178    #[must_use]
1179    pub fn new(capacity: usize) -> Self {
1180        let capacity = capacity.max(1);
1181        Self {
1182            store: HashMap::with_capacity(capacity),
1183            order: VecDeque::with_capacity(capacity),
1184            capacity,
1185        }
1186    }
1187
1188    /// Return a reference to the cached bytes for `frame_num`, rendering and
1189    /// inserting them via `render_fn` if not already present.
1190    ///
1191    /// The rendered bytes are stored in the cache; on subsequent calls the same
1192    /// reference is returned without invoking `render_fn`.
1193    ///
1194    /// When the cache is full the **oldest** frame is evicted first (LRU by
1195    /// insertion order).
1196    pub fn get_or_render(&mut self, frame_num: u64, render_fn: impl FnOnce() -> Vec<u8>) -> &[u8] {
1197        if !self.store.contains_key(&frame_num) {
1198            // Evict oldest entry if at capacity.
1199            if self.store.len() >= self.capacity {
1200                if let Some(oldest) = self.order.pop_front() {
1201                    self.store.remove(&oldest);
1202                }
1203            }
1204
1205            let data = render_fn();
1206            self.store.insert(frame_num, data);
1207            self.order.push_back(frame_num);
1208        }
1209
1210        // Safety: key is guaranteed to be present after the block above.
1211        self.store.get(&frame_num).map(Vec::as_slice).unwrap_or(&[])
1212    }
1213
1214    /// Return a reference to the cached bytes for `frame_num` without rendering.
1215    ///
1216    /// Returns `None` if the frame is not in the cache.
1217    #[must_use]
1218    pub fn get(&self, frame_num: u64) -> Option<&[u8]> {
1219        self.store.get(&frame_num).map(Vec::as_slice)
1220    }
1221
1222    /// Explicitly insert pre-rendered bytes for `frame_num`.
1223    ///
1224    /// If the frame already exists it is replaced.  If the cache is full the
1225    /// oldest frame is evicted.
1226    pub fn insert(&mut self, frame_num: u64, data: Vec<u8>) {
1227        if let std::collections::hash_map::Entry::Occupied(mut e) = self.store.entry(frame_num) {
1228            e.insert(data);
1229            return;
1230        }
1231        if self.store.len() >= self.capacity {
1232            if let Some(oldest) = self.order.pop_front() {
1233                self.store.remove(&oldest);
1234            }
1235        }
1236        self.store.insert(frame_num, data);
1237        self.order.push_back(frame_num);
1238    }
1239
1240    /// Invalidate (remove) the cache entry for `frame_num`, if present.
1241    pub fn invalidate(&mut self, frame_num: u64) {
1242        if self.store.remove(&frame_num).is_some() {
1243            self.order.retain(|&f| f != frame_num);
1244        }
1245    }
1246
1247    /// Clear all cached frames.
1248    pub fn clear(&mut self) {
1249        self.store.clear();
1250        self.order.clear();
1251    }
1252
1253    /// Return the number of frames currently in the cache.
1254    #[must_use]
1255    pub fn len(&self) -> usize {
1256        self.store.len()
1257    }
1258
1259    /// Return `true` if the cache contains no frames.
1260    #[must_use]
1261    pub fn is_empty(&self) -> bool {
1262        self.store.is_empty()
1263    }
1264
1265    /// Return the maximum number of frames this cache holds.
1266    #[must_use]
1267    pub fn capacity(&self) -> usize {
1268        self.capacity
1269    }
1270
1271    /// Return `true` if a frame for `frame_num` is present in the cache.
1272    #[must_use]
1273    pub fn contains(&self, frame_num: u64) -> bool {
1274        self.store.contains_key(&frame_num)
1275    }
1276}
1277
1278impl std::fmt::Debug for RawFrameCache {
1279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280        f.debug_struct("RawFrameCache")
1281            .field("len", &self.store.len())
1282            .field("capacity", &self.capacity)
1283            .finish()
1284    }
1285}
1286
1287// ─────────────────────────────────────────────────────────────────────────────
1288// Tests for RawFrameCache
1289// ─────────────────────────────────────────────────────────────────────────────
1290
1291#[cfg(test)]
1292mod raw_cache_tests {
1293    use super::{RawFrameCache, RAW_FRAME_CACHE_CAPACITY};
1294
1295    #[test]
1296    fn test_raw_frame_cache_basic_get_or_render() {
1297        let mut cache = RawFrameCache::new(4);
1298        let mut render_count = 0usize;
1299
1300        // First call: renders.
1301        let data = cache.get_or_render(0, || {
1302            render_count += 1;
1303            vec![1u8, 2, 3]
1304        });
1305        assert_eq!(data, &[1u8, 2, 3]);
1306        assert_eq!(render_count, 1);
1307
1308        // Second call: cached — render_fn must not be invoked.
1309        let data2 = cache.get_or_render(0, || {
1310            render_count += 1;
1311            vec![99u8]
1312        });
1313        assert_eq!(data2, &[1u8, 2, 3]);
1314        assert_eq!(render_count, 1, "render_fn should not be called twice");
1315    }
1316
1317    #[test]
1318    fn test_raw_frame_cache_lru_eviction() {
1319        let mut cache = RawFrameCache::new(4);
1320
1321        // Fill cache to capacity.
1322        for i in 0u64..4 {
1323            cache.get_or_render(i, || vec![i as u8]);
1324        }
1325        assert_eq!(cache.len(), 4);
1326
1327        // Insert one more frame — oldest (frame 0) should be evicted.
1328        cache.get_or_render(4, || vec![4u8]);
1329        assert_eq!(cache.len(), 4, "cache must not exceed capacity");
1330        assert!(
1331            !cache.contains(0),
1332            "oldest frame (0) should have been evicted"
1333        );
1334        assert!(
1335            cache.contains(4),
1336            "newly inserted frame (4) should be present"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_raw_frame_cache_capacity_32() {
1342        let cache = RawFrameCache::new(RAW_FRAME_CACHE_CAPACITY);
1343        assert_eq!(cache.capacity(), 32);
1344    }
1345
1346    #[test]
1347    fn test_raw_frame_cache_get_missing() {
1348        let cache = RawFrameCache::new(4);
1349        assert!(cache.get(99).is_none());
1350    }
1351
1352    #[test]
1353    fn test_raw_frame_cache_insert_and_get() {
1354        let mut cache = RawFrameCache::new(4);
1355        cache.insert(7, vec![10, 20, 30]);
1356        assert_eq!(cache.get(7), Some(&[10u8, 20, 30][..]));
1357    }
1358
1359    #[test]
1360    fn test_raw_frame_cache_invalidate() {
1361        let mut cache = RawFrameCache::new(4);
1362        cache.insert(1, vec![1, 2]);
1363        cache.invalidate(1);
1364        assert!(!cache.contains(1));
1365        assert_eq!(cache.len(), 0);
1366    }
1367
1368    #[test]
1369    fn test_raw_frame_cache_clear() {
1370        let mut cache = RawFrameCache::new(4);
1371        for i in 0u64..4 {
1372            cache.insert(i, vec![i as u8]);
1373        }
1374        cache.clear();
1375        assert!(cache.is_empty());
1376        assert_eq!(cache.len(), 0);
1377    }
1378
1379    #[test]
1380    fn test_raw_frame_cache_eviction_order() {
1381        let mut cache = RawFrameCache::new(3);
1382        cache.insert(10, vec![10]);
1383        cache.insert(20, vec![20]);
1384        cache.insert(30, vec![30]);
1385
1386        // Frame 40 → evicts frame 10 (oldest).
1387        cache.insert(40, vec![40]);
1388        assert!(!cache.contains(10));
1389        assert!(cache.contains(20));
1390        assert!(cache.contains(30));
1391        assert!(cache.contains(40));
1392
1393        // Frame 50 → evicts frame 20.
1394        cache.insert(50, vec![50]);
1395        assert!(!cache.contains(20));
1396        assert!(cache.contains(30));
1397        assert!(cache.contains(40));
1398        assert!(cache.contains(50));
1399    }
1400
1401    #[test]
1402    fn test_raw_frame_cache_capacity_clamped_to_one() {
1403        let cache = RawFrameCache::new(0);
1404        assert_eq!(cache.capacity(), 1);
1405    }
1406
1407    #[test]
1408    fn test_raw_frame_cache_is_empty_initially() {
1409        let cache = RawFrameCache::new(8);
1410        assert!(cache.is_empty());
1411    }
1412
1413    #[test]
1414    fn test_raw_frame_cache_debug_format() {
1415        let cache = RawFrameCache::new(4);
1416        let debug = format!("{cache:?}");
1417        assert!(debug.contains("RawFrameCache"), "debug output: {debug}");
1418    }
1419}
1420
1421// Note: Unit tests for dirty-region tracking, prefetch wiring, and parallel
1422// rendering are in tests/incremental_render.rs (integration tests) and
1423// tests/renderer_unit.rs (synchronous unit tests).
1424
1425/// Transition renderer helper.
1426pub struct TransitionRenderer;
1427
1428impl TransitionRenderer {
1429    /// Blend two video frames based on transition progress.
1430    ///
1431    /// `progress` ranges from `0.0` (fully `frame_a`) to `1.0` (fully `frame_b`).
1432    /// When the two frames have different dimensions the larger frame is returned
1433    /// unchanged.  When formats differ `frame_a` is returned unchanged.
1434    #[must_use]
1435    pub fn blend_video(
1436        frame_a: &VideoFrame,
1437        frame_b: &VideoFrame,
1438        transition: &Transition,
1439        progress: f64,
1440    ) -> VideoFrame {
1441        use crate::transition::TransitionType;
1442
1443        // Dimension / format mismatch — return the larger (or frame_a) unblended.
1444        if frame_a.format != frame_b.format {
1445            return frame_a.clone();
1446        }
1447        let a_pixels = frame_a.width as u64 * frame_a.height as u64;
1448        let b_pixels = frame_b.width as u64 * frame_b.height as u64;
1449        if a_pixels != b_pixels {
1450            return if b_pixels > a_pixels {
1451                frame_b.clone()
1452            } else {
1453                frame_a.clone()
1454            };
1455        }
1456
1457        let progress_f32 = progress as f32;
1458
1459        match &transition.transition_type {
1460            TransitionType::Dissolve => Self::dissolve_video(frame_a, frame_b, progress_f32),
1461            TransitionType::WipeLeft => {
1462                Self::wipe_video(frame_a, frame_b, progress_f32, WipeDirection::Left)
1463            }
1464            TransitionType::WipeRight => {
1465                Self::wipe_video(frame_a, frame_b, progress_f32, WipeDirection::Right)
1466            }
1467            TransitionType::WipeDown => {
1468                Self::wipe_video(frame_a, frame_b, progress_f32, WipeDirection::Down)
1469            }
1470            TransitionType::WipeUp => {
1471                Self::wipe_video(frame_a, frame_b, progress_f32, WipeDirection::Up)
1472            }
1473            // CrossFade is audio-only; all remaining video variants and Cut-like
1474            // behaviour: switch at mid-point.
1475            _ => {
1476                if progress_f32 >= 0.5 {
1477                    frame_b.clone()
1478                } else {
1479                    frame_a.clone()
1480                }
1481            }
1482        }
1483    }
1484
1485    /// Mix two audio frames based on transition progress (cross-fade).
1486    ///
1487    /// `progress` ranges from `0.0` (fully `frame_a`) to `1.0` (fully `frame_b`).
1488    /// `F32` interleaved and `F32p` planar audio are blended; all other formats
1489    /// fall back to returning `frame_a` unchanged.  When sample formats differ,
1490    /// `frame_a` is returned unchanged.
1491    #[must_use]
1492    pub fn mix_audio(
1493        frame_a: &AudioFrame,
1494        frame_b: &AudioFrame,
1495        _transition: &Transition,
1496        progress: f64,
1497    ) -> AudioFrame {
1498        // Format mismatch — return frame_a unblended.
1499        if frame_a.format != frame_b.format {
1500            return frame_a.clone();
1501        }
1502
1503        let alpha = progress as f32;
1504        let inv_alpha = 1.0_f32 - alpha;
1505
1506        match (&frame_a.samples, &frame_b.samples) {
1507            (AudioBuffer::Interleaved(a_bytes), AudioBuffer::Interleaved(b_bytes))
1508                if frame_a.format == SampleFormat::F32 =>
1509            {
1510                let len_samples = (a_bytes.len() / 4).min(b_bytes.len() / 4);
1511                let mut out_bytes = Vec::with_capacity(len_samples * 4);
1512
1513                for i in 0..len_samples {
1514                    let base = i * 4;
1515                    let a_val = f32::from_ne_bytes([
1516                        a_bytes[base],
1517                        a_bytes[base + 1],
1518                        a_bytes[base + 2],
1519                        a_bytes[base + 3],
1520                    ]);
1521                    let b_val = f32::from_ne_bytes([
1522                        b_bytes[base],
1523                        b_bytes[base + 1],
1524                        b_bytes[base + 2],
1525                        b_bytes[base + 3],
1526                    ]);
1527                    let blended = (a_val * inv_alpha + b_val * alpha).clamp(-1.0, 1.0);
1528                    out_bytes.extend_from_slice(&blended.to_ne_bytes());
1529                }
1530
1531                AudioFrame {
1532                    format: frame_a.format,
1533                    sample_rate: frame_a.sample_rate,
1534                    channels: frame_a.channels.clone(),
1535                    samples: AudioBuffer::Interleaved(Bytes::from(out_bytes)),
1536                    timestamp: frame_a.timestamp,
1537                }
1538            }
1539            (AudioBuffer::Planar(a_planes), AudioBuffer::Planar(b_planes))
1540                if frame_a.format == SampleFormat::F32p =>
1541            {
1542                let plane_count = a_planes.len().min(b_planes.len());
1543                let mut out_planes = Vec::with_capacity(plane_count);
1544
1545                for p in 0..plane_count {
1546                    let a_plane = &a_planes[p];
1547                    let b_plane = &b_planes[p];
1548                    let len_samples = (a_plane.len() / 4).min(b_plane.len() / 4);
1549                    let mut plane_bytes = Vec::with_capacity(len_samples * 4);
1550
1551                    for i in 0..len_samples {
1552                        let base = i * 4;
1553                        let a_val = f32::from_ne_bytes([
1554                            a_plane[base],
1555                            a_plane[base + 1],
1556                            a_plane[base + 2],
1557                            a_plane[base + 3],
1558                        ]);
1559                        let b_val = f32::from_ne_bytes([
1560                            b_plane[base],
1561                            b_plane[base + 1],
1562                            b_plane[base + 2],
1563                            b_plane[base + 3],
1564                        ]);
1565                        let blended = (a_val * inv_alpha + b_val * alpha).clamp(-1.0, 1.0);
1566                        plane_bytes.extend_from_slice(&blended.to_ne_bytes());
1567                    }
1568
1569                    out_planes.push(Bytes::from(plane_bytes));
1570                }
1571
1572                AudioFrame {
1573                    format: frame_a.format,
1574                    sample_rate: frame_a.sample_rate,
1575                    channels: frame_a.channels.clone(),
1576                    samples: AudioBuffer::Planar(out_planes),
1577                    timestamp: frame_a.timestamp,
1578                }
1579            }
1580            // Unsupported format combination — return frame_a unchanged.
1581            _ => frame_a.clone(),
1582        }
1583    }
1584
1585    // ── Private helpers ──────────────────────────────────────────────────────
1586
1587    /// Linear dissolve blend over all planes.
1588    fn dissolve_video(frame_a: &VideoFrame, frame_b: &VideoFrame, progress: f32) -> VideoFrame {
1589        use oximedia_codec::frame::Plane;
1590
1591        let inv = 1.0_f32 - progress;
1592        let mut output = frame_a.clone();
1593
1594        for (out_plane, b_plane) in output.planes.iter_mut().zip(frame_b.planes.iter()) {
1595            let len = out_plane.data.len().min(b_plane.data.len());
1596            let blended: Vec<u8> = (0..len)
1597                .map(|i| {
1598                    #[allow(clippy::cast_possible_truncation)]
1599                    #[allow(clippy::cast_sign_loss)]
1600                    let v = (out_plane.data[i] as f32 * inv + b_plane.data[i] as f32 * progress)
1601                        .round()
1602                        .clamp(0.0, 255.0) as u8;
1603                    v
1604                })
1605                .collect();
1606
1607            // Rebuild the plane so that stride/width/height are preserved and
1608            // only the pixel data is replaced.
1609            let new_plane = Plane::with_dimensions(
1610                blended,
1611                out_plane.stride,
1612                out_plane.width,
1613                out_plane.height,
1614            );
1615            *out_plane = new_plane;
1616        }
1617
1618        output
1619    }
1620
1621    /// Wipe transition — one side uses `frame_b`, the other `frame_a`.
1622    fn wipe_video(
1623        frame_a: &VideoFrame,
1624        frame_b: &VideoFrame,
1625        progress: f32,
1626        direction: WipeDirection,
1627    ) -> VideoFrame {
1628        use oximedia_codec::frame::Plane;
1629
1630        let mut output = frame_a.clone();
1631
1632        // Process plane by plane so that chroma subsampling is handled correctly.
1633        for (out_plane, b_plane) in output.planes.iter_mut().zip(frame_b.planes.iter()) {
1634            let pw = out_plane.width as usize;
1635            let ph = out_plane.height as usize;
1636            // Chroma planes have reduced spatial size; compute the wipe boundary
1637            // in plane-local coordinates by using the plane's own dimensions.
1638
1639            let mut new_data = out_plane.data.clone();
1640
1641            match direction {
1642                WipeDirection::Left | WipeDirection::Right => {
1643                    // Number of columns (in this plane) that show frame_b.
1644                    #[allow(clippy::cast_possible_truncation)]
1645                    #[allow(clippy::cast_sign_loss)]
1646                    let boundary = (progress * pw as f32).round() as usize;
1647                    for y in 0..ph {
1648                        for x in 0..pw {
1649                            let use_b = match direction {
1650                                WipeDirection::Left => x < boundary,
1651                                WipeDirection::Right => x >= pw.saturating_sub(boundary),
1652                                _ => false,
1653                            };
1654                            if use_b {
1655                                let src_idx = y * b_plane.stride + x;
1656                                let dst_idx = y * out_plane.stride + x;
1657                                if src_idx < b_plane.data.len() && dst_idx < new_data.len() {
1658                                    new_data[dst_idx] = b_plane.data[src_idx];
1659                                }
1660                            }
1661                        }
1662                    }
1663                }
1664                WipeDirection::Down | WipeDirection::Up => {
1665                    #[allow(clippy::cast_possible_truncation)]
1666                    #[allow(clippy::cast_sign_loss)]
1667                    let boundary = (progress * ph as f32).round() as usize;
1668                    for y in 0..ph {
1669                        let use_b = match direction {
1670                            WipeDirection::Down => y < boundary,
1671                            WipeDirection::Up => y >= ph.saturating_sub(boundary),
1672                            _ => false,
1673                        };
1674                        if use_b {
1675                            for x in 0..pw {
1676                                let src_idx = y * b_plane.stride + x;
1677                                let dst_idx = y * out_plane.stride + x;
1678                                if src_idx < b_plane.data.len() && dst_idx < new_data.len() {
1679                                    new_data[dst_idx] = b_plane.data[src_idx];
1680                                }
1681                            }
1682                        }
1683                    }
1684                }
1685            }
1686
1687            let new_plane = Plane::with_dimensions(
1688                new_data,
1689                out_plane.stride,
1690                out_plane.width,
1691                out_plane.height,
1692            );
1693            *out_plane = new_plane;
1694        }
1695
1696        output
1697    }
1698}
1699
1700/// Direction for wipe transitions.
1701#[derive(Clone, Copy)]
1702enum WipeDirection {
1703    Left,
1704    Right,
1705    Down,
1706    Up,
1707}
1708
1709// ─────────────────────────────────────────────────────────────────────────────
1710// Tests for TransitionRenderer
1711// ─────────────────────────────────────────────────────────────────────────────
1712
1713#[cfg(test)]
1714mod transition_renderer_tests {
1715    use super::*;
1716    use crate::transition::{Transition, TransitionType};
1717    use bytes::Bytes;
1718    use oximedia_audio::{AudioBuffer, AudioFrame, ChannelLayout};
1719    use oximedia_codec::VideoFrame;
1720    use oximedia_core::{PixelFormat, SampleFormat};
1721
1722    /// Build a solid-colour YUV420p video frame (all planes set to `value`).
1723    fn make_video_frame(width: u32, height: u32, value: u8) -> VideoFrame {
1724        let mut frame = VideoFrame::new(PixelFormat::Yuv420p, width, height);
1725        frame.allocate();
1726        for plane in &mut frame.planes {
1727            for b in &mut plane.data {
1728                *b = value;
1729            }
1730        }
1731        frame
1732    }
1733
1734    /// Build an F32 interleaved audio frame with all samples set to `value`.
1735    fn make_audio_frame(num_samples: usize, value: f32) -> AudioFrame {
1736        let bytes: Vec<u8> = (0..num_samples).flat_map(|_| value.to_ne_bytes()).collect();
1737        AudioFrame {
1738            format: SampleFormat::F32,
1739            sample_rate: 48_000,
1740            channels: ChannelLayout::Stereo,
1741            samples: AudioBuffer::Interleaved(Bytes::from(bytes)),
1742            timestamp: oximedia_core::Timestamp::new(0, oximedia_core::Rational::new(1, 48_000)),
1743        }
1744    }
1745
1746    /// Make a minimal `Transition` with the given type (no real clips needed).
1747    fn make_transition(tt: TransitionType) -> Transition {
1748        Transition::new(0, tt, 0, 0, 1000, 0, 1)
1749    }
1750
1751    // ── blend_video tests ────────────────────────────────────────────────────
1752
1753    /// Dissolve at progress=0.5 should produce pixels near (100+200)/2 = 150.
1754    #[test]
1755    fn test_blend_video_dissolve_mid() {
1756        let frame_a = make_video_frame(8, 4, 100);
1757        let frame_b = make_video_frame(8, 4, 200);
1758        let t = make_transition(TransitionType::Dissolve);
1759
1760        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 0.5);
1761
1762        for plane in &out.planes {
1763            for &b in &plane.data {
1764                assert!((i32::from(b) - 150).abs() <= 1, "expected ~150, got {b}");
1765            }
1766        }
1767    }
1768
1769    /// Dissolve at progress=0.0 should reproduce frame_a exactly.
1770    #[test]
1771    fn test_blend_video_dissolve_zero() {
1772        let frame_a = make_video_frame(8, 4, 80);
1773        let frame_b = make_video_frame(8, 4, 200);
1774        let t = make_transition(TransitionType::Dissolve);
1775
1776        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 0.0);
1777
1778        for plane in &out.planes {
1779            for &b in &plane.data {
1780                assert_eq!(b, 80);
1781            }
1782        }
1783    }
1784
1785    /// Dissolve at progress=1.0 should reproduce frame_b exactly.
1786    #[test]
1787    fn test_blend_video_dissolve_one() {
1788        let frame_a = make_video_frame(8, 4, 80);
1789        let frame_b = make_video_frame(8, 4, 200);
1790        let t = make_transition(TransitionType::Dissolve);
1791
1792        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 1.0);
1793
1794        for plane in &out.planes {
1795            for &b in &plane.data {
1796                assert_eq!(b, 200);
1797            }
1798        }
1799    }
1800
1801    /// Dimension mismatch must not panic and must return the larger frame.
1802    #[test]
1803    fn test_blend_video_dimension_mismatch_no_panic() {
1804        let frame_a = make_video_frame(8, 4, 100);
1805        let frame_b = make_video_frame(16, 8, 200);
1806        let t = make_transition(TransitionType::Dissolve);
1807
1808        // Must not panic.
1809        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 0.5);
1810        // The larger frame (frame_b, 16×8) is returned.
1811        assert_eq!(out.width, 16);
1812        assert_eq!(out.height, 8);
1813    }
1814
1815    /// Dimension mismatch: same format but frame_a is larger.
1816    #[test]
1817    fn test_blend_video_dimension_mismatch_a_larger() {
1818        let frame_a = make_video_frame(16, 8, 100);
1819        let frame_b = make_video_frame(8, 4, 200);
1820        let t = make_transition(TransitionType::Dissolve);
1821
1822        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 0.5);
1823        assert_eq!(out.width, 16);
1824        assert_eq!(out.height, 8);
1825    }
1826
1827    /// WipeLeft at 0.5 — left half of output should equal frame_b pixels.
1828    #[test]
1829    fn test_blend_video_wipe_left() {
1830        let frame_a = make_video_frame(8, 4, 10);
1831        let frame_b = make_video_frame(8, 4, 250);
1832        let t = make_transition(TransitionType::WipeLeft);
1833
1834        let out = TransitionRenderer::blend_video(&frame_a, &frame_b, &t, 0.5);
1835
1836        // Y-plane: columns 0-3 should be frame_b (250), columns 4-7 should be frame_a (10).
1837        let y_plane = &out.planes[0];
1838        for y in 0..4usize {
1839            for x in 0..4usize {
1840                assert_eq!(y_plane.data[y * y_plane.stride + x], 250, "x={x},y={y}");
1841            }
1842            for x in 4..8usize {
1843                assert_eq!(y_plane.data[y * y_plane.stride + x], 10, "x={x},y={y}");
1844            }
1845        }
1846    }
1847
1848    // ── mix_audio tests ──────────────────────────────────────────────────────
1849
1850    /// Cross-fade at 0.5: 0.5*a + 0.5*b; with a=0.5 and b=-0.5 → 0.0.
1851    #[test]
1852    fn test_mix_audio_f32_mid() {
1853        let frame_a = make_audio_frame(64, 0.5_f32);
1854        let frame_b = make_audio_frame(64, -0.5_f32);
1855        let t = make_transition(TransitionType::CrossFade);
1856
1857        let out = TransitionRenderer::mix_audio(&frame_a, &frame_b, &t, 0.5);
1858
1859        if let AudioBuffer::Interleaved(bytes) = &out.samples {
1860            for chunk in bytes.chunks_exact(4) {
1861                let v = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
1862                assert!(v.abs() < 1e-5, "expected ~0.0, got {v}");
1863            }
1864        } else {
1865            panic!("expected interleaved buffer");
1866        }
1867    }
1868
1869    /// Cross-fade at 0.0 should preserve frame_a samples.
1870    #[test]
1871    fn test_mix_audio_f32_zero() {
1872        let frame_a = make_audio_frame(32, 0.8_f32);
1873        let frame_b = make_audio_frame(32, -0.8_f32);
1874        let t = make_transition(TransitionType::CrossFade);
1875
1876        let out = TransitionRenderer::mix_audio(&frame_a, &frame_b, &t, 0.0);
1877
1878        if let AudioBuffer::Interleaved(bytes) = &out.samples {
1879            for chunk in bytes.chunks_exact(4) {
1880                let v = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
1881                assert!((v - 0.8).abs() < 1e-5, "expected 0.8, got {v}");
1882            }
1883        } else {
1884            panic!("expected interleaved buffer");
1885        }
1886    }
1887
1888    /// Cross-fade at 1.0 should reproduce frame_b samples.
1889    #[test]
1890    fn test_mix_audio_f32_one() {
1891        let frame_a = make_audio_frame(32, 0.3_f32);
1892        let frame_b = make_audio_frame(32, 0.9_f32);
1893        let t = make_transition(TransitionType::CrossFade);
1894
1895        let out = TransitionRenderer::mix_audio(&frame_a, &frame_b, &t, 1.0);
1896
1897        if let AudioBuffer::Interleaved(bytes) = &out.samples {
1898            for chunk in bytes.chunks_exact(4) {
1899                let v = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
1900                assert!((v - 0.9).abs() < 1e-5, "expected 0.9, got {v}");
1901            }
1902        } else {
1903            panic!("expected interleaved buffer");
1904        }
1905    }
1906
1907    /// Format mismatch on audio returns frame_a unchanged.
1908    #[test]
1909    fn test_mix_audio_format_mismatch() {
1910        let frame_a = make_audio_frame(16, 0.5_f32);
1911        let mut frame_b = make_audio_frame(16, -0.5_f32);
1912        frame_b.format = SampleFormat::S16; // intentional mismatch
1913
1914        let t = make_transition(TransitionType::CrossFade);
1915        let out = TransitionRenderer::mix_audio(&frame_a, &frame_b, &t, 0.5);
1916
1917        // Should return frame_a unchanged.
1918        assert_eq!(out.format, SampleFormat::F32);
1919        assert_eq!(out.samples.size(), frame_a.samples.size());
1920    }
1921}