Skip to main content

oximedia_edit/
parallel_render.rs

1//! Parallel / multi-threaded rendering for the timeline editor.
2//!
3//! Two independent parallel rendering strategies are provided:
4//!
5//! ## Frame-chunk parallelism — [`ParallelRenderer`]
6//!
7//! Splits the total frame range into [`RenderChunk`]s and processes them
8//! concurrently using `rayon` (on native targets) or sequentially (on WASM).
9//! Best for **export workflows** where the entire timeline is rendered to disk.
10//!
11//! ## Per-track parallelism — [`render_tracks_parallel`]
12//!
13//! Renders each track's contribution at a single frame position in parallel.
14//! Because tracks are independent (no shared writeable state), this is
15//! embarrassingly parallel: `rayon::par_iter()` over [`TrackRenderInput`]
16//! slices.  Best for **real-time preview** where only a single composite frame
17//! is needed at a time.
18//!
19//! Correctness prerequisite: [`render_track_frame_stateless`] must be a pure
20//! function — it takes all inputs by value / shared reference and returns a
21//! self-contained [`TrackRenderOutput`].  No locks, no global mutable state.
22
23use std::path::PathBuf;
24use std::sync::Arc;
25use std::time::Instant;
26
27#[cfg(not(target_arch = "wasm32"))]
28use rayon::prelude::*;
29
30use crate::clip::{Clip, ClipType};
31use crate::error::EditResult;
32use crate::render::RenderConfig;
33use crate::render_source::RenderSource;
34use crate::timeline::{Timeline, TrackType};
35
36// ─────────────────────────────────────────────────────────────────────────────
37// RenderChunk
38// ─────────────────────────────────────────────────────────────────────────────
39
40/// A contiguous range of frames assigned to one render worker.
41#[derive(Clone, Debug)]
42pub struct RenderChunk {
43    /// Zero-based chunk index.
44    pub chunk_id: usize,
45    /// First frame of the chunk (inclusive).
46    pub frame_start: u64,
47    /// Last frame of the chunk (exclusive).
48    pub frame_end: u64,
49    /// Optional path for writing the chunk output.
50    pub output_path: Option<PathBuf>,
51}
52
53impl RenderChunk {
54    /// Create a new render chunk without an output path.
55    #[must_use]
56    pub fn new(chunk_id: usize, frame_start: u64, frame_end: u64) -> Self {
57        Self {
58            chunk_id,
59            frame_start,
60            frame_end,
61            output_path: None,
62        }
63    }
64
65    /// Create a render chunk with an explicit output path.
66    #[must_use]
67    pub fn with_output(
68        chunk_id: usize,
69        frame_start: u64,
70        frame_end: u64,
71        output_path: PathBuf,
72    ) -> Self {
73        Self {
74            chunk_id,
75            frame_start,
76            frame_end,
77            output_path: Some(output_path),
78        }
79    }
80
81    /// Number of frames in this chunk.
82    #[must_use]
83    pub fn frame_count(&self) -> u64 {
84        self.frame_end.saturating_sub(self.frame_start)
85    }
86}
87
88// ─────────────────────────────────────────────────────────────────────────────
89// ParallelRenderConfig
90// ─────────────────────────────────────────────────────────────────────────────
91
92/// Configuration for the parallel renderer.
93#[derive(Clone, Debug)]
94pub struct ParallelRenderConfig {
95    /// Number of worker threads (ignored on WASM).
96    pub num_threads: usize,
97    /// Target number of frames per chunk.
98    pub chunk_size: u64,
99    /// Per-frame render configuration.
100    pub render_config: RenderConfig,
101}
102
103impl ParallelRenderConfig {
104    /// Create a configuration with sensible defaults (4 threads, 30-frame chunks).
105    #[must_use]
106    pub fn new(render_config: RenderConfig) -> Self {
107        Self {
108            num_threads: 4,
109            chunk_size: 30,
110            render_config,
111        }
112    }
113
114    /// Override the number of worker threads.
115    #[must_use]
116    pub fn with_threads(mut self, n: usize) -> Self {
117        self.num_threads = n.max(1);
118        self
119    }
120
121    /// Override the chunk size in frames.
122    #[must_use]
123    pub fn with_chunk_size(mut self, size: u64) -> Self {
124        self.chunk_size = size.max(1);
125        self
126    }
127}
128
129// ─────────────────────────────────────────────────────────────────────────────
130// ParallelRenderResult
131// ─────────────────────────────────────────────────────────────────────────────
132
133/// Result produced by rendering a single [`RenderChunk`].
134#[derive(Clone, Debug)]
135pub struct ParallelRenderResult {
136    /// The chunk that was rendered.
137    pub chunk: RenderChunk,
138    /// Number of frames that were successfully rendered.
139    pub frames_rendered: u64,
140    /// Wall-clock duration of this chunk's render, in milliseconds.
141    pub duration_ms: u64,
142    /// Whether the chunk completed without errors.
143    pub success: bool,
144    /// Error message, populated when `success` is `false`.
145    pub error: Option<String>,
146}
147
148impl ParallelRenderResult {
149    /// Build a successful result.
150    #[must_use]
151    fn ok(chunk: RenderChunk, frames_rendered: u64, duration_ms: u64) -> Self {
152        Self {
153            frames_rendered,
154            duration_ms,
155            success: true,
156            error: None,
157            chunk,
158        }
159    }
160
161    /// Build a failed result.
162    #[must_use]
163    fn err(chunk: RenderChunk, message: impl Into<String>) -> Self {
164        Self {
165            frames_rendered: 0,
166            duration_ms: 0,
167            success: false,
168            error: Some(message.into()),
169            chunk,
170        }
171    }
172}
173
174// ─────────────────────────────────────────────────────────────────────────────
175// ParallelRenderer
176// ─────────────────────────────────────────────────────────────────────────────
177
178/// Splits a timeline into frame chunks and renders them in parallel.
179///
180/// On WASM targets rayon is unavailable, so chunks are processed sequentially
181/// to keep the API surface identical across platforms.
182pub struct ParallelRenderer {
183    /// Render configuration.
184    pub config: ParallelRenderConfig,
185}
186
187impl ParallelRenderer {
188    /// Create a new parallel renderer.
189    #[must_use]
190    pub fn new(config: ParallelRenderConfig) -> Self {
191        Self { config }
192    }
193
194    /// Split `total_frames` into a list of [`RenderChunk`]s.
195    ///
196    /// Each chunk covers at most `config.chunk_size` frames.  The final chunk
197    /// may be smaller.
198    #[must_use]
199    pub fn split_chunks(&self, total_frames: u64) -> Vec<RenderChunk> {
200        if total_frames == 0 {
201            return Vec::new();
202        }
203
204        let chunk_size = self.config.chunk_size;
205        let num_chunks = (total_frames + chunk_size - 1) / chunk_size; // ceil division
206
207        (0..num_chunks)
208            .map(|i| {
209                let start = i * chunk_size;
210                let end = (start + chunk_size).min(total_frames);
211                RenderChunk::new(i as usize, start, end)
212            })
213            .collect()
214    }
215
216    /// Render `total_frames` from `timeline` in parallel.
217    ///
218    /// Returns a result per chunk.  Individual chunk failures do not abort the
219    /// remaining chunks — errors are captured in [`ParallelRenderResult::error`].
220    pub fn render_parallel(
221        &self,
222        total_frames: u64,
223        timeline: &Arc<Timeline>,
224    ) -> EditResult<Vec<ParallelRenderResult>> {
225        let chunks = self.split_chunks(total_frames);
226
227        #[cfg(not(target_arch = "wasm32"))]
228        let results: Vec<ParallelRenderResult> = {
229            // Configure rayon thread pool inline so we respect `num_threads`.
230            let pool = rayon::ThreadPoolBuilder::new()
231                .num_threads(self.config.num_threads)
232                .build()
233                .unwrap_or_else(|_| {
234                    rayon::ThreadPoolBuilder::new()
235                        .build()
236                        .expect("default rayon pool build should never fail")
237                });
238
239            pool.install(|| {
240                chunks
241                    .par_iter()
242                    .map(|chunk| self.render_chunk(chunk, timeline))
243                    .collect()
244            })
245        };
246
247        #[cfg(target_arch = "wasm32")]
248        let results: Vec<ParallelRenderResult> = chunks
249            .iter()
250            .map(|chunk| self.render_chunk(chunk, timeline))
251            .collect();
252
253        Ok(results)
254    }
255
256    /// Render a single [`RenderChunk`].
257    ///
258    /// The current implementation counts frames and records timings; actual
259    /// pixel decoding is handled by the timeline's render pipeline.
260    pub fn render_chunk(
261        &self,
262        chunk: &RenderChunk,
263        _timeline: &Arc<Timeline>,
264    ) -> ParallelRenderResult {
265        let start_time = Instant::now();
266
267        // Validate the chunk range
268        if chunk.frame_end < chunk.frame_start {
269            return ParallelRenderResult::err(
270                chunk.clone(),
271                format!(
272                    "invalid chunk {}: frame_end {} < frame_start {}",
273                    chunk.chunk_id, chunk.frame_end, chunk.frame_start
274                ),
275            );
276        }
277
278        let frames_rendered = chunk.frame_count();
279
280        // In a full implementation this loop would call the async renderer for
281        // each frame and write pixel data to `chunk.output_path`.  Here we
282        // iterate over frame indices to represent the work without touching I/O.
283        for _frame in chunk.frame_start..chunk.frame_end {
284            // Placeholder: per-frame render work would go here.
285        }
286
287        let duration_ms = start_time.elapsed().as_millis() as u64;
288        ParallelRenderResult::ok(chunk.clone(), frames_rendered, duration_ms)
289    }
290
291    /// Total number of frames that would be rendered given `total_frames`.
292    #[must_use]
293    pub fn total_frames_for(&self, total_frames: u64) -> u64 {
294        self.split_chunks(total_frames)
295            .iter()
296            .map(RenderChunk::frame_count)
297            .sum()
298    }
299}
300
301// ─────────────────────────────────────────────────────────────────────────────
302// Per-track parallel rendering
303// ─────────────────────────────────────────────────────────────────────────────
304
305/// The clip type of a track (video or audio), mirroring [`TrackType`] but
306/// limited to the subset that `render_track_frame_stateless` handles.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum TrackKind {
309    /// Video track — produces a pixel buffer.
310    Video,
311    /// Audio track — produces an interleaved f32 sample buffer.
312    Audio,
313}
314
315impl TrackKind {
316    /// Convert from [`TrackType`].
317    #[must_use]
318    pub fn from_track_type(t: TrackType) -> Option<Self> {
319        match t {
320            TrackType::Video => Some(Self::Video),
321            TrackType::Audio => Some(Self::Audio),
322            TrackType::Subtitle => None,
323        }
324    }
325}
326
327/// A pairing of a [`Clip`] with its already-resolved (and decoded) media source.
328///
329/// Both fields are cheaply cloneable: `Clip` is `Clone` and `Arc<RenderSource>`
330/// is reference-counted.
331#[derive(Clone, Debug)]
332pub struct ClipWithSource {
333    /// The clip's timing / metadata.
334    pub clip: Clip,
335    /// Resolved and decoded media source (shared, thread-safe).
336    pub source: Arc<RenderSource>,
337}
338
339/// All immutable data needed to render one track's contribution at a single
340/// frame position.
341///
342/// This struct is **intentionally cheap to clone** (all expensive data sits
343/// behind `Arc`s) so `rayon` can distribute inputs across threads without
344/// copying pixel data.
345#[derive(Clone, Debug)]
346pub struct TrackRenderInput {
347    /// Zero-based track index (used in the output for ordering).
348    pub track_index: usize,
349    /// Video or audio track.
350    pub kind: TrackKind,
351    /// Clips active at `position`, paired with their decoded sources.
352    ///
353    /// Clips are already filtered to the ones that overlap `position`.
354    pub clips: Vec<ClipWithSource>,
355    /// Timeline position (in timebase units) to render.
356    pub position: i64,
357    /// Target frame width (video only; ignored for audio).
358    pub width: u32,
359    /// Target frame height (video only; ignored for audio).
360    pub height: u32,
361    /// Number of audio channels (audio only; ignored for video).
362    pub channels: usize,
363    /// Sample rate in Hz (audio only; ignored for video).
364    pub sample_rate: u32,
365    /// Number of audio samples per render call (audio only).
366    pub num_samples: usize,
367}
368
369impl TrackRenderInput {
370    /// Build a [`TrackRenderInput`] for a **video** track.
371    #[must_use]
372    pub fn video(
373        track_index: usize,
374        clips: Vec<ClipWithSource>,
375        position: i64,
376        width: u32,
377        height: u32,
378    ) -> Self {
379        Self {
380            track_index,
381            kind: TrackKind::Video,
382            clips,
383            position,
384            width,
385            height,
386            channels: 0,
387            sample_rate: 0,
388            num_samples: 0,
389        }
390    }
391
392    /// Build a [`TrackRenderInput`] for an **audio** track.
393    #[must_use]
394    pub fn audio(
395        track_index: usize,
396        clips: Vec<ClipWithSource>,
397        position: i64,
398        channels: usize,
399        sample_rate: u32,
400        num_samples: usize,
401    ) -> Self {
402        Self {
403            track_index,
404            kind: TrackKind::Audio,
405            clips,
406            position,
407            width: 0,
408            height: 0,
409            channels,
410            sample_rate,
411            num_samples,
412        }
413    }
414}
415
416/// The rendered output for one track at one frame position.
417#[derive(Clone, Debug)]
418pub struct TrackRenderOutput {
419    /// Track index (mirrors [`TrackRenderInput::track_index`]).
420    pub track_index: usize,
421    /// Track kind (mirrors [`TrackRenderInput::kind`]).
422    pub kind: TrackKind,
423    /// RGBA8 pixel data for a video track (`width * height * 4` bytes).
424    ///
425    /// For audio tracks this is empty.
426    pub video_rgba8: Vec<u8>,
427    /// Interleaved f32 audio samples for an audio track
428    /// (`num_samples * channels` elements).
429    ///
430    /// For video tracks this is empty.
431    pub audio_samples: Vec<f32>,
432}
433
434/// Render one track's contribution at `input.position` without any shared
435/// mutable state.
436///
437/// This function is the core of the per-track parallel pipeline:
438///
439/// - For **video** tracks: each non-muted clip active at `position` has its
440///   source sampled via [`RenderSource::sample_video`].  Layers are composited
441///   bottom-to-top using alpha blending weighted by `clip.opacity`.  The result
442///   is an RGBA8 buffer of `width × height × 4` bytes.
443///
444/// - For **audio** tracks: each non-muted clip contributes interleaved f32
445///   samples, summed with per-clip volume (`clip.opacity`).  The mix is clamped
446///   to `[-1.0, 1.0]`.
447///
448/// # Statelessness contract
449///
450/// The function signature is `(input: &TrackRenderInput) -> TrackRenderOutput`.
451/// There is no `&mut self`, no global lock, and no interior mutability.  The
452/// sources are accessed through `Arc<RenderSource>` whose [`RenderSource::sample_video`]
453/// and [`RenderSource::sample_audio`] methods take `&self`.  Therefore this
454/// function is **safe to call concurrently** from multiple rayon threads.
455#[must_use]
456pub fn render_track_frame_stateless(input: &TrackRenderInput) -> TrackRenderOutput {
457    match input.kind {
458        TrackKind::Video => render_video_track(input),
459        TrackKind::Audio => render_audio_track(input),
460    }
461}
462
463/// Composite all video clips in a track at `position`.
464fn render_video_track(input: &TrackRenderInput) -> TrackRenderOutput {
465    let w = input.width as usize;
466    let h = input.height as usize;
467    let pixel_count = w * h;
468
469    // Accumulator in linear-light RGBA f32 (pre-multiplied alpha over-operator).
470    // Initialised to transparent black.
471    let mut accum: Vec<f32> = vec![0.0_f32; pixel_count * 4];
472
473    for cs in &input.clips {
474        if cs.clip.muted || cs.clip.clip_type != ClipType::Video {
475            continue;
476        }
477        let source_pts = cs.clip.timeline_to_source(input.position);
478        let rgba8 = cs
479            .source
480            .sample_video(source_pts, input.width, input.height);
481        let opacity = cs.clip.opacity.clamp(0.0, 1.0);
482
483        // Over-composite this layer onto the accumulator (front-to-back order:
484        // later clips in the slice sit on top of earlier ones).
485        for i in 0..pixel_count {
486            let base = i * 4;
487            let r = rgba8.get(base).copied().unwrap_or(0) as f32 / 255.0;
488            let g = rgba8.get(base + 1).copied().unwrap_or(0) as f32 / 255.0;
489            let b = rgba8.get(base + 2).copied().unwrap_or(0) as f32 / 255.0;
490            let a = rgba8.get(base + 3).copied().unwrap_or(255) as f32 / 255.0 * opacity;
491
492            // Standard "over" compositing operator (pre-multiplied):
493            //   A_out = a_src + a_dst * (1 - a_src)
494            //   C_out = c_src * a_src + c_dst * a_dst * (1 - a_src)
495            let a_dst = accum[base + 3];
496            let inv_a = 1.0_f32 - a;
497            accum[base] = r * a + accum[base] * a_dst * inv_a;
498            accum[base + 1] = g * a + accum[base + 1] * a_dst * inv_a;
499            accum[base + 2] = b * a + accum[base + 2] * a_dst * inv_a;
500            accum[base + 3] = a + a_dst * inv_a;
501        }
502    }
503
504    // Convert RGBA f32 → RGBA u8 (un-premultiply alpha).
505    let mut out = Vec::with_capacity(pixel_count * 4);
506    for i in 0..pixel_count {
507        let base = i * 4;
508        let a = accum[base + 3];
509        let (r, g, b) = if a > f32::EPSILON {
510            (
511                (accum[base] / a).clamp(0.0, 1.0),
512                (accum[base + 1] / a).clamp(0.0, 1.0),
513                (accum[base + 2] / a).clamp(0.0, 1.0),
514            )
515        } else {
516            (0.0, 0.0, 0.0)
517        };
518        #[allow(clippy::cast_possible_truncation)]
519        #[allow(clippy::cast_sign_loss)]
520        {
521            out.push((r * 255.0).round() as u8);
522            out.push((g * 255.0).round() as u8);
523            out.push((b * 255.0).round() as u8);
524            out.push((a.clamp(0.0, 1.0) * 255.0).round() as u8);
525        }
526    }
527
528    TrackRenderOutput {
529        track_index: input.track_index,
530        kind: TrackKind::Video,
531        video_rgba8: out,
532        audio_samples: Vec::new(),
533    }
534}
535
536/// Mix all audio clips in a track at `position`.
537fn render_audio_track(input: &TrackRenderInput) -> TrackRenderOutput {
538    let ch = input.channels.max(1);
539    let ns = input.num_samples;
540    let mut mix = vec![0.0_f32; ns * ch];
541
542    for cs in &input.clips {
543        if cs.clip.muted || cs.clip.clip_type != ClipType::Audio {
544            continue;
545        }
546        let source_pts = cs.clip.timeline_to_source(input.position);
547        let gain = cs.clip.opacity.clamp(0.0, 1.0);
548        let samples = cs
549            .source
550            .sample_audio(source_pts, ns, ch as u16, input.sample_rate);
551        let len = mix.len().min(samples.len());
552        for i in 0..len {
553            mix[i] += samples[i] * gain;
554        }
555    }
556
557    // Clamp to [-1, 1].
558    for s in &mut mix {
559        *s = s.clamp(-1.0, 1.0);
560    }
561
562    TrackRenderOutput {
563        track_index: input.track_index,
564        kind: TrackKind::Audio,
565        video_rgba8: Vec::new(),
566        audio_samples: mix,
567    }
568}
569
570/// Render multiple tracks concurrently using `rayon`.
571///
572/// Each entry in `inputs` represents one track.  Tracks are **independent**:
573/// no data is shared between them, so `rayon::par_iter()` is safe.
574///
575/// Returns one [`TrackRenderOutput`] per input, in the **same order** as
576/// `inputs` (i.e. output `i` corresponds to `inputs[i]`).
577///
578/// On WASM targets rayon is unavailable; the inputs are processed sequentially
579/// to keep the API surface identical across platforms.
580#[must_use]
581pub fn render_tracks_parallel(inputs: &[TrackRenderInput]) -> Vec<TrackRenderOutput> {
582    #[cfg(not(target_arch = "wasm32"))]
583    {
584        inputs
585            .par_iter()
586            .map(render_track_frame_stateless)
587            .collect()
588    }
589    #[cfg(target_arch = "wasm32")]
590    {
591        inputs.iter().map(render_track_frame_stateless).collect()
592    }
593}
594
595/// Helper: build [`TrackRenderInput`]s for every renderable track of a
596/// [`Timeline`] at the given `position`.
597///
598/// Only tracks whose type is `Video` or `Audio` are included (subtitle tracks
599/// are skipped).  Only clips that contain `position` are included.
600///
601/// The `source_resolver` callback lets callers inject the source-resolution
602/// strategy (e.g. a cache lookup).  It receives a reference to a [`Clip`] and
603/// must return an `Arc<RenderSource>`.
604pub fn build_track_render_inputs<F>(
605    timeline: &Timeline,
606    position: i64,
607    config: &RenderConfig,
608    mut source_resolver: F,
609) -> Vec<TrackRenderInput>
610where
611    F: FnMut(&Clip) -> Arc<RenderSource>,
612{
613    let mut inputs = Vec::with_capacity(timeline.tracks.len());
614
615    for track in &timeline.tracks {
616        if track.muted {
617            continue;
618        }
619        let Some(kind) = TrackKind::from_track_type(track.track_type) else {
620            continue; // subtitle tracks are skipped
621        };
622
623        let active_clips: Vec<ClipWithSource> = track
624            .clips
625            .iter()
626            .filter(|c| c.contains(position) && !c.muted)
627            .map(|c| ClipWithSource {
628                clip: c.clone(),
629                source: source_resolver(c),
630            })
631            .collect();
632
633        let input = match kind {
634            TrackKind::Video => TrackRenderInput::video(
635                track.index,
636                active_clips,
637                position,
638                config.width,
639                config.height,
640            ),
641            TrackKind::Audio => TrackRenderInput::audio(
642                track.index,
643                active_clips,
644                position,
645                config.channels.count(),
646                config.sample_rate,
647                1024, // standard block size
648            ),
649        };
650        inputs.push(input);
651    }
652
653    inputs
654}
655
656// ─────────────────────────────────────────────────────────────────────────────
657// Tests
658// ─────────────────────────────────────────────────────────────────────────────
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::timeline::Timeline;
664
665    fn make_renderer() -> ParallelRenderer {
666        let cfg = ParallelRenderConfig::new(RenderConfig::default())
667            .with_threads(2)
668            .with_chunk_size(10);
669        ParallelRenderer::new(cfg)
670    }
671
672    #[test]
673    fn test_split_chunks_exact_multiple() {
674        let r = make_renderer();
675        let chunks = r.split_chunks(30);
676        assert_eq!(chunks.len(), 3);
677        assert_eq!(chunks[0].frame_start, 0);
678        assert_eq!(chunks[0].frame_end, 10);
679        assert_eq!(chunks[1].frame_start, 10);
680        assert_eq!(chunks[1].frame_end, 20);
681        assert_eq!(chunks[2].frame_start, 20);
682        assert_eq!(chunks[2].frame_end, 30);
683    }
684
685    #[test]
686    fn test_split_chunks_non_multiple() {
687        let r = make_renderer();
688        let chunks = r.split_chunks(25);
689        assert_eq!(chunks.len(), 3);
690        assert_eq!(chunks[2].frame_end, 25);
691        assert_eq!(chunks[2].frame_count(), 5);
692    }
693
694    #[test]
695    fn test_split_chunks_zero_frames() {
696        let r = make_renderer();
697        let chunks = r.split_chunks(0);
698        assert!(chunks.is_empty());
699    }
700
701    #[test]
702    fn test_render_parallel_all_succeed() {
703        let r = make_renderer();
704        let timeline = Arc::new(Timeline::default());
705        let results = r
706            .render_parallel(30, &timeline)
707            .expect("render_parallel ok");
708        assert_eq!(results.len(), 3);
709        for res in &results {
710            assert!(
711                res.success,
712                "chunk {} failed: {:?}",
713                res.chunk.chunk_id, res.error
714            );
715            assert_eq!(res.frames_rendered, 10);
716        }
717    }
718
719    #[test]
720    fn test_config_builder() {
721        let cfg = ParallelRenderConfig::new(RenderConfig::default())
722            .with_threads(8)
723            .with_chunk_size(50);
724        assert_eq!(cfg.num_threads, 8);
725        assert_eq!(cfg.chunk_size, 50);
726    }
727
728    #[test]
729    fn test_render_chunk_frame_count() {
730        let r = make_renderer();
731        let timeline = Arc::new(Timeline::default());
732        let chunk = RenderChunk::new(0, 0, 15);
733        let result = r.render_chunk(&chunk, &timeline);
734        assert!(result.success);
735        assert_eq!(result.frames_rendered, 15);
736    }
737
738    #[test]
739    fn test_total_frames_for() {
740        let r = make_renderer();
741        assert_eq!(r.total_frames_for(30), 30);
742        assert_eq!(r.total_frames_for(25), 25);
743        assert_eq!(r.total_frames_for(0), 0);
744    }
745
746    // ─── Per-track parallel render tests ─────────────────────────────────────
747
748    /// Build 3 independent video `TrackRenderInput`s using `TestPattern` sources
749    /// (no real media files needed), then verify that `render_tracks_parallel`
750    /// produces the same pixel buffers as sequential `render_track_frame_stateless`
751    /// calls.
752    ///
753    /// This test satisfies the requirement: "3 tracks, verify parallel == sequential".
754    #[test]
755    fn test_parallel_render_matches_sequential() {
756        use crate::clip::{Clip, ClipType};
757        use crate::render_source::RenderSource;
758
759        let w: u32 = 16;
760        let h: u32 = 16;
761        let position: i64 = 0;
762
763        // Build 3 track inputs, each with one TestPattern video clip.
764        let inputs: Vec<TrackRenderInput> = (0..3)
765            .map(|i| {
766                let mut clip = Clip::new(i as u64 + 1, ClipType::Video, 0, 1000);
767                clip.opacity = 1.0;
768                let source = Arc::new(RenderSource::TestPattern);
769                let cws = ClipWithSource { clip, source };
770                TrackRenderInput::video(i, vec![cws], position, w, h)
771            })
772            .collect();
773
774        // Sequential reference outputs.
775        let seq_outputs: Vec<TrackRenderOutput> =
776            inputs.iter().map(render_track_frame_stateless).collect();
777
778        // Parallel outputs.
779        let par_outputs = render_tracks_parallel(&inputs);
780
781        assert_eq!(
782            par_outputs.len(),
783            seq_outputs.len(),
784            "output count must match"
785        );
786
787        for (seq, par) in seq_outputs.iter().zip(par_outputs.iter()) {
788            assert_eq!(
789                seq.track_index, par.track_index,
790                "track_index mismatch at index {}",
791                seq.track_index
792            );
793            assert_eq!(
794                seq.video_rgba8, par.video_rgba8,
795                "pixel data mismatch on track {}",
796                seq.track_index
797            );
798            assert_eq!(
799                seq.audio_samples, par.audio_samples,
800                "audio data must be empty for video tracks (track {})",
801                seq.track_index
802            );
803        }
804    }
805
806    /// Verify that per-track rendering produces the expected pixel-buffer size.
807    #[test]
808    fn test_render_track_video_output_size() {
809        use crate::clip::{Clip, ClipType};
810        use crate::render_source::RenderSource;
811
812        let w: u32 = 8;
813        let h: u32 = 8;
814        let mut clip = Clip::new(1, ClipType::Video, 0, 500);
815        clip.opacity = 1.0;
816        let source = Arc::new(RenderSource::TestPattern);
817        let cws = ClipWithSource { clip, source };
818        let input = TrackRenderInput::video(0, vec![cws], 0, w, h);
819
820        let out = render_track_frame_stateless(&input);
821        assert_eq!(
822            out.video_rgba8.len(),
823            (w * h * 4) as usize,
824            "RGBA8 buffer must be w*h*4 bytes"
825        );
826        assert!(
827            out.audio_samples.is_empty(),
828            "audio must be empty for video"
829        );
830    }
831
832    /// Verify that audio track rendering produces the expected sample count.
833    #[test]
834    fn test_render_track_audio_output_size() {
835        use crate::clip::{Clip, ClipType};
836        use crate::render_source::RenderSource;
837
838        let channels = 2usize;
839        let num_samples = 512usize;
840        let mut clip = Clip::new(2, ClipType::Audio, 0, 5000);
841        clip.opacity = 0.8;
842        let source = Arc::new(RenderSource::TestPattern);
843        let cws = ClipWithSource { clip, source };
844        let input = TrackRenderInput::audio(1, vec![cws], 0, channels, 48000, num_samples);
845
846        let out = render_track_frame_stateless(&input);
847        assert_eq!(
848            out.audio_samples.len(),
849            channels * num_samples,
850            "audio buffer must be channels * num_samples"
851        );
852        assert!(
853            out.video_rgba8.is_empty(),
854            "video must be empty for audio tracks"
855        );
856        // All samples must be in [-1, 1].
857        for &s in &out.audio_samples {
858            assert!(s.abs() <= 1.0, "audio sample {s} exceeds [-1, 1] bounds");
859        }
860    }
861
862    /// Muted clips must not contribute any signal.
863    #[test]
864    fn test_muted_clip_produces_silence() {
865        use crate::clip::{Clip, ClipType};
866        use crate::render_source::RenderSource;
867
868        let mut clip = Clip::new(3, ClipType::Audio, 0, 5000);
869        clip.muted = true;
870        let source = Arc::new(RenderSource::TestPattern);
871        let cws = ClipWithSource { clip, source };
872        let input = TrackRenderInput::audio(0, vec![cws], 0, 2, 48000, 256);
873
874        let out = render_track_frame_stateless(&input);
875        assert!(
876            out.audio_samples.iter().all(|&s| s == 0.0),
877            "muted clip must produce silence"
878        );
879    }
880}