1use alpha2_eval::FrameNeed;
2use alpha2_media::{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)]
53pub enum ExportStep {
54 Waiting {
56 time: MediaTime,
58 missing_sources: Vec<SourceId>,
60 },
61 Rendered {
63 index: u64,
65 time: MediaTime,
67 readback: Yuv420pReadback,
69 },
70 Complete,
72}
73
74#[derive(Debug)]
76pub enum ExportTextureStep<I = alpha2_render::RenderedTexture> {
77 Waiting {
79 time: MediaTime,
81 missing_sources: Vec<SourceId>,
83 },
84 Rendered {
86 index: u64,
88 time: MediaTime,
90 texture: I,
94 },
95 Complete,
97}
98
99#[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)]
127pub struct ExportStepTimings {
128 pub evaluate: Duration,
130 pub prepare: Duration,
133 pub submit: FrameSubmitTimings,
135}
136
137impl ExportStepTimings {
138 #[must_use]
140 pub fn merged(self, other: Self) -> Self {
141 Self {
142 evaluate: self.evaluate.saturating_add(other.evaluate),
143 prepare: self.prepare.saturating_add(other.prepare),
144 submit: self.submit.merged(other.submit),
145 }
146 }
147}
148
149#[derive(Clone, Debug)]
151pub struct Exporter {
152 snapshot: u64,
154 quality: QualityProfile,
155 encoding: YuvEncoding,
156 frame_rate: FrameRate,
157 effective_range: TimeRange,
158 next_frame: i64,
159 end_frame: i64,
160 completed: u64,
161 total: u64,
162}
163
164impl Exporter {
165 pub fn new(
179 snapshot: &EngineProject,
180 range: TimeRange,
181 frame_rate: FrameRate,
182 quality: QualityProfile,
183 codec: VideoCodec,
184 ) -> Result<Self, EngineError> {
185 quality.export_size(&snapshot.project().settings)?;
186 Self::over(
187 snapshot,
188 range,
189 frame_rate,
190 quality,
191 YuvEncoding::export(codec),
192 )
193 }
194
195 pub fn still(
205 snapshot: &EngineProject,
206 time: MediaTime,
207 frame_rate: FrameRate,
208 quality: QualityProfile,
209 ) -> Result<Self, EngineError> {
210 quality.render_size(&snapshot.project().settings)?;
211 let range = TimeRange::new(
212 time,
213 MediaTime::from_ticks(time.ticks().checked_add(1).ok_or(EngineError::OutOfRange)?),
214 )
215 .map_err(|_| EngineError::OutOfRange)?;
216 Self::over(
217 snapshot,
218 range,
219 frame_rate,
220 quality,
221 YuvEncoding::export(VideoCodec::H264),
222 )
223 }
224
225 fn over(
226 snapshot: &EngineProject,
227 range: TimeRange,
228 frame_rate: FrameRate,
229 quality: QualityProfile,
230 encoding: YuvEncoding,
231 ) -> Result<Self, EngineError> {
232 if range.is_empty() {
233 return Err(EngineError::EmptyExportRange);
234 }
235 let next_frame = frame_rate
236 .frame_at_or_before(range.start())
237 .ok_or(EngineError::OutOfRange)?;
238 let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
239 let count = end_frame
240 .checked_sub(next_frame)
241 .ok_or(EngineError::OutOfRange)?;
242 let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
243 if total == 0 {
244 return Err(EngineError::EmptyExportRange);
245 }
246 if total > MAX_EXPORT_FRAMES {
247 return Err(EngineError::ExportTooLarge {
248 frames: total,
249 maximum: MAX_EXPORT_FRAMES,
250 });
251 }
252 let effective_range = TimeRange::new(
253 frame_rate
254 .frame_start(next_frame)
255 .ok_or(EngineError::OutOfRange)?,
256 frame_rate
257 .frame_start(end_frame)
258 .ok_or(EngineError::OutOfRange)?,
259 )
260 .map_err(|_| EngineError::OutOfRange)?;
261 Ok(Self {
262 snapshot: snapshot.generation(),
263 quality,
264 encoding,
265 frame_rate,
266 effective_range,
267 next_frame,
268 end_frame,
269 completed: 0,
270 total,
271 })
272 }
273
274 #[must_use]
276 pub const fn encoding(&self) -> YuvEncoding {
277 self.encoding
278 }
279
280 #[must_use]
288 pub const fn effective_range(&self) -> TimeRange {
289 self.effective_range
290 }
291
292 pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
299 export_audio_window(self.effective_range, sample_rate)
300 }
301
302 pub fn for_project(
309 project: &EngineProject,
310 quality: QualityProfile,
311 codec: VideoCodec,
312 ) -> Result<Self, EngineError> {
313 let range = project
314 .timeline_range()
315 .ok_or(EngineError::EmptyExportRange)?;
316 Self::new(
317 project,
318 range,
319 project.project().settings.frame_rate,
320 quality,
321 codec,
322 )
323 }
324
325 pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
337
338 #[must_use]
340 pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
341 Self::MISSING_FRAME_POLICY
342 }
343
344 #[must_use]
346 pub const fn progress(&self) -> ExportProgress {
347 ExportProgress {
348 completed: self.completed,
349 total: self.total,
350 }
351 }
352
353 pub fn render_size(
359 &self,
360 project: &EngineProject,
361 ) -> Result<alpha2_render::RenderSize, EngineError> {
362 self.quality.render_size(&project.project().settings)
363 }
364
365 fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
367 if project.generation() == self.snapshot {
368 Ok(())
369 } else {
370 Err(EngineError::ExportProjectChanged)
371 }
372 }
373
374 #[must_use]
376 pub fn pending_time(&self) -> Option<MediaTime> {
377 (self.next_frame < self.end_frame)
378 .then(|| self.frame_rate.frame_start(self.next_frame))
379 .flatten()
380 }
381
382 pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
390 let Some(time) = self.pending_time() else {
391 return Ok(None);
392 };
393 let index = self.completed;
394 self.next_frame = self
395 .next_frame
396 .checked_add(1)
397 .ok_or(EngineError::OutOfRange)?;
398 self.completed += 1;
399 Ok(Some((index, time)))
400 }
401
402 #[must_use]
404 pub const fn frame_rate(&self) -> FrameRate {
405 self.frame_rate
406 }
407
408 pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
417 Ok(frame_times(
418 self.frame_rate,
419 self.next_frame,
420 frames.min(MAX_PREFETCH_FRAMES),
421 Some(self.end_frame),
422 )?
423 .into_iter()
424 .map(|(_, time)| time)
425 .collect())
426 }
427
428 pub fn prefetch_needs(
438 &self,
439 project: &EngineProject,
440 frames: u32,
441 ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
442 self.check(project)?;
443 let count = frames.min(MAX_PREFETCH_FRAMES);
444 let times = frame_times(
445 self.frame_rate,
446 self.next_frame,
447 count,
448 Some(self.end_frame),
449 )?;
450 times
451 .into_iter()
452 .enumerate()
453 .map(|(offset, (_, time))| {
454 let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
455 let index = self
456 .completed
457 .checked_add(offset)
458 .ok_or(EngineError::OutOfRange)?;
459 Ok(ExportFrameNeeds {
460 index,
461 time,
462 needs: project.frame_needs(time)?,
463 })
464 })
465 .collect()
466 }
467
468 pub fn render_next(
477 &mut self,
478 project: &EngineProject,
479 renderer: &Renderer,
480 frames: &dyn FrameProvider,
481 ) -> Result<ExportStep, EngineError> {
482 self.render_next_measured(project, renderer, frames, None)
483 }
484
485 pub fn render_next_timed(
494 &mut self,
495 project: &EngineProject,
496 renderer: &Renderer,
497 frames: &dyn FrameProvider,
498 timings: &mut ExportStepTimings,
499 ) -> Result<ExportStep, EngineError> {
500 self.render_next_measured(project, renderer, frames, Some(timings))
501 }
502
503 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
514 pub fn render_next_nv12_timed(
515 &mut self,
516 project: &EngineProject,
517 renderer: &Renderer,
518 frames: &dyn FrameProvider,
519 planes: alpha2_render::Nv12PlaneTargets<'_>,
520 timings: &mut ExportStepTimings,
521 ) -> Result<ExportPlaneStep, EngineError> {
522 self.check(project)?;
523 let Some(time) = self.pending_time() else {
524 return Ok(ExportPlaneStep::Complete);
525 };
526 let evaluate_started = Instant::now();
527 let frame = project.evaluate(time)?;
528 crate::frame::check_frame_effects(renderer, &frame)?;
529 let prepare_started = Instant::now();
530 let evaluate = prepare_started.duration_since(evaluate_started);
531 let size = self.quality.render_size(&project.project().settings)?;
532 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
533 renderer.set_quality_controls(
534 self.quality.max_filter_samples,
535 self.quality.motion_blur_subframes,
536 );
537 let prepare = prepare_started.elapsed();
538 let mut submit = FrameSubmitTimings::default();
539 let attempt = renderer.render_frame_nv12_timed(
540 size,
541 &frame,
542 frames,
543 planes,
544 self.encoding,
545 &mut submit,
546 )?;
547 *timings = ExportStepTimings {
548 evaluate,
549 prepare,
550 submit,
551 };
552 if !attempt.submitted {
553 return Ok(ExportPlaneStep::Waiting {
554 time,
555 missing_sources: attempt.report.missing_sources,
556 });
557 }
558 let index = self.completed;
559 self.next_frame = self
560 .next_frame
561 .checked_add(1)
562 .ok_or(EngineError::OutOfRange)?;
563 self.completed += 1;
564 Ok(ExportPlaneStep::Rendered { index, time })
565 }
566
567 pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
579 &mut self,
580 project: &EngineProject,
581 renderer: &R,
582 frames: &dyn FrameProvider,
583 targets: &mut Yuv420pTargets<'_>,
584 timings: Option<&mut ExportStepTimings>,
585 ) -> Result<ExportPlaneStep, EngineError> {
586 self.check(project)?;
587 let Some(time) = self.pending_time() else {
588 return Ok(ExportPlaneStep::Complete);
589 };
590 let evaluate_started = Instant::now();
591 let frame = project.evaluate(time)?;
592 crate::frame::check_frame_effects(renderer, &frame)?;
593 let prepare_started = Instant::now();
594 let evaluate = prepare_started.duration_since(evaluate_started);
595 let size = self.quality.render_size(&project.project().settings)?;
596 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
597 renderer.set_quality_controls(
598 self.quality.max_filter_samples,
599 self.quality.motion_blur_subframes,
600 );
601 let draw_started = Instant::now();
602 let prepare = draw_started.duration_since(prepare_started);
603 let report =
604 renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
605 if let Some(timings) = timings {
606 *timings = ExportStepTimings {
607 evaluate,
608 prepare,
609 submit: FrameSubmitTimings {
610 command_encode: draw_started.elapsed(),
611 ..FrameSubmitTimings::default()
612 },
613 };
614 }
615 if !report.is_complete() {
616 return Ok(ExportPlaneStep::Waiting {
617 time,
618 missing_sources: report.missing_sources,
619 });
620 }
621 let index = self.completed;
622 self.next_frame = self
623 .next_frame
624 .checked_add(1)
625 .ok_or(EngineError::OutOfRange)?;
626 self.completed += 1;
627 Ok(ExportPlaneStep::Rendered { index, time })
628 }
629
630 pub fn render_next_texture<R: crate::FrameRenderer + ?Sized>(
640 &mut self,
641 project: &EngineProject,
642 renderer: &R,
643 frames: &dyn FrameProvider,
644 ) -> Result<ExportTextureStep<R::Image>, EngineError> {
645 self.check(project)?;
646 let Some(time) = self.pending_time() else {
647 return Ok(ExportTextureStep::Complete);
648 };
649 let frame = project.evaluate(time)?;
650 crate::frame::check_frame_effects(renderer, &frame)?;
651 let size = self.quality.render_size(&project.project().settings)?;
652 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
653 renderer.set_quality_controls(
654 self.quality.max_filter_samples,
655 self.quality.motion_blur_subframes,
656 );
657 let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
658 let Some(texture) = render.texture else {
659 return Ok(ExportTextureStep::Waiting {
660 time,
661 missing_sources: render.report.missing_sources,
662 });
663 };
664 let index = self.completed;
665 self.next_frame = self
666 .next_frame
667 .checked_add(1)
668 .ok_or(EngineError::OutOfRange)?;
669 self.completed += 1;
670 Ok(ExportTextureStep::Rendered {
671 index,
672 time,
673 texture,
674 })
675 }
676
677 fn render_next_measured(
678 &mut self,
679 project: &EngineProject,
680 renderer: &Renderer,
681 frames: &dyn FrameProvider,
682 timings: Option<&mut ExportStepTimings>,
683 ) -> Result<ExportStep, EngineError> {
684 self.check(project)?;
685 let Some(time) = self.pending_time() else {
686 return Ok(ExportStep::Complete);
687 };
688 let evaluate_started = timings.as_ref().map(|_| Instant::now());
689 let frame = project.evaluate(time)?;
690 crate::frame::check_frame_effects(renderer, &frame)?;
691 let prepare_started = evaluate_started.map(|_| Instant::now());
692 let evaluate = evaluate_started
693 .zip(prepare_started)
694 .map_or(Duration::ZERO, |(started, ended)| {
695 ended.duration_since(started)
696 });
697 let size = self.quality.render_size(&project.project().settings)?;
698 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
699 renderer.set_quality_controls(
700 self.quality.max_filter_samples,
701 self.quality.motion_blur_subframes,
702 );
703 let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
704 let attempt = match timings {
705 Some(timings) => {
706 let mut submit = FrameSubmitTimings::default();
707 let attempt = renderer.render_frame_yuv420p_timed(
708 size,
709 &frame,
710 frames,
711 self.encoding,
712 &mut submit,
713 )?;
714 *timings = ExportStepTimings {
715 evaluate,
716 prepare,
717 submit,
718 };
719 attempt
720 }
721 None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
722 };
723 let Some(readback) = attempt.readback else {
724 return Ok(ExportStep::Waiting {
725 time,
726 missing_sources: attempt.report.missing_sources,
727 });
728 };
729 let index = self.completed;
730 self.next_frame = self
731 .next_frame
732 .checked_add(1)
733 .ok_or(EngineError::OutOfRange)?;
734 self.completed += 1;
735 Ok(ExportStep::Rendered {
736 index,
737 time,
738 readback,
739 })
740 }
741}
742
743#[derive(Copy, Clone, Debug, Eq, PartialEq)]
745pub struct ExportAudioWindow {
746 pub start_sample: i64,
748 pub frames: usize,
750}
751
752pub fn export_audio_window(
764 range: TimeRange,
765 sample_rate: u32,
766) -> Result<ExportAudioWindow, EngineError> {
767 let nearest = |ticks: i64| {
768 let doubled =
769 i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
770 i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
771 .map_err(|_| EngineError::OutOfRange)
772 };
773 let start_sample = nearest(range.start().ticks())?;
774 let end = nearest(range.end().ticks())?;
775 let frames = end
776 .checked_sub(start_sample)
777 .and_then(|frames| usize::try_from(frames).ok())
778 .ok_or(EngineError::OutOfRange)?;
779 Ok(ExportAudioWindow {
780 start_sample,
781 frames,
782 })
783}
784
785fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
786 let floor = rate
787 .frame_at_or_before(time)
788 .ok_or(EngineError::OutOfRange)?;
789 let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
790 if floor_time < time {
791 floor.checked_add(1).ok_or(EngineError::OutOfRange)
792 } else {
793 Ok(floor)
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use crate::Player;
801 use alpha2_model::{Project, ProjectId, ProjectSettings};
802
803 fn snapshot(width: u32, height: u32) -> EngineProject {
804 EngineProject::new(Project::new(
805 ProjectId::new("export".to_owned()).expect("id"),
806 "export",
807 ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
808 .expect("settings"),
809 ))
810 .expect("valid project")
811 }
812
813 #[test]
814 fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
815 let range =
816 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
817 let rate = FrameRate::new(30, 1).expect("valid rate");
818 for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
819 let error = Exporter::new(
820 &snapshot(size.0, size.1),
821 range,
822 rate,
823 QualityProfile::export(),
824 VideoCodec::H264,
825 )
826 .expect_err("odd output");
827 assert_eq!(
828 error,
829 EngineError::OddExportDimensions {
830 width: size.0,
831 height: size.1,
832 suggested,
833 }
834 );
835 assert!(
836 error
837 .to_string()
838 .contains(&format!("{}x{}", suggested.0, suggested.1))
839 );
840 }
841 assert!(
842 Exporter::new(
843 &snapshot(64, 48),
844 range,
845 rate,
846 QualityProfile::export(),
847 VideoCodec::H264
848 )
849 .is_ok()
850 );
851 }
852
853 #[test]
854 fn runners_expose_distinct_missing_frame_policies() {
855 let rate = FrameRate::new(30, 1).expect("valid rate");
856 let range =
857 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
858 let exporter = Exporter::new(
859 &snapshot(16, 16),
860 range,
861 rate,
862 QualityProfile::export(),
863 VideoCodec::H264,
864 )
865 .expect("valid exporter");
866 assert_eq!(
867 Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
868 MissingFramePolicy::HoldPrevious
869 );
870 assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
871 }
872
873 #[test]
874 fn schedule_uses_exact_rational_frame_boundaries() {
875 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
876 let start = rate.frame_start(10).expect("representable");
877 let end = rate.frame_start(20).expect("representable");
878 let exporter = Exporter::new(
879 &snapshot(16, 16),
880 TimeRange::new(start, end).expect("valid range"),
881 rate,
882 QualityProfile::export(),
883 VideoCodec::H264,
884 )
885 .expect("valid exporter");
886 assert_eq!(exporter.pending_time(), Some(start));
887 assert_eq!(
888 exporter.progress(),
889 ExportProgress {
890 completed: 0,
891 total: 10,
892 }
893 );
894 }
895
896 #[test]
897 fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
898 let rate = FrameRate::new(30, 1).expect("valid rate");
899 let subframe = Exporter::new(
900 &snapshot(16, 16),
901 TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
902 rate,
903 QualityProfile::export(),
904 VideoCodec::H264,
905 )
906 .expect("one intersecting frame");
907 assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
908 assert_eq!(subframe.progress().total, 1);
909
910 let unaligned = Exporter::new(
911 &snapshot(16, 16),
912 TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
913 rate,
914 QualityProfile::export(),
915 VideoCodec::H264,
916 )
917 .expect("two intersecting frames");
918 assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
919 assert_eq!(unaligned.progress().total, 2);
920 assert_eq!(
921 unaligned.effective_range(),
922 TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
923 );
924 }
925
926 #[test]
927 fn effective_range_is_the_request_widened_to_the_frame_grid() {
928 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
929 let frame = |index| rate.frame_start(index).expect("representable").ticks();
930 for (start, end) in [
931 (frame(3), frame(9)),
932 (frame(3) + 1, frame(9) - 1),
933 (frame(4) - 1, frame(8) + 1),
934 ] {
935 let exporter = Exporter::new(
936 &snapshot(16, 16),
937 TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
938 rate,
939 QualityProfile::export(),
940 VideoCodec::H264,
941 )
942 .expect("valid exporter");
943 let effective = exporter.effective_range();
944 assert_eq!(
945 (effective.start(), effective.end()),
946 (MediaTime(frame(3)), MediaTime(frame(9))),
947 "{start}..{end}"
948 );
949 assert_eq!(exporter.pending_time(), Some(effective.start()));
950 }
951 }
952
953 #[test]
954 fn schedule_rejects_unbounded_frame_counts() {
955 let rate = FrameRate::new(30, 1).expect("valid rate");
956 let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
957 assert!(matches!(
958 Exporter::new(
959 &snapshot(16, 16),
960 range,
961 rate,
962 QualityProfile::export(),
963 VideoCodec::H264
964 ),
965 Err(EngineError::ExportTooLarge { .. })
966 ));
967 }
968
969 #[test]
970 fn prefetch_reports_future_indices_without_advancing_the_cursor() {
971 let rate = FrameRate::new(30, 1).expect("valid rate");
972 let project = Project::new(
973 ProjectId::new("prefetch").expect("project ID"),
974 "prefetch",
975 ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
976 );
977 let snapshot = EngineProject::new(project).expect("snapshot");
978 let range =
979 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
980 let exporter = Exporter::new(
981 &snapshot,
982 range,
983 rate,
984 QualityProfile::export(),
985 VideoCodec::H264,
986 )
987 .expect("valid exporter");
988
989 let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
990 assert_eq!(
991 prefetched
992 .iter()
993 .map(|frame| (frame.index, frame.time))
994 .collect::<Vec<_>>(),
995 vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
996 );
997 assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
998 assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
999 assert_eq!(exporter.progress().completed, 0);
1000 }
1001
1002 #[test]
1003 fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
1004 let rate = FrameRate::new(30, 1).expect("valid rate");
1005 let snapshot = snapshot(16, 16);
1006 let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
1008 .expect("non-empty range");
1009 let mut exporter = Exporter::new(
1010 &snapshot,
1011 range,
1012 rate,
1013 QualityProfile::export(),
1014 VideoCodec::H264,
1015 )
1016 .expect("valid exporter");
1017 let frame = MediaTime::from_ticks;
1018 assert_eq!(
1019 exporter.upcoming_times(2).expect("times"),
1020 vec![frame(0), frame(4_000)]
1021 );
1022 let (_, taken) = exporter.take_next().expect("take").expect("a frame");
1023 assert_eq!(taken, frame(0));
1024 assert_eq!(
1026 exporter.upcoming_times(5).expect("times"),
1027 vec![frame(4_000), frame(8_000)]
1028 );
1029 assert_eq!(exporter.pending_time(), Some(frame(4_000)));
1030 exporter.take_next().expect("take");
1031 exporter.take_next().expect("take");
1032 assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
1033 }
1034
1035 #[test]
1036 fn a_whole_project_export_covers_its_caption_cues() {
1037 let project = |clips: &str| {
1038 let json = format!(
1039 r#"{{"schema_version": 1, "id": "p", "name": "p",
1040 "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
1041 "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
1042 "timeline": {{"tracks": [
1043 {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
1044 {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
1045 {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
1046 );
1047 EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
1048 };
1049 let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
1050 for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
1051 let range = snapshot.timeline_range().expect("something is shown");
1052 assert_eq!(
1053 (range.start().ticks(), range.end().ticks()),
1054 (start, 1_440_000)
1055 );
1056 let exporter =
1057 Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
1058 .expect("an export of the whole project");
1059 assert_eq!(exporter.effective_range(), range);
1060 }
1061 }
1062}