1use alpha2_eval::FrameNeed;
2use alpha2_media::{SourceId, VideoCodec, YuvEncoding};
3use alpha2_render::{FrameSubmitTimings, Renderer, Yuv420pReadback};
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, MissingFramePolicy, QualityProfile,
11};
12
13pub const MAX_EXPORT_FRAMES: u64 = 10_000_000;
15const MAX_PREFETCH_FRAMES: u32 = 120;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ExportFrameNeeds {
20 pub index: u64,
22 pub time: MediaTime,
24 pub needs: Vec<FrameNeed>,
26}
27
28#[derive(Copy, Clone, Debug, Eq, PartialEq)]
30pub struct ExportProgress {
31 pub completed: u64,
33 pub total: u64,
35}
36
37impl ExportProgress {
38 #[must_use]
40 #[allow(clippy::cast_precision_loss)]
41 pub fn fraction(self) -> f64 {
42 if self.total == 0 {
43 1.0
44 } else {
45 self.completed as f64 / self.total as f64
46 }
47 }
48}
49
50#[derive(Debug)]
52pub enum ExportStep {
53 Waiting {
55 time: MediaTime,
57 missing_sources: Vec<SourceId>,
59 },
60 Rendered {
62 index: u64,
64 time: MediaTime,
66 readback: Yuv420pReadback,
68 },
69 Complete,
71}
72
73#[derive(Debug)]
75pub enum ExportTextureStep {
76 Waiting {
78 time: MediaTime,
80 missing_sources: Vec<SourceId>,
82 },
83 Rendered {
85 index: u64,
87 time: MediaTime,
89 texture: alpha2_render::RenderedTexture,
92 },
93 Complete,
95}
96
97#[derive(Debug)]
99#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
100pub enum ExportNv12Step {
101 Waiting {
103 time: MediaTime,
105 missing_sources: Vec<SourceId>,
107 },
108 Rendered {
110 index: u64,
112 time: MediaTime,
114 },
115 Complete,
117}
118
119#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
125pub struct ExportStepTimings {
126 pub evaluate: Duration,
128 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 frame_rate: FrameRate,
155 effective_range: TimeRange,
156 next_frame: i64,
157 end_frame: i64,
158 completed: u64,
159 total: u64,
160}
161
162impl Exporter {
163 pub fn new(
177 snapshot: &EngineProject,
178 range: TimeRange,
179 frame_rate: FrameRate,
180 quality: QualityProfile,
181 codec: VideoCodec,
182 ) -> Result<Self, EngineError> {
183 quality.export_size(&snapshot.project().settings)?;
184 if range.is_empty() {
185 return Err(EngineError::EmptyExportRange);
186 }
187 let next_frame = frame_rate
188 .frame_at_or_before(range.start())
189 .ok_or(EngineError::OutOfRange)?;
190 let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
191 let count = end_frame
192 .checked_sub(next_frame)
193 .ok_or(EngineError::OutOfRange)?;
194 let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
195 if total == 0 {
196 return Err(EngineError::EmptyExportRange);
197 }
198 if total > MAX_EXPORT_FRAMES {
199 return Err(EngineError::ExportTooLarge {
200 frames: total,
201 maximum: MAX_EXPORT_FRAMES,
202 });
203 }
204 let effective_range = TimeRange::new(
205 frame_rate
206 .frame_start(next_frame)
207 .ok_or(EngineError::OutOfRange)?,
208 frame_rate
209 .frame_start(end_frame)
210 .ok_or(EngineError::OutOfRange)?,
211 )
212 .map_err(|_| EngineError::OutOfRange)?;
213 Ok(Self {
214 snapshot: snapshot.generation(),
215 quality,
216 encoding: YuvEncoding::export(codec),
217 frame_rate,
218 effective_range,
219 next_frame,
220 end_frame,
221 completed: 0,
222 total,
223 })
224 }
225
226 #[must_use]
228 pub const fn encoding(&self) -> YuvEncoding {
229 self.encoding
230 }
231
232 #[must_use]
240 pub const fn effective_range(&self) -> TimeRange {
241 self.effective_range
242 }
243
244 pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
251 export_audio_window(self.effective_range, sample_rate)
252 }
253
254 pub fn for_project(
261 project: &EngineProject,
262 quality: QualityProfile,
263 codec: VideoCodec,
264 ) -> Result<Self, EngineError> {
265 let range = project
266 .timeline_range()
267 .ok_or(EngineError::EmptyExportRange)?;
268 Self::new(
269 project,
270 range,
271 project.project().settings.frame_rate,
272 quality,
273 codec,
274 )
275 }
276
277 pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
289
290 #[must_use]
292 pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
293 Self::MISSING_FRAME_POLICY
294 }
295
296 #[must_use]
298 pub const fn progress(&self) -> ExportProgress {
299 ExportProgress {
300 completed: self.completed,
301 total: self.total,
302 }
303 }
304
305 pub fn render_size(
311 &self,
312 project: &EngineProject,
313 ) -> Result<alpha2_render::RenderSize, EngineError> {
314 self.quality.render_size(&project.project().settings)
315 }
316
317 fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
319 if project.generation() == self.snapshot {
320 Ok(())
321 } else {
322 Err(EngineError::ExportProjectChanged)
323 }
324 }
325
326 #[must_use]
328 pub fn pending_time(&self) -> Option<MediaTime> {
329 (self.next_frame < self.end_frame)
330 .then(|| self.frame_rate.frame_start(self.next_frame))
331 .flatten()
332 }
333
334 pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
342 let Some(time) = self.pending_time() else {
343 return Ok(None);
344 };
345 let index = self.completed;
346 self.next_frame = self
347 .next_frame
348 .checked_add(1)
349 .ok_or(EngineError::OutOfRange)?;
350 self.completed += 1;
351 Ok(Some((index, time)))
352 }
353
354 #[must_use]
356 pub const fn frame_rate(&self) -> FrameRate {
357 self.frame_rate
358 }
359
360 pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
369 Ok(frame_times(
370 self.frame_rate,
371 self.next_frame,
372 frames.min(MAX_PREFETCH_FRAMES),
373 Some(self.end_frame),
374 )?
375 .into_iter()
376 .map(|(_, time)| time)
377 .collect())
378 }
379
380 pub fn prefetch_needs(
390 &self,
391 project: &EngineProject,
392 frames: u32,
393 ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
394 self.check(project)?;
395 let count = frames.min(MAX_PREFETCH_FRAMES);
396 let times = frame_times(
397 self.frame_rate,
398 self.next_frame,
399 count,
400 Some(self.end_frame),
401 )?;
402 times
403 .into_iter()
404 .enumerate()
405 .map(|(offset, (_, time))| {
406 let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
407 let index = self
408 .completed
409 .checked_add(offset)
410 .ok_or(EngineError::OutOfRange)?;
411 Ok(ExportFrameNeeds {
412 index,
413 time,
414 needs: project.frame_needs(time)?,
415 })
416 })
417 .collect()
418 }
419
420 pub fn render_next(
429 &mut self,
430 project: &EngineProject,
431 renderer: &Renderer,
432 frames: &dyn FrameProvider,
433 ) -> Result<ExportStep, EngineError> {
434 self.render_next_measured(project, renderer, frames, None)
435 }
436
437 pub fn render_next_timed(
446 &mut self,
447 project: &EngineProject,
448 renderer: &Renderer,
449 frames: &dyn FrameProvider,
450 timings: &mut ExportStepTimings,
451 ) -> Result<ExportStep, EngineError> {
452 self.render_next_measured(project, renderer, frames, Some(timings))
453 }
454
455 #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
466 pub fn render_next_nv12_timed(
467 &mut self,
468 project: &EngineProject,
469 renderer: &Renderer,
470 frames: &dyn FrameProvider,
471 planes: alpha2_render::Nv12PlaneTargets<'_>,
472 timings: &mut ExportStepTimings,
473 ) -> Result<ExportNv12Step, EngineError> {
474 self.check(project)?;
475 let Some(time) = self.pending_time() else {
476 return Ok(ExportNv12Step::Complete);
477 };
478 let evaluate_started = Instant::now();
479 let frame = project.evaluate(time)?;
480 crate::frame::check_frame_effects(renderer, &frame)?;
481 let prepare_started = Instant::now();
482 let evaluate = prepare_started.duration_since(evaluate_started);
483 let size = self.quality.render_size(&project.project().settings)?;
484 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
485 renderer.set_quality_controls(
486 self.quality.max_filter_samples,
487 self.quality.motion_blur_subframes,
488 );
489 let prepare = prepare_started.elapsed();
490 let mut submit = FrameSubmitTimings::default();
491 let attempt = renderer.render_frame_nv12_timed(
492 size,
493 &frame,
494 frames,
495 planes,
496 self.encoding,
497 &mut submit,
498 )?;
499 *timings = ExportStepTimings {
500 evaluate,
501 prepare,
502 submit,
503 };
504 if !attempt.submitted {
505 return Ok(ExportNv12Step::Waiting {
506 time,
507 missing_sources: attempt.report.missing_sources,
508 });
509 }
510 let index = self.completed;
511 self.next_frame = self
512 .next_frame
513 .checked_add(1)
514 .ok_or(EngineError::OutOfRange)?;
515 self.completed += 1;
516 Ok(ExportNv12Step::Rendered { index, time })
517 }
518
519 pub fn render_next_texture(
529 &mut self,
530 project: &EngineProject,
531 renderer: &Renderer,
532 frames: &dyn FrameProvider,
533 ) -> Result<ExportTextureStep, EngineError> {
534 self.check(project)?;
535 let Some(time) = self.pending_time() else {
536 return Ok(ExportTextureStep::Complete);
537 };
538 let frame = project.evaluate(time)?;
539 crate::frame::check_frame_effects(renderer, &frame)?;
540 let size = self.quality.render_size(&project.project().settings)?;
541 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
542 renderer.set_quality_controls(
543 self.quality.max_filter_samples,
544 self.quality.motion_blur_subframes,
545 );
546 let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
547 let Some(texture) = render.texture else {
548 return Ok(ExportTextureStep::Waiting {
549 time,
550 missing_sources: render.report.missing_sources,
551 });
552 };
553 let index = self.completed;
554 self.next_frame = self
555 .next_frame
556 .checked_add(1)
557 .ok_or(EngineError::OutOfRange)?;
558 self.completed += 1;
559 Ok(ExportTextureStep::Rendered {
560 index,
561 time,
562 texture,
563 })
564 }
565
566 fn render_next_measured(
567 &mut self,
568 project: &EngineProject,
569 renderer: &Renderer,
570 frames: &dyn FrameProvider,
571 timings: Option<&mut ExportStepTimings>,
572 ) -> Result<ExportStep, EngineError> {
573 self.check(project)?;
574 let Some(time) = self.pending_time() else {
575 return Ok(ExportStep::Complete);
576 };
577 let evaluate_started = timings.as_ref().map(|_| Instant::now());
578 let frame = project.evaluate(time)?;
579 crate::frame::check_frame_effects(renderer, &frame)?;
580 let prepare_started = evaluate_started.map(|_| Instant::now());
581 let evaluate = evaluate_started
582 .zip(prepare_started)
583 .map_or(Duration::ZERO, |(started, ended)| {
584 ended.duration_since(started)
585 });
586 let size = self.quality.render_size(&project.project().settings)?;
587 renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
588 renderer.set_quality_controls(
589 self.quality.max_filter_samples,
590 self.quality.motion_blur_subframes,
591 );
592 let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
593 let attempt = match timings {
594 Some(timings) => {
595 let mut submit = FrameSubmitTimings::default();
596 let attempt = renderer.render_frame_yuv420p_timed(
597 size,
598 &frame,
599 frames,
600 self.encoding,
601 &mut submit,
602 )?;
603 *timings = ExportStepTimings {
604 evaluate,
605 prepare,
606 submit,
607 };
608 attempt
609 }
610 None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
611 };
612 let Some(readback) = attempt.readback else {
613 return Ok(ExportStep::Waiting {
614 time,
615 missing_sources: attempt.report.missing_sources,
616 });
617 };
618 let index = self.completed;
619 self.next_frame = self
620 .next_frame
621 .checked_add(1)
622 .ok_or(EngineError::OutOfRange)?;
623 self.completed += 1;
624 Ok(ExportStep::Rendered {
625 index,
626 time,
627 readback,
628 })
629 }
630}
631
632#[derive(Copy, Clone, Debug, Eq, PartialEq)]
634pub struct ExportAudioWindow {
635 pub start_sample: i64,
637 pub frames: usize,
639}
640
641pub fn export_audio_window(
653 range: TimeRange,
654 sample_rate: u32,
655) -> Result<ExportAudioWindow, EngineError> {
656 let nearest = |ticks: i64| {
657 let doubled =
658 i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
659 i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
660 .map_err(|_| EngineError::OutOfRange)
661 };
662 let start_sample = nearest(range.start().ticks())?;
663 let end = nearest(range.end().ticks())?;
664 let frames = end
665 .checked_sub(start_sample)
666 .and_then(|frames| usize::try_from(frames).ok())
667 .ok_or(EngineError::OutOfRange)?;
668 Ok(ExportAudioWindow {
669 start_sample,
670 frames,
671 })
672}
673
674fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
675 let floor = rate
676 .frame_at_or_before(time)
677 .ok_or(EngineError::OutOfRange)?;
678 let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
679 if floor_time < time {
680 floor.checked_add(1).ok_or(EngineError::OutOfRange)
681 } else {
682 Ok(floor)
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::Player;
690 use alpha2_model::{Project, ProjectId, ProjectSettings};
691
692 fn snapshot(width: u32, height: u32) -> EngineProject {
693 EngineProject::new(Project::new(
694 ProjectId::new("export".to_owned()).expect("id"),
695 "export",
696 ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
697 .expect("settings"),
698 ))
699 .expect("valid project")
700 }
701
702 #[test]
703 fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
704 let range =
705 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
706 let rate = FrameRate::new(30, 1).expect("valid rate");
707 for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
708 let error = Exporter::new(
709 &snapshot(size.0, size.1),
710 range,
711 rate,
712 QualityProfile::export(),
713 VideoCodec::H264,
714 )
715 .expect_err("odd output");
716 assert_eq!(
717 error,
718 EngineError::OddExportDimensions {
719 width: size.0,
720 height: size.1,
721 suggested,
722 }
723 );
724 assert!(
725 error
726 .to_string()
727 .contains(&format!("{}x{}", suggested.0, suggested.1))
728 );
729 }
730 assert!(
731 Exporter::new(
732 &snapshot(64, 48),
733 range,
734 rate,
735 QualityProfile::export(),
736 VideoCodec::H264
737 )
738 .is_ok()
739 );
740 }
741
742 #[test]
743 fn runners_expose_distinct_missing_frame_policies() {
744 let rate = FrameRate::new(30, 1).expect("valid rate");
745 let range =
746 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
747 let exporter = Exporter::new(
748 &snapshot(16, 16),
749 range,
750 rate,
751 QualityProfile::export(),
752 VideoCodec::H264,
753 )
754 .expect("valid exporter");
755 assert_eq!(
756 Player::default().missing_frame_policy(),
757 MissingFramePolicy::HoldPrevious
758 );
759 assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
760 }
761
762 #[test]
763 fn schedule_uses_exact_rational_frame_boundaries() {
764 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
765 let start = rate.frame_start(10).expect("representable");
766 let end = rate.frame_start(20).expect("representable");
767 let exporter = Exporter::new(
768 &snapshot(16, 16),
769 TimeRange::new(start, end).expect("valid range"),
770 rate,
771 QualityProfile::export(),
772 VideoCodec::H264,
773 )
774 .expect("valid exporter");
775 assert_eq!(exporter.pending_time(), Some(start));
776 assert_eq!(
777 exporter.progress(),
778 ExportProgress {
779 completed: 0,
780 total: 10,
781 }
782 );
783 }
784
785 #[test]
786 fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
787 let rate = FrameRate::new(30, 1).expect("valid rate");
788 let subframe = Exporter::new(
789 &snapshot(16, 16),
790 TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
791 rate,
792 QualityProfile::export(),
793 VideoCodec::H264,
794 )
795 .expect("one intersecting frame");
796 assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
797 assert_eq!(subframe.progress().total, 1);
798
799 let unaligned = Exporter::new(
800 &snapshot(16, 16),
801 TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
802 rate,
803 QualityProfile::export(),
804 VideoCodec::H264,
805 )
806 .expect("two intersecting frames");
807 assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
808 assert_eq!(unaligned.progress().total, 2);
809 assert_eq!(
810 unaligned.effective_range(),
811 TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
812 );
813 }
814
815 #[test]
816 fn effective_range_is_the_request_widened_to_the_frame_grid() {
817 let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
818 let frame = |index| rate.frame_start(index).expect("representable").ticks();
819 for (start, end) in [
820 (frame(3), frame(9)),
821 (frame(3) + 1, frame(9) - 1),
822 (frame(4) - 1, frame(8) + 1),
823 ] {
824 let exporter = Exporter::new(
825 &snapshot(16, 16),
826 TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
827 rate,
828 QualityProfile::export(),
829 VideoCodec::H264,
830 )
831 .expect("valid exporter");
832 let effective = exporter.effective_range();
833 assert_eq!(
834 (effective.start(), effective.end()),
835 (MediaTime(frame(3)), MediaTime(frame(9))),
836 "{start}..{end}"
837 );
838 assert_eq!(exporter.pending_time(), Some(effective.start()));
839 }
840 }
841
842 #[test]
843 fn schedule_rejects_unbounded_frame_counts() {
844 let rate = FrameRate::new(30, 1).expect("valid rate");
845 let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
846 assert!(matches!(
847 Exporter::new(
848 &snapshot(16, 16),
849 range,
850 rate,
851 QualityProfile::export(),
852 VideoCodec::H264
853 ),
854 Err(EngineError::ExportTooLarge { .. })
855 ));
856 }
857
858 #[test]
859 fn prefetch_reports_future_indices_without_advancing_the_cursor() {
860 let rate = FrameRate::new(30, 1).expect("valid rate");
861 let project = Project::new(
862 ProjectId::new("prefetch").expect("project ID"),
863 "prefetch",
864 ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
865 );
866 let snapshot = EngineProject::new(project).expect("snapshot");
867 let range =
868 TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
869 let exporter = Exporter::new(
870 &snapshot,
871 range,
872 rate,
873 QualityProfile::export(),
874 VideoCodec::H264,
875 )
876 .expect("valid exporter");
877
878 let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
879 assert_eq!(
880 prefetched
881 .iter()
882 .map(|frame| (frame.index, frame.time))
883 .collect::<Vec<_>>(),
884 vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
885 );
886 assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
887 assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
888 assert_eq!(exporter.progress().completed, 0);
889 }
890
891 #[test]
892 fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
893 let rate = FrameRate::new(30, 1).expect("valid rate");
894 let snapshot = snapshot(16, 16);
895 let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
897 .expect("non-empty range");
898 let mut exporter = Exporter::new(
899 &snapshot,
900 range,
901 rate,
902 QualityProfile::export(),
903 VideoCodec::H264,
904 )
905 .expect("valid exporter");
906 let frame = MediaTime::from_ticks;
907 assert_eq!(
908 exporter.upcoming_times(2).expect("times"),
909 vec![frame(0), frame(4_000)]
910 );
911 let (_, taken) = exporter.take_next().expect("take").expect("a frame");
912 assert_eq!(taken, frame(0));
913 assert_eq!(
915 exporter.upcoming_times(5).expect("times"),
916 vec![frame(4_000), frame(8_000)]
917 );
918 assert_eq!(exporter.pending_time(), Some(frame(4_000)));
919 exporter.take_next().expect("take");
920 exporter.take_next().expect("take");
921 assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
922 }
923
924 #[test]
925 fn a_whole_project_export_covers_its_caption_cues() {
926 let project = |clips: &str| {
927 let json = format!(
928 r#"{{"schema_version": 1, "id": "p", "name": "p",
929 "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
930 "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
931 "timeline": {{"tracks": [
932 {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
933 {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
934 {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
935 );
936 EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
937 };
938 let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
939 for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
940 let range = snapshot.timeline_range().expect("something is shown");
941 assert_eq!(
942 (range.start().ticks(), range.end().ticks()),
943 (start, 1_440_000)
944 );
945 let exporter =
946 Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
947 .expect("an export of the whole project");
948 assert_eq!(exporter.effective_range(), range);
949 }
950 }
951}