1use alpha2_eval::FrameNeed;
2use alpha2_media::{PixelLayout, SourceId, VideoCodec, YuvEncoding};
3use alpha2_render::{FrameSubmitTimings, Renderer, Yuv420pReadback, Yuv420pTargets};
4use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
5use std::time::Duration;
6use web_time::Instant;
7
8use crate::schedule::frame_times;
9use crate::{
10 EngineError, EngineProject, FrameProvider, FrameRenderer, GpuFrameRenderer, MissingFramePolicy,
11 QualityProfile,
12};
13
14pub const MAX_EXPORT_FRAMES: u64 = 10_000_000;
16const MAX_PREFETCH_FRAMES: u32 = 120;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ExportFrameNeeds {
21 pub index: u64,
23 pub time: MediaTime,
25 pub needs: Vec<FrameNeed>,
27}
28
29#[derive(Copy, Clone, Debug, Eq, PartialEq)]
31pub struct ExportProgress {
32 pub completed: u64,
34 pub total: u64,
36}
37
38impl ExportProgress {
39 #[must_use]
41 #[allow(clippy::cast_precision_loss)]
42 pub fn fraction(self) -> f64 {
43 if self.total == 0 {
44 1.0
45 } else {
46 self.completed as f64 / self.total as f64
47 }
48 }
49}
50
51#[derive(Debug)]
53#[allow(clippy::large_enum_variant)]
55pub enum ExportStep {
56 Waiting {
58 time: MediaTime,
60 missing_sources: Vec<SourceId>,
62 },
63 Rendered {
65 index: u64,
67 time: MediaTime,
69 readback: Yuv420pReadback,
71 },
72 Complete,
74}
75
76#[derive(Debug)]
78pub enum ExportTextureStep<I = alpha2_render::RenderedTexture> {
79 Waiting {
81 time: MediaTime,
83 missing_sources: Vec<SourceId>,
85 },
86 Rendered {
88 index: u64,
90 time: MediaTime,
92 texture: I,
95 },
96 Complete,
98}
99
100#[derive(Debug)]
102pub enum ExportPlaneStep {
103 Waiting {
105 time: MediaTime,
107 missing_sources: Vec<SourceId>,
109 },
110 Rendered {
112 index: u64,
114 time: MediaTime,
116 },
117 Complete,
119}
120
121#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
126pub struct ExportStepTimings {
127 pub evaluate: Duration,
129 pub prepare: Duration,
131 pub submit: FrameSubmitTimings,
133}
134
135impl ExportStepTimings {
136 #[must_use]
138 pub fn merged(self, other: Self) -> Self {
139 Self {
140 evaluate: self.evaluate.saturating_add(other.evaluate),
141 prepare: self.prepare.saturating_add(other.prepare),
142 submit: self.submit.merged(other.submit),
143 }
144 }
145}
146
147#[derive(Clone, Debug)]
149pub struct Exporter {
150 snapshot: u64,
152 quality: QualityProfile,
153 encoding: YuvEncoding,
154 pixels: PixelLayout,
155 background: bool,
157 frame_rate: FrameRate,
158 effective_range: TimeRange,
159 next_frame: i64,
160 end_frame: i64,
161 completed: u64,
162 total: u64,
163}
164
165impl Exporter {
166 pub fn new(
176 snapshot: &EngineProject,
177 range: TimeRange,
178 frame_rate: FrameRate,
179 quality: QualityProfile,
180 codec: VideoCodec,
181 ) -> Result<Self, EngineError> {
182 quality.export_size(&snapshot.project().settings)?;
183 Self::over(
184 snapshot,
185 range,
186 frame_rate,
187 quality,
188 YuvEncoding::export(codec),
189 )
190 }
191
192 pub fn still(
198 snapshot: &EngineProject,
199 time: MediaTime,
200 frame_rate: FrameRate,
201 quality: QualityProfile,
202 ) -> Result<Self, EngineError> {
203 quality.render_size(&snapshot.project().settings)?;
204 let range = TimeRange::new(
205 time,
206 MediaTime::from_ticks(time.ticks().checked_add(1).ok_or(EngineError::OutOfRange)?),
207 )
208 .map_err(|_| EngineError::OutOfRange)?;
209 Self::over(
210 snapshot,
211 range,
212 frame_rate,
213 quality,
214 YuvEncoding::export(VideoCodec::H264),
215 )
216 }
217
218 fn over(
219 snapshot: &EngineProject,
220 range: TimeRange,
221 frame_rate: FrameRate,
222 quality: QualityProfile,
223 encoding: YuvEncoding,
224 ) -> Result<Self, EngineError> {
225 if range.is_empty() {
226 return Err(EngineError::EmptyExportRange);
227 }
228 let next_frame = frame_rate
229 .frame_at_or_before(range.start())
230 .ok_or(EngineError::OutOfRange)?;
231 let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
232 let count = end_frame
233 .checked_sub(next_frame)
234 .ok_or(EngineError::OutOfRange)?;
235 let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
236 if total == 0 {
237 return Err(EngineError::EmptyExportRange);
238 }
239 if total > MAX_EXPORT_FRAMES {
240 return Err(EngineError::ExportTooLarge {
241 frames: total,
242 maximum: MAX_EXPORT_FRAMES,
243 });
244 }
245 let effective_range = TimeRange::new(
246 frame_rate
247 .frame_start(next_frame)
248 .ok_or(EngineError::OutOfRange)?,
249 frame_rate
250 .frame_start(end_frame)
251 .ok_or(EngineError::OutOfRange)?,
252 )
253 .map_err(|_| EngineError::OutOfRange)?;
254 Ok(Self {
255 snapshot: snapshot.generation(),
256 quality,
257 encoding,
258 pixels: PixelLayout::YUV420P,
259 background: true,
260 frame_rate,
261 effective_range,
262 next_frame,
263 end_frame,
264 completed: 0,
265 total,
266 })
267 }
268
269 #[must_use]
271 pub const fn encoding(&self) -> YuvEncoding {
272 self.encoding
273 }
274
275 #[must_use]
279 pub const fn with_pixels(mut self, pixels: PixelLayout) -> Self {
280 self.pixels = pixels;
281 self
282 }
283
284 #[must_use]
286 pub const fn pixels(&self) -> PixelLayout {
287 self.pixels
288 }
289
290 #[must_use]
293 pub const fn with_background(mut self, drawn: bool) -> Self {
294 self.background = drawn;
295 self
296 }
297
298 fn evaluate<'a>(
299 &self,
300 project: &'a EngineProject,
301 time: MediaTime,
302 ) -> Result<alpha2_eval::FrameDesc<'a>, EngineError> {
303 let mut frame = project.evaluate(time)?;
304 if self.pixels.alpha || !self.background {
305 frame.background = None;
306 }
307 Ok(frame)
308 }
309
310 #[must_use]
316 pub const fn effective_range(&self) -> TimeRange {
317 self.effective_range
318 }
319
320 pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
326 export_audio_window(self.effective_range, sample_rate)
327 }
328
329 pub fn for_project(
334 project: &EngineProject,
335 quality: QualityProfile,
336 codec: VideoCodec,
337 ) -> Result<Self, EngineError> {
338 let range = project
339 .timeline_range()
340 .ok_or(EngineError::EmptyExportRange)?;
341 Self::new(
342 project,
343 range,
344 project.project().settings.frame_rate,
345 quality,
346 codec,
347 )
348 }
349
350 pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
358
359 #[must_use]
361 pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
362 Self::MISSING_FRAME_POLICY
363 }
364
365 #[must_use]
367 pub const fn progress(&self) -> ExportProgress {
368 ExportProgress {
369 completed: self.completed,
370 total: self.total,
371 }
372 }
373
374 pub fn render_size(
379 &self,
380 project: &EngineProject,
381 ) -> Result<alpha2_render::RenderSize, EngineError> {
382 self.quality.render_size(&project.project().settings)
383 }
384
385 fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
386 if project.generation() == self.snapshot {
387 Ok(())
388 } else {
389 Err(EngineError::ExportProjectChanged)
390 }
391 }
392
393 #[must_use]
395 pub fn pending_time(&self) -> Option<MediaTime> {
396 (self.next_frame < self.end_frame)
397 .then(|| self.frame_rate.frame_start(self.next_frame))
398 .flatten()
399 }
400
401 pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
407 let Some(time) = self.pending_time() else {
408 return Ok(None);
409 };
410 let index = self.completed;
411 self.next_frame = self
412 .next_frame
413 .checked_add(1)
414 .ok_or(EngineError::OutOfRange)?;
415 self.completed += 1;
416 Ok(Some((index, time)))
417 }
418
419 #[must_use]
421 pub const fn frame_rate(&self) -> FrameRate {
422 self.frame_rate
423 }
424
425 pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
431 Ok(frame_times(
432 self.frame_rate,
433 self.next_frame,
434 frames.min(MAX_PREFETCH_FRAMES),
435 Some(self.end_frame),
436 )?
437 .into_iter()
438 .map(|(_, time)| time)
439 .collect())
440 }
441
442 pub fn prefetch_needs(
450 &self,
451 project: &EngineProject,
452 frames: u32,
453 ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
454 self.check(project)?;
455 let count = frames.min(MAX_PREFETCH_FRAMES);
456 let times = frame_times(
457 self.frame_rate,
458 self.next_frame,
459 count,
460 Some(self.end_frame),
461 )?;
462 times
463 .into_iter()
464 .enumerate()
465 .map(|(offset, (_, time))| {
466 let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
467 let index = self
468 .completed
469 .checked_add(offset)
470 .ok_or(EngineError::OutOfRange)?;
471 Ok(ExportFrameNeeds {
472 index,
473 time,
474 needs: project.frame_needs(time)?,
475 })
476 })
477 .collect()
478 }
479
480 pub fn render_next(
489 &mut self,
490 project: &EngineProject,
491 renderer: &Renderer,
492 frames: &dyn FrameProvider,
493 ) -> Result<ExportStep, EngineError> {
494 self.render_next_measured(project, renderer, frames, None)
495 }
496
497 pub fn render_next_timed(
506 &mut self,
507 project: &EngineProject,
508 renderer: &Renderer,
509 frames: &dyn FrameProvider,
510 timings: &mut ExportStepTimings,
511 ) -> Result<ExportStep, EngineError> {
512 self.render_next_measured(project, renderer, frames, Some(timings))
513 }
514
515 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
524 pub fn render_next_nv12_timed(
525 &mut self,
526 project: &EngineProject,
527 renderer: &Renderer,
528 frames: &dyn FrameProvider,
529 planes: alpha2_render::Nv12PlaneTargets<'_>,
530 timings: &mut ExportStepTimings,
531 ) -> Result<ExportPlaneStep, EngineError> {
532 self.check(project)?;
533 let Some(time) = self.pending_time() else {
534 return Ok(ExportPlaneStep::Complete);
535 };
536 let evaluate_started = Instant::now();
537 let frame = self.evaluate(project, time)?;
538 crate::frame::check_frame_effects(renderer, &frame)?;
539 let prepare_started = Instant::now();
540 let evaluate = prepare_started.duration_since(evaluate_started);
541 let size = self.quality.render_size(&project.project().settings)?;
542 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
543 renderer.set_quality_controls(
544 self.quality.max_filter_samples,
545 self.quality.motion_blur_subframes,
546 );
547 let prepare = prepare_started.elapsed();
548 let mut submit = FrameSubmitTimings::default();
549 let attempt = renderer.render_frame_nv12_timed(
550 size,
551 &frame,
552 frames,
553 planes,
554 self.encoding,
555 &mut submit,
556 )?;
557 *timings = ExportStepTimings {
558 evaluate,
559 prepare,
560 submit,
561 };
562 if !attempt.submitted {
563 return Ok(ExportPlaneStep::Waiting {
564 time,
565 missing_sources: attempt.report.missing_sources,
566 });
567 }
568 let index = self.completed;
569 self.next_frame = self
570 .next_frame
571 .checked_add(1)
572 .ok_or(EngineError::OutOfRange)?;
573 self.completed += 1;
574 Ok(ExportPlaneStep::Rendered { index, time })
575 }
576
577 pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
586 &mut self,
587 project: &EngineProject,
588 renderer: &R,
589 frames: &dyn FrameProvider,
590 targets: &mut Yuv420pTargets<'_>,
591 timings: Option<&mut ExportStepTimings>,
592 ) -> Result<ExportPlaneStep, EngineError> {
593 self.check(project)?;
594 let Some(time) = self.pending_time() else {
595 return Ok(ExportPlaneStep::Complete);
596 };
597 let evaluate_started = Instant::now();
598 let frame = self.evaluate(project, time)?;
599 crate::frame::check_frame_effects(renderer, &frame)?;
600 let prepare_started = Instant::now();
601 let evaluate = prepare_started.duration_since(evaluate_started);
602 let size = self.quality.render_size(&project.project().settings)?;
603 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
604 renderer.set_quality_controls(
605 self.quality.max_filter_samples,
606 self.quality.motion_blur_subframes,
607 );
608 let draw_started = Instant::now();
609 let prepare = draw_started.duration_since(prepare_started);
610 let report =
611 renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
612 if let Some(timings) = timings {
613 *timings = ExportStepTimings {
614 evaluate,
615 prepare,
616 submit: FrameSubmitTimings {
617 command_encode: draw_started.elapsed(),
618 ..FrameSubmitTimings::default()
619 },
620 };
621 }
622 if !report.is_complete() {
623 return Ok(ExportPlaneStep::Waiting {
624 time,
625 missing_sources: report.missing_sources,
626 });
627 }
628 let index = self.completed;
629 self.next_frame = self
630 .next_frame
631 .checked_add(1)
632 .ok_or(EngineError::OutOfRange)?;
633 self.completed += 1;
634 Ok(ExportPlaneStep::Rendered { index, time })
635 }
636
637 pub fn render_next_into_planes<R: FrameRenderer + ?Sized>(
643 &mut self,
644 project: &EngineProject,
645 renderer: &R,
646 frames: &dyn FrameProvider,
647 targets: &mut [alpha2_render::PlaneTarget<'_>],
648 timings: Option<&mut ExportStepTimings>,
649 ) -> Result<ExportPlaneStep, EngineError> {
650 if self.pixels.is_yuv420p() {
651 let [y, u, v] = targets else {
652 return Err(EngineError::Render(
653 alpha2_render::RenderError::TextureReadback(
654 alpha2_render::ReadbackFailure::Layout,
655 ),
656 ));
657 };
658 let mut planes = Yuv420pTargets {
659 y: y.reborrow(),
660 u: u.reborrow(),
661 v: v.reborrow(),
662 };
663 return self.render_next_into_yuv420p(project, renderer, frames, &mut planes, timings);
664 }
665 self.check(project)?;
666 let Some(time) = self.pending_time() else {
667 return Ok(ExportPlaneStep::Complete);
668 };
669 let evaluate_started = Instant::now();
670 let frame = self.evaluate(project, time)?;
671 crate::frame::check_frame_effects(renderer, &frame)?;
672 let prepare_started = Instant::now();
673 let evaluate = prepare_started.duration_since(evaluate_started);
674 let size = self.quality.render_size(&project.project().settings)?;
675 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
676 renderer.set_quality_controls(
677 self.quality.max_filter_samples,
678 self.quality.motion_blur_subframes,
679 );
680 let draw_started = Instant::now();
681 let prepare = draw_started.duration_since(prepare_started);
682 let report = renderer.render_frame_into_planes(
683 size,
684 &frame,
685 frames,
686 self.encoding,
687 self.pixels,
688 targets,
689 )?;
690 if let Some(timings) = timings {
691 *timings = ExportStepTimings {
692 evaluate,
693 prepare,
694 submit: FrameSubmitTimings {
695 command_encode: draw_started.elapsed(),
696 ..FrameSubmitTimings::default()
697 },
698 };
699 }
700 if !report.is_complete() {
701 return Ok(ExportPlaneStep::Waiting {
702 time,
703 missing_sources: report.missing_sources,
704 });
705 }
706 let index = self.completed;
707 self.next_frame = self
708 .next_frame
709 .checked_add(1)
710 .ok_or(EngineError::OutOfRange)?;
711 self.completed += 1;
712 Ok(ExportPlaneStep::Rendered { index, time })
713 }
714
715 pub fn render_next_texture<R: crate::FrameRenderer + ?Sized>(
723 &mut self,
724 project: &EngineProject,
725 renderer: &R,
726 frames: &dyn FrameProvider,
727 ) -> Result<ExportTextureStep<R::Image>, EngineError> {
728 self.check(project)?;
729 let Some(time) = self.pending_time() else {
730 return Ok(ExportTextureStep::Complete);
731 };
732 let frame = self.evaluate(project, time)?;
733 crate::frame::check_frame_effects(renderer, &frame)?;
734 let size = self.quality.render_size(&project.project().settings)?;
735 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
736 renderer.set_quality_controls(
737 self.quality.max_filter_samples,
738 self.quality.motion_blur_subframes,
739 );
740 let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
741 let Some(texture) = render.texture else {
742 return Ok(ExportTextureStep::Waiting {
743 time,
744 missing_sources: render.report.missing_sources,
745 });
746 };
747 let index = self.completed;
748 self.next_frame = self
749 .next_frame
750 .checked_add(1)
751 .ok_or(EngineError::OutOfRange)?;
752 self.completed += 1;
753 Ok(ExportTextureStep::Rendered {
754 index,
755 time,
756 texture,
757 })
758 }
759
760 fn render_next_measured(
761 &mut self,
762 project: &EngineProject,
763 renderer: &Renderer,
764 frames: &dyn FrameProvider,
765 timings: Option<&mut ExportStepTimings>,
766 ) -> Result<ExportStep, EngineError> {
767 self.check(project)?;
768 let Some(time) = self.pending_time() else {
769 return Ok(ExportStep::Complete);
770 };
771 let evaluate_started = timings.as_ref().map(|_| Instant::now());
772 let frame = self.evaluate(project, time)?;
773 crate::frame::check_frame_effects(renderer, &frame)?;
774 let prepare_started = evaluate_started.map(|_| Instant::now());
775 let evaluate = evaluate_started
776 .zip(prepare_started)
777 .map_or(Duration::ZERO, |(started, ended)| {
778 ended.duration_since(started)
779 });
780 let size = self.quality.render_size(&project.project().settings)?;
781 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
782 renderer.set_quality_controls(
783 self.quality.max_filter_samples,
784 self.quality.motion_blur_subframes,
785 );
786 let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
787 if !self.pixels.is_yuv420p() {
788 let submit_started = Instant::now();
790 let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
791 let Some(texture) = render.texture else {
792 return Ok(ExportStep::Waiting {
793 time,
794 missing_sources: render.report.missing_sources,
795 });
796 };
797 let readback = renderer.begin_layout_readback(&texture, self.encoding, self.pixels)?;
798 if let Some(timings) = timings {
799 *timings = ExportStepTimings {
800 evaluate,
801 prepare,
802 submit: FrameSubmitTimings {
803 command_encode: submit_started.elapsed(),
804 ..FrameSubmitTimings::default()
805 },
806 };
807 }
808 return self.rendered(time, readback);
809 }
810 let attempt = match timings {
811 Some(timings) => {
812 let mut submit = FrameSubmitTimings::default();
813 let attempt = renderer.render_frame_yuv420p_timed(
814 size,
815 &frame,
816 frames,
817 self.encoding,
818 &mut submit,
819 )?;
820 *timings = ExportStepTimings {
821 evaluate,
822 prepare,
823 submit,
824 };
825 attempt
826 }
827 None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
828 };
829 let Some(readback) = attempt.readback else {
830 return Ok(ExportStep::Waiting {
831 time,
832 missing_sources: attempt.report.missing_sources,
833 });
834 };
835 self.rendered(time, readback)
836 }
837
838 fn rendered(
840 &mut self,
841 time: MediaTime,
842 readback: alpha2_render::Yuv420pReadback,
843 ) -> Result<ExportStep, EngineError> {
844 let index = self.completed;
845 self.next_frame = self
846 .next_frame
847 .checked_add(1)
848 .ok_or(EngineError::OutOfRange)?;
849 self.completed += 1;
850 Ok(ExportStep::Rendered {
851 index,
852 time,
853 readback,
854 })
855 }
856}
857
858#[derive(Copy, Clone, Debug, Eq, PartialEq)]
860pub struct ExportAudioWindow {
861 pub start_sample: i64,
863 pub frames: usize,
865}
866
867pub fn export_audio_window(
877 range: TimeRange,
878 sample_rate: u32,
879) -> Result<ExportAudioWindow, EngineError> {
880 let nearest = |ticks: i64| {
881 let doubled =
882 i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
883 i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
884 .map_err(|_| EngineError::OutOfRange)
885 };
886 let start_sample = nearest(range.start().ticks())?;
887 let end = nearest(range.end().ticks())?;
888 let frames = end
889 .checked_sub(start_sample)
890 .and_then(|frames| usize::try_from(frames).ok())
891 .ok_or(EngineError::OutOfRange)?;
892 Ok(ExportAudioWindow {
893 start_sample,
894 frames,
895 })
896}
897
898fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
899 let floor = rate
900 .frame_at_or_before(time)
901 .ok_or(EngineError::OutOfRange)?;
902 let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
903 if floor_time < time {
904 floor.checked_add(1).ok_or(EngineError::OutOfRange)
905 } else {
906 Ok(floor)
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913 use crate::Player;
914 use alpha2_model::{Project, ProjectId, ProjectSettings};
915
916 fn snapshot(width: u32, height: u32) -> EngineProject {
917 EngineProject::new(Project::new(
918 ProjectId::new("export".to_owned()).expect("id"),
919 "export",
920 ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
921 .expect("settings"),
922 ))
923 .expect("valid project")
924 }
925
926 #[test]
927 fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
928 let range =
929 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
930 let rate = FrameRate::new(30, 1).expect("valid rate");
931 for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
932 let error = Exporter::new(
933 &snapshot(size.0, size.1),
934 range,
935 rate,
936 QualityProfile::export(),
937 VideoCodec::H264,
938 )
939 .expect_err("odd output");
940 assert_eq!(
941 error,
942 EngineError::OddExportDimensions {
943 width: size.0,
944 height: size.1,
945 suggested,
946 }
947 );
948 assert!(
949 error
950 .to_string()
951 .contains(&format!("{}x{}", suggested.0, suggested.1))
952 );
953 }
954 assert!(
955 Exporter::new(
956 &snapshot(64, 48),
957 range,
958 rate,
959 QualityProfile::export(),
960 VideoCodec::H264
961 )
962 .is_ok()
963 );
964 }
965
966 #[test]
967 fn runners_expose_distinct_missing_frame_policies() {
968 let rate = FrameRate::new(30, 1).expect("valid rate");
969 let range =
970 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
971 let exporter = Exporter::new(
972 &snapshot(16, 16),
973 range,
974 rate,
975 QualityProfile::export(),
976 VideoCodec::H264,
977 )
978 .expect("valid exporter");
979 assert_eq!(
980 Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
981 MissingFramePolicy::HoldPrevious
982 );
983 assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
984 }
985
986 #[test]
987 fn schedule_uses_exact_rational_frame_boundaries() {
988 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
989 let start = rate.frame_start(10).expect("representable");
990 let end = rate.frame_start(20).expect("representable");
991 let exporter = Exporter::new(
992 &snapshot(16, 16),
993 TimeRange::new(start, end).expect("valid range"),
994 rate,
995 QualityProfile::export(),
996 VideoCodec::H264,
997 )
998 .expect("valid exporter");
999 assert_eq!(exporter.pending_time(), Some(start));
1000 assert_eq!(
1001 exporter.progress(),
1002 ExportProgress {
1003 completed: 0,
1004 total: 10,
1005 }
1006 );
1007 }
1008
1009 #[test]
1010 fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
1011 let rate = FrameRate::new(30, 1).expect("valid rate");
1012 let subframe = Exporter::new(
1013 &snapshot(16, 16),
1014 TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
1015 rate,
1016 QualityProfile::export(),
1017 VideoCodec::H264,
1018 )
1019 .expect("one intersecting frame");
1020 assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
1021 assert_eq!(subframe.progress().total, 1);
1022
1023 let unaligned = Exporter::new(
1024 &snapshot(16, 16),
1025 TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
1026 rate,
1027 QualityProfile::export(),
1028 VideoCodec::H264,
1029 )
1030 .expect("two intersecting frames");
1031 assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
1032 assert_eq!(unaligned.progress().total, 2);
1033 assert_eq!(
1034 unaligned.effective_range(),
1035 TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
1036 );
1037 }
1038
1039 #[test]
1040 fn effective_range_is_the_request_widened_to_the_frame_grid() {
1041 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
1042 let frame = |index| rate.frame_start(index).expect("representable").ticks();
1043 for (start, end) in [
1044 (frame(3), frame(9)),
1045 (frame(3) + 1, frame(9) - 1),
1046 (frame(4) - 1, frame(8) + 1),
1047 ] {
1048 let exporter = Exporter::new(
1049 &snapshot(16, 16),
1050 TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
1051 rate,
1052 QualityProfile::export(),
1053 VideoCodec::H264,
1054 )
1055 .expect("valid exporter");
1056 let effective = exporter.effective_range();
1057 assert_eq!(
1058 (effective.start(), effective.end()),
1059 (MediaTime(frame(3)), MediaTime(frame(9))),
1060 "{start}..{end}"
1061 );
1062 assert_eq!(exporter.pending_time(), Some(effective.start()));
1063 }
1064 }
1065
1066 #[test]
1067 fn schedule_rejects_unbounded_frame_counts() {
1068 let rate = FrameRate::new(30, 1).expect("valid rate");
1069 let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
1070 assert!(matches!(
1071 Exporter::new(
1072 &snapshot(16, 16),
1073 range,
1074 rate,
1075 QualityProfile::export(),
1076 VideoCodec::H264
1077 ),
1078 Err(EngineError::ExportTooLarge { .. })
1079 ));
1080 }
1081
1082 #[test]
1083 fn prefetch_reports_future_indices_without_advancing_the_cursor() {
1084 let rate = FrameRate::new(30, 1).expect("valid rate");
1085 let project = Project::new(
1086 ProjectId::new("prefetch").expect("project ID"),
1087 "prefetch",
1088 ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
1089 );
1090 let snapshot = EngineProject::new(project).expect("snapshot");
1091 let range =
1092 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
1093 let exporter = Exporter::new(
1094 &snapshot,
1095 range,
1096 rate,
1097 QualityProfile::export(),
1098 VideoCodec::H264,
1099 )
1100 .expect("valid exporter");
1101
1102 let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
1103 assert_eq!(
1104 prefetched
1105 .iter()
1106 .map(|frame| (frame.index, frame.time))
1107 .collect::<Vec<_>>(),
1108 vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
1109 );
1110 assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
1111 assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
1112 assert_eq!(exporter.progress().completed, 0);
1113 }
1114
1115 #[test]
1116 fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
1117 let rate = FrameRate::new(30, 1).expect("valid rate");
1118 let snapshot = snapshot(16, 16);
1119 let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
1121 .expect("non-empty range");
1122 let mut exporter = Exporter::new(
1123 &snapshot,
1124 range,
1125 rate,
1126 QualityProfile::export(),
1127 VideoCodec::H264,
1128 )
1129 .expect("valid exporter");
1130 let frame = MediaTime::from_ticks;
1131 assert_eq!(
1132 exporter.upcoming_times(2).expect("times"),
1133 vec![frame(0), frame(4_000)]
1134 );
1135 let (_, taken) = exporter.take_next().expect("take").expect("a frame");
1136 assert_eq!(taken, frame(0));
1137 assert_eq!(
1139 exporter.upcoming_times(5).expect("times"),
1140 vec![frame(4_000), frame(8_000)]
1141 );
1142 assert_eq!(exporter.pending_time(), Some(frame(4_000)));
1143 exporter.take_next().expect("take");
1144 exporter.take_next().expect("take");
1145 assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
1146 }
1147
1148 #[test]
1149 fn a_whole_project_export_covers_its_caption_cues() {
1150 let project = |clips: &str| {
1151 let json = format!(
1152 r#"{{"schema_version": 1, "id": "p", "name": "p",
1153 "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
1154 "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
1155 "timeline": {{"tracks": [
1156 {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
1157 {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
1158 {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
1159 );
1160 EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
1161 };
1162 let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
1163 for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
1164 let range = snapshot.timeline_range().expect("something is shown");
1165 assert_eq!(
1166 (range.start().ticks(), range.end().ticks()),
1167 (start, 1_440_000)
1168 );
1169 let exporter =
1170 Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
1171 .expect("an export of the whole project");
1172 assert_eq!(exporter.effective_range(), range);
1173 }
1174 }
1175}