1use 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#[derive(Clone, Debug)]
42pub struct RenderChunk {
43 pub chunk_id: usize,
45 pub frame_start: u64,
47 pub frame_end: u64,
49 pub output_path: Option<PathBuf>,
51}
52
53impl RenderChunk {
54 #[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 #[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 #[must_use]
83 pub fn frame_count(&self) -> u64 {
84 self.frame_end.saturating_sub(self.frame_start)
85 }
86}
87
88#[derive(Clone, Debug)]
94pub struct ParallelRenderConfig {
95 pub num_threads: usize,
97 pub chunk_size: u64,
99 pub render_config: RenderConfig,
101}
102
103impl ParallelRenderConfig {
104 #[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 #[must_use]
116 pub fn with_threads(mut self, n: usize) -> Self {
117 self.num_threads = n.max(1);
118 self
119 }
120
121 #[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#[derive(Clone, Debug)]
135pub struct ParallelRenderResult {
136 pub chunk: RenderChunk,
138 pub frames_rendered: u64,
140 pub duration_ms: u64,
142 pub success: bool,
144 pub error: Option<String>,
146}
147
148impl ParallelRenderResult {
149 #[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 #[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
174pub struct ParallelRenderer {
183 pub config: ParallelRenderConfig,
185}
186
187impl ParallelRenderer {
188 #[must_use]
190 pub fn new(config: ParallelRenderConfig) -> Self {
191 Self { config }
192 }
193
194 #[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; (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 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 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 pub fn render_chunk(
261 &self,
262 chunk: &RenderChunk,
263 _timeline: &Arc<Timeline>,
264 ) -> ParallelRenderResult {
265 let start_time = Instant::now();
266
267 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 for _frame in chunk.frame_start..chunk.frame_end {
284 }
286
287 let duration_ms = start_time.elapsed().as_millis() as u64;
288 ParallelRenderResult::ok(chunk.clone(), frames_rendered, duration_ms)
289 }
290
291 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum TrackKind {
309 Video,
311 Audio,
313}
314
315impl TrackKind {
316 #[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#[derive(Clone, Debug)]
332pub struct ClipWithSource {
333 pub clip: Clip,
335 pub source: Arc<RenderSource>,
337}
338
339#[derive(Clone, Debug)]
346pub struct TrackRenderInput {
347 pub track_index: usize,
349 pub kind: TrackKind,
351 pub clips: Vec<ClipWithSource>,
355 pub position: i64,
357 pub width: u32,
359 pub height: u32,
361 pub channels: usize,
363 pub sample_rate: u32,
365 pub num_samples: usize,
367}
368
369impl TrackRenderInput {
370 #[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 #[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#[derive(Clone, Debug)]
418pub struct TrackRenderOutput {
419 pub track_index: usize,
421 pub kind: TrackKind,
423 pub video_rgba8: Vec<u8>,
427 pub audio_samples: Vec<f32>,
432}
433
434#[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
463fn 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 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 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 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 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
536fn 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 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#[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
595pub 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; };
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, ),
649 };
650 inputs.push(input);
651 }
652
653 inputs
654}
655
656#[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 #[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 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 let seq_outputs: Vec<TrackRenderOutput> =
776 inputs.iter().map(render_track_frame_stateless).collect();
777
778 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 #[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 #[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 for &s in &out.audio_samples {
858 assert!(s.abs() <= 1.0, "audio sample {s} exceeds [-1, 1] bounds");
859 }
860 }
861
862 #[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}