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 {
77 Waiting {
79 time: MediaTime,
81 missing_sources: Vec<SourceId>,
83 },
84 Rendered {
86 index: u64,
88 time: MediaTime,
90 texture: alpha2_render::RenderedTexture,
93 },
94 Complete,
96}
97
98#[derive(Debug)]
101pub enum ExportPlaneStep {
102 Waiting {
104 time: MediaTime,
106 missing_sources: Vec<SourceId>,
108 },
109 Rendered {
111 index: u64,
113 time: MediaTime,
115 },
116 Complete,
118}
119
120#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
126pub struct ExportStepTimings {
127 pub evaluate: Duration,
129 pub prepare: Duration,
132 pub submit: FrameSubmitTimings,
134}
135
136impl ExportStepTimings {
137 #[must_use]
139 pub fn merged(self, other: Self) -> Self {
140 Self {
141 evaluate: self.evaluate.saturating_add(other.evaluate),
142 prepare: self.prepare.saturating_add(other.prepare),
143 submit: self.submit.merged(other.submit),
144 }
145 }
146}
147
148#[derive(Clone, Debug)]
150pub struct Exporter {
151 snapshot: u64,
153 quality: QualityProfile,
154 encoding: YuvEncoding,
155 frame_rate: FrameRate,
156 effective_range: TimeRange,
157 next_frame: i64,
158 end_frame: i64,
159 completed: u64,
160 total: u64,
161}
162
163impl Exporter {
164 pub fn new(
178 snapshot: &EngineProject,
179 range: TimeRange,
180 frame_rate: FrameRate,
181 quality: QualityProfile,
182 codec: VideoCodec,
183 ) -> Result<Self, EngineError> {
184 quality.export_size(&snapshot.project().settings)?;
185 if range.is_empty() {
186 return Err(EngineError::EmptyExportRange);
187 }
188 let next_frame = frame_rate
189 .frame_at_or_before(range.start())
190 .ok_or(EngineError::OutOfRange)?;
191 let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
192 let count = end_frame
193 .checked_sub(next_frame)
194 .ok_or(EngineError::OutOfRange)?;
195 let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
196 if total == 0 {
197 return Err(EngineError::EmptyExportRange);
198 }
199 if total > MAX_EXPORT_FRAMES {
200 return Err(EngineError::ExportTooLarge {
201 frames: total,
202 maximum: MAX_EXPORT_FRAMES,
203 });
204 }
205 let effective_range = TimeRange::new(
206 frame_rate
207 .frame_start(next_frame)
208 .ok_or(EngineError::OutOfRange)?,
209 frame_rate
210 .frame_start(end_frame)
211 .ok_or(EngineError::OutOfRange)?,
212 )
213 .map_err(|_| EngineError::OutOfRange)?;
214 Ok(Self {
215 snapshot: snapshot.generation(),
216 quality,
217 encoding: YuvEncoding::export(codec),
218 frame_rate,
219 effective_range,
220 next_frame,
221 end_frame,
222 completed: 0,
223 total,
224 })
225 }
226
227 #[must_use]
229 pub const fn encoding(&self) -> YuvEncoding {
230 self.encoding
231 }
232
233 #[must_use]
241 pub const fn effective_range(&self) -> TimeRange {
242 self.effective_range
243 }
244
245 pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
252 export_audio_window(self.effective_range, sample_rate)
253 }
254
255 pub fn for_project(
262 project: &EngineProject,
263 quality: QualityProfile,
264 codec: VideoCodec,
265 ) -> Result<Self, EngineError> {
266 let range = project
267 .timeline_range()
268 .ok_or(EngineError::EmptyExportRange)?;
269 Self::new(
270 project,
271 range,
272 project.project().settings.frame_rate,
273 quality,
274 codec,
275 )
276 }
277
278 pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
290
291 #[must_use]
293 pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
294 Self::MISSING_FRAME_POLICY
295 }
296
297 #[must_use]
299 pub const fn progress(&self) -> ExportProgress {
300 ExportProgress {
301 completed: self.completed,
302 total: self.total,
303 }
304 }
305
306 pub fn render_size(
312 &self,
313 project: &EngineProject,
314 ) -> Result<alpha2_render::RenderSize, EngineError> {
315 self.quality.render_size(&project.project().settings)
316 }
317
318 fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
320 if project.generation() == self.snapshot {
321 Ok(())
322 } else {
323 Err(EngineError::ExportProjectChanged)
324 }
325 }
326
327 #[must_use]
329 pub fn pending_time(&self) -> Option<MediaTime> {
330 (self.next_frame < self.end_frame)
331 .then(|| self.frame_rate.frame_start(self.next_frame))
332 .flatten()
333 }
334
335 pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
343 let Some(time) = self.pending_time() else {
344 return Ok(None);
345 };
346 let index = self.completed;
347 self.next_frame = self
348 .next_frame
349 .checked_add(1)
350 .ok_or(EngineError::OutOfRange)?;
351 self.completed += 1;
352 Ok(Some((index, time)))
353 }
354
355 #[must_use]
357 pub const fn frame_rate(&self) -> FrameRate {
358 self.frame_rate
359 }
360
361 pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
370 Ok(frame_times(
371 self.frame_rate,
372 self.next_frame,
373 frames.min(MAX_PREFETCH_FRAMES),
374 Some(self.end_frame),
375 )?
376 .into_iter()
377 .map(|(_, time)| time)
378 .collect())
379 }
380
381 pub fn prefetch_needs(
391 &self,
392 project: &EngineProject,
393 frames: u32,
394 ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
395 self.check(project)?;
396 let count = frames.min(MAX_PREFETCH_FRAMES);
397 let times = frame_times(
398 self.frame_rate,
399 self.next_frame,
400 count,
401 Some(self.end_frame),
402 )?;
403 times
404 .into_iter()
405 .enumerate()
406 .map(|(offset, (_, time))| {
407 let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
408 let index = self
409 .completed
410 .checked_add(offset)
411 .ok_or(EngineError::OutOfRange)?;
412 Ok(ExportFrameNeeds {
413 index,
414 time,
415 needs: project.frame_needs(time)?,
416 })
417 })
418 .collect()
419 }
420
421 pub fn render_next(
430 &mut self,
431 project: &EngineProject,
432 renderer: &Renderer,
433 frames: &dyn FrameProvider,
434 ) -> Result<ExportStep, EngineError> {
435 self.render_next_measured(project, renderer, frames, None)
436 }
437
438 pub fn render_next_timed(
447 &mut self,
448 project: &EngineProject,
449 renderer: &Renderer,
450 frames: &dyn FrameProvider,
451 timings: &mut ExportStepTimings,
452 ) -> Result<ExportStep, EngineError> {
453 self.render_next_measured(project, renderer, frames, Some(timings))
454 }
455
456 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
467 pub fn render_next_nv12_timed(
468 &mut self,
469 project: &EngineProject,
470 renderer: &Renderer,
471 frames: &dyn FrameProvider,
472 planes: alpha2_render::Nv12PlaneTargets<'_>,
473 timings: &mut ExportStepTimings,
474 ) -> Result<ExportPlaneStep, EngineError> {
475 self.check(project)?;
476 let Some(time) = self.pending_time() else {
477 return Ok(ExportPlaneStep::Complete);
478 };
479 let evaluate_started = Instant::now();
480 let frame = project.evaluate(time)?;
481 crate::frame::check_frame_effects(renderer, &frame)?;
482 let prepare_started = Instant::now();
483 let evaluate = prepare_started.duration_since(evaluate_started);
484 let size = self.quality.render_size(&project.project().settings)?;
485 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
486 renderer.set_quality_controls(
487 self.quality.max_filter_samples,
488 self.quality.motion_blur_subframes,
489 );
490 let prepare = prepare_started.elapsed();
491 let mut submit = FrameSubmitTimings::default();
492 let attempt = renderer.render_frame_nv12_timed(
493 size,
494 &frame,
495 frames,
496 planes,
497 self.encoding,
498 &mut submit,
499 )?;
500 *timings = ExportStepTimings {
501 evaluate,
502 prepare,
503 submit,
504 };
505 if !attempt.submitted {
506 return Ok(ExportPlaneStep::Waiting {
507 time,
508 missing_sources: attempt.report.missing_sources,
509 });
510 }
511 let index = self.completed;
512 self.next_frame = self
513 .next_frame
514 .checked_add(1)
515 .ok_or(EngineError::OutOfRange)?;
516 self.completed += 1;
517 Ok(ExportPlaneStep::Rendered { index, time })
518 }
519
520 pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
532 &mut self,
533 project: &EngineProject,
534 renderer: &R,
535 frames: &dyn FrameProvider,
536 targets: &mut Yuv420pTargets<'_>,
537 timings: Option<&mut ExportStepTimings>,
538 ) -> Result<ExportPlaneStep, EngineError> {
539 self.check(project)?;
540 let Some(time) = self.pending_time() else {
541 return Ok(ExportPlaneStep::Complete);
542 };
543 let evaluate_started = Instant::now();
544 let frame = project.evaluate(time)?;
545 crate::frame::check_frame_effects(renderer, &frame)?;
546 let prepare_started = Instant::now();
547 let evaluate = prepare_started.duration_since(evaluate_started);
548 let size = self.quality.render_size(&project.project().settings)?;
549 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
550 renderer.set_quality_controls(
551 self.quality.max_filter_samples,
552 self.quality.motion_blur_subframes,
553 );
554 let draw_started = Instant::now();
555 let prepare = draw_started.duration_since(prepare_started);
556 let report =
557 renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
558 if let Some(timings) = timings {
559 *timings = ExportStepTimings {
560 evaluate,
561 prepare,
562 submit: FrameSubmitTimings {
563 command_encode: draw_started.elapsed(),
564 ..FrameSubmitTimings::default()
565 },
566 };
567 }
568 if !report.is_complete() {
569 return Ok(ExportPlaneStep::Waiting {
570 time,
571 missing_sources: report.missing_sources,
572 });
573 }
574 let index = self.completed;
575 self.next_frame = self
576 .next_frame
577 .checked_add(1)
578 .ok_or(EngineError::OutOfRange)?;
579 self.completed += 1;
580 Ok(ExportPlaneStep::Rendered { index, time })
581 }
582
583 pub fn render_next_texture(
593 &mut self,
594 project: &EngineProject,
595 renderer: &Renderer,
596 frames: &dyn FrameProvider,
597 ) -> Result<ExportTextureStep, EngineError> {
598 self.check(project)?;
599 let Some(time) = self.pending_time() else {
600 return Ok(ExportTextureStep::Complete);
601 };
602 let frame = project.evaluate(time)?;
603 crate::frame::check_frame_effects(renderer, &frame)?;
604 let size = self.quality.render_size(&project.project().settings)?;
605 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
606 renderer.set_quality_controls(
607 self.quality.max_filter_samples,
608 self.quality.motion_blur_subframes,
609 );
610 let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
611 let Some(texture) = render.texture else {
612 return Ok(ExportTextureStep::Waiting {
613 time,
614 missing_sources: render.report.missing_sources,
615 });
616 };
617 let index = self.completed;
618 self.next_frame = self
619 .next_frame
620 .checked_add(1)
621 .ok_or(EngineError::OutOfRange)?;
622 self.completed += 1;
623 Ok(ExportTextureStep::Rendered {
624 index,
625 time,
626 texture,
627 })
628 }
629
630 fn render_next_measured(
631 &mut self,
632 project: &EngineProject,
633 renderer: &Renderer,
634 frames: &dyn FrameProvider,
635 timings: Option<&mut ExportStepTimings>,
636 ) -> Result<ExportStep, EngineError> {
637 self.check(project)?;
638 let Some(time) = self.pending_time() else {
639 return Ok(ExportStep::Complete);
640 };
641 let evaluate_started = timings.as_ref().map(|_| Instant::now());
642 let frame = project.evaluate(time)?;
643 crate::frame::check_frame_effects(renderer, &frame)?;
644 let prepare_started = evaluate_started.map(|_| Instant::now());
645 let evaluate = evaluate_started
646 .zip(prepare_started)
647 .map_or(Duration::ZERO, |(started, ended)| {
648 ended.duration_since(started)
649 });
650 let size = self.quality.render_size(&project.project().settings)?;
651 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
652 renderer.set_quality_controls(
653 self.quality.max_filter_samples,
654 self.quality.motion_blur_subframes,
655 );
656 let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
657 let attempt = match timings {
658 Some(timings) => {
659 let mut submit = FrameSubmitTimings::default();
660 let attempt = renderer.render_frame_yuv420p_timed(
661 size,
662 &frame,
663 frames,
664 self.encoding,
665 &mut submit,
666 )?;
667 *timings = ExportStepTimings {
668 evaluate,
669 prepare,
670 submit,
671 };
672 attempt
673 }
674 None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
675 };
676 let Some(readback) = attempt.readback else {
677 return Ok(ExportStep::Waiting {
678 time,
679 missing_sources: attempt.report.missing_sources,
680 });
681 };
682 let index = self.completed;
683 self.next_frame = self
684 .next_frame
685 .checked_add(1)
686 .ok_or(EngineError::OutOfRange)?;
687 self.completed += 1;
688 Ok(ExportStep::Rendered {
689 index,
690 time,
691 readback,
692 })
693 }
694}
695
696#[derive(Copy, Clone, Debug, Eq, PartialEq)]
698pub struct ExportAudioWindow {
699 pub start_sample: i64,
701 pub frames: usize,
703}
704
705pub fn export_audio_window(
717 range: TimeRange,
718 sample_rate: u32,
719) -> Result<ExportAudioWindow, EngineError> {
720 let nearest = |ticks: i64| {
721 let doubled =
722 i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
723 i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
724 .map_err(|_| EngineError::OutOfRange)
725 };
726 let start_sample = nearest(range.start().ticks())?;
727 let end = nearest(range.end().ticks())?;
728 let frames = end
729 .checked_sub(start_sample)
730 .and_then(|frames| usize::try_from(frames).ok())
731 .ok_or(EngineError::OutOfRange)?;
732 Ok(ExportAudioWindow {
733 start_sample,
734 frames,
735 })
736}
737
738fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
739 let floor = rate
740 .frame_at_or_before(time)
741 .ok_or(EngineError::OutOfRange)?;
742 let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
743 if floor_time < time {
744 floor.checked_add(1).ok_or(EngineError::OutOfRange)
745 } else {
746 Ok(floor)
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::Player;
754 use alpha2_model::{Project, ProjectId, ProjectSettings};
755
756 fn snapshot(width: u32, height: u32) -> EngineProject {
757 EngineProject::new(Project::new(
758 ProjectId::new("export".to_owned()).expect("id"),
759 "export",
760 ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
761 .expect("settings"),
762 ))
763 .expect("valid project")
764 }
765
766 #[test]
767 fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
768 let range =
769 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
770 let rate = FrameRate::new(30, 1).expect("valid rate");
771 for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
772 let error = Exporter::new(
773 &snapshot(size.0, size.1),
774 range,
775 rate,
776 QualityProfile::export(),
777 VideoCodec::H264,
778 )
779 .expect_err("odd output");
780 assert_eq!(
781 error,
782 EngineError::OddExportDimensions {
783 width: size.0,
784 height: size.1,
785 suggested,
786 }
787 );
788 assert!(
789 error
790 .to_string()
791 .contains(&format!("{}x{}", suggested.0, suggested.1))
792 );
793 }
794 assert!(
795 Exporter::new(
796 &snapshot(64, 48),
797 range,
798 rate,
799 QualityProfile::export(),
800 VideoCodec::H264
801 )
802 .is_ok()
803 );
804 }
805
806 #[test]
807 fn runners_expose_distinct_missing_frame_policies() {
808 let rate = FrameRate::new(30, 1).expect("valid rate");
809 let range =
810 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
811 let exporter = Exporter::new(
812 &snapshot(16, 16),
813 range,
814 rate,
815 QualityProfile::export(),
816 VideoCodec::H264,
817 )
818 .expect("valid exporter");
819 assert_eq!(
820 Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
821 MissingFramePolicy::HoldPrevious
822 );
823 assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
824 }
825
826 #[test]
827 fn schedule_uses_exact_rational_frame_boundaries() {
828 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
829 let start = rate.frame_start(10).expect("representable");
830 let end = rate.frame_start(20).expect("representable");
831 let exporter = Exporter::new(
832 &snapshot(16, 16),
833 TimeRange::new(start, end).expect("valid range"),
834 rate,
835 QualityProfile::export(),
836 VideoCodec::H264,
837 )
838 .expect("valid exporter");
839 assert_eq!(exporter.pending_time(), Some(start));
840 assert_eq!(
841 exporter.progress(),
842 ExportProgress {
843 completed: 0,
844 total: 10,
845 }
846 );
847 }
848
849 #[test]
850 fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
851 let rate = FrameRate::new(30, 1).expect("valid rate");
852 let subframe = Exporter::new(
853 &snapshot(16, 16),
854 TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
855 rate,
856 QualityProfile::export(),
857 VideoCodec::H264,
858 )
859 .expect("one intersecting frame");
860 assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
861 assert_eq!(subframe.progress().total, 1);
862
863 let unaligned = Exporter::new(
864 &snapshot(16, 16),
865 TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
866 rate,
867 QualityProfile::export(),
868 VideoCodec::H264,
869 )
870 .expect("two intersecting frames");
871 assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
872 assert_eq!(unaligned.progress().total, 2);
873 assert_eq!(
874 unaligned.effective_range(),
875 TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
876 );
877 }
878
879 #[test]
880 fn effective_range_is_the_request_widened_to_the_frame_grid() {
881 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
882 let frame = |index| rate.frame_start(index).expect("representable").ticks();
883 for (start, end) in [
884 (frame(3), frame(9)),
885 (frame(3) + 1, frame(9) - 1),
886 (frame(4) - 1, frame(8) + 1),
887 ] {
888 let exporter = Exporter::new(
889 &snapshot(16, 16),
890 TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
891 rate,
892 QualityProfile::export(),
893 VideoCodec::H264,
894 )
895 .expect("valid exporter");
896 let effective = exporter.effective_range();
897 assert_eq!(
898 (effective.start(), effective.end()),
899 (MediaTime(frame(3)), MediaTime(frame(9))),
900 "{start}..{end}"
901 );
902 assert_eq!(exporter.pending_time(), Some(effective.start()));
903 }
904 }
905
906 #[test]
907 fn schedule_rejects_unbounded_frame_counts() {
908 let rate = FrameRate::new(30, 1).expect("valid rate");
909 let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
910 assert!(matches!(
911 Exporter::new(
912 &snapshot(16, 16),
913 range,
914 rate,
915 QualityProfile::export(),
916 VideoCodec::H264
917 ),
918 Err(EngineError::ExportTooLarge { .. })
919 ));
920 }
921
922 #[test]
923 fn prefetch_reports_future_indices_without_advancing_the_cursor() {
924 let rate = FrameRate::new(30, 1).expect("valid rate");
925 let project = Project::new(
926 ProjectId::new("prefetch").expect("project ID"),
927 "prefetch",
928 ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
929 );
930 let snapshot = EngineProject::new(project).expect("snapshot");
931 let range =
932 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
933 let exporter = Exporter::new(
934 &snapshot,
935 range,
936 rate,
937 QualityProfile::export(),
938 VideoCodec::H264,
939 )
940 .expect("valid exporter");
941
942 let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
943 assert_eq!(
944 prefetched
945 .iter()
946 .map(|frame| (frame.index, frame.time))
947 .collect::<Vec<_>>(),
948 vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
949 );
950 assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
951 assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
952 assert_eq!(exporter.progress().completed, 0);
953 }
954
955 #[test]
956 fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
957 let rate = FrameRate::new(30, 1).expect("valid rate");
958 let snapshot = snapshot(16, 16);
959 let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
961 .expect("non-empty range");
962 let mut exporter = Exporter::new(
963 &snapshot,
964 range,
965 rate,
966 QualityProfile::export(),
967 VideoCodec::H264,
968 )
969 .expect("valid exporter");
970 let frame = MediaTime::from_ticks;
971 assert_eq!(
972 exporter.upcoming_times(2).expect("times"),
973 vec![frame(0), frame(4_000)]
974 );
975 let (_, taken) = exporter.take_next().expect("take").expect("a frame");
976 assert_eq!(taken, frame(0));
977 assert_eq!(
979 exporter.upcoming_times(5).expect("times"),
980 vec![frame(4_000), frame(8_000)]
981 );
982 assert_eq!(exporter.pending_time(), Some(frame(4_000)));
983 exporter.take_next().expect("take");
984 exporter.take_next().expect("take");
985 assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
986 }
987
988 #[test]
989 fn a_whole_project_export_covers_its_caption_cues() {
990 let project = |clips: &str| {
991 let json = format!(
992 r#"{{"schema_version": 1, "id": "p", "name": "p",
993 "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
994 "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
995 "timeline": {{"tracks": [
996 {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
997 {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
998 {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
999 );
1000 EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
1001 };
1002 let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
1003 for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
1004 let range = snapshot.timeline_range().expect("something is shown");
1005 assert_eq!(
1006 (range.start().ticks(), range.end().ticks()),
1007 (start, 1_440_000)
1008 );
1009 let exporter =
1010 Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
1011 .expect("an export of the whole project");
1012 assert_eq!(exporter.effective_range(), range);
1013 }
1014 }
1015}