Skip to main content

alpha2_engine/
exporter.rs

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
13/// Largest frame schedule accepted by one export operation.
14pub const MAX_EXPORT_FRAMES: u64 = 10_000_000;
15const MAX_PREFETCH_FRAMES: u32 = 120;
16
17/// Decode requirements for one scheduled export frame.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ExportFrameNeeds {
20    /// Zero-based frame position inside the export operation.
21    pub index: u64,
22    /// Exact rational frame timestamp on the project timeline.
23    pub time: MediaTime,
24    /// Deduplicated source frames required before rendering this timestamp.
25    pub needs: Vec<FrameNeed>,
26}
27
28/// Completed and total frame counts for deterministic export.
29#[derive(Copy, Clone, Debug, Eq, PartialEq)]
30pub struct ExportProgress {
31    /// Successfully rendered frames.
32    pub completed: u64,
33    /// Frame intervals that intersect the export range.
34    pub total: u64,
35}
36
37impl ExportProgress {
38    /// Completion ratio in the inclusive range zero to one.
39    #[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/// Outcome of one non-blocking exporter step.
51#[derive(Debug)]
52pub enum ExportStep {
53    /// Required decoded frames are not available; retry without advancing.
54    Waiting {
55        /// Timeline timestamp that remains pending.
56        time: MediaTime,
57        /// Sources the host must provide before retrying.
58        missing_sources: Vec<SourceId>,
59    },
60    /// One frame was rendered and the cursor advanced exactly once.
61    Rendered {
62        /// Zero-based position inside this export operation.
63        index: u64,
64        /// Exact rational-frame timestamp.
65        time: MediaTime,
66        /// Submitted planar YUV420 readback for the encoder.
67        readback: Yuv420pReadback,
68    },
69    /// Every scheduled frame has been produced.
70    Complete,
71}
72
73/// One step of an export that leaves its picture as a rendered texture.
74#[derive(Debug)]
75pub enum ExportTextureStep {
76    /// Required decoded frames are not available; retry without advancing.
77    Waiting {
78        /// Timeline timestamp that remains pending.
79        time: MediaTime,
80        /// Sources the host must provide before retrying.
81        missing_sources: Vec<SourceId>,
82    },
83    /// One frame was rendered and the cursor advanced exactly once.
84    Rendered {
85        /// Zero-based position inside this export operation.
86        index: u64,
87        /// Exact rational-frame timestamp.
88        time: MediaTime,
89        /// The frame at export quality, in the renderer's intermediate
90        /// format; its work is submitted.
91        texture: alpha2_render::RenderedTexture,
92    },
93    /// Every scheduled frame has been produced.
94    Complete,
95}
96
97/// One step of an export that packs straight into GPU encoder planes.
98#[derive(Debug)]
99#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
100pub enum ExportNv12Step {
101    /// Required decoded frames are not available; retry without advancing.
102    Waiting {
103        /// Timeline timestamp that remains pending.
104        time: MediaTime,
105        /// Sources the host must provide before retrying.
106        missing_sources: Vec<SourceId>,
107    },
108    /// One frame was packed into the caller's planes and the cursor advanced.
109    Rendered {
110        /// Zero-based position inside this export operation.
111        index: u64,
112        /// Exact rational-frame timestamp.
113        time: MediaTime,
114    },
115    /// Every scheduled frame has been produced.
116    Complete,
117}
118
119/// Where one [`Exporter::render_next_timed`] step spent its time.
120///
121/// `frameSubmitMs` is one number covering timeline evaluation, graph
122/// construction, and the queue submission; these spans split it so a
123/// regression can be attributed to one of them.
124#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
125pub struct ExportStepTimings {
126    /// Evaluating the timeline at this frame's timestamp.
127    pub evaluate: Duration,
128    /// Applying the quality profile, which also trims the texture pool to its
129    /// budget and so is not the constant-time setter it reads as.
130    pub prepare: Duration,
131    /// Everything the renderer did with the evaluated frame.
132    pub submit: FrameSubmitTimings,
133}
134
135impl ExportStepTimings {
136    /// Adds another step's spans to these, saturating on overflow.
137    #[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/// Deterministic, retryable frame-sequence runner.
148#[derive(Clone, Debug)]
149pub struct Exporter {
150    /// The [`EngineProject::generation`] it was made for, and renders.
151    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    /// Creates an exporter for frame intervals intersecting a half-open timeline range.
164    ///
165    /// Everything an export can be refused for is refused here, before a
166    /// host opens an encoder: the output 4:2:0 frames are the project size at
167    /// `quality`, and they must be even in both dimensions. They are packed
168    /// for `codec`: [`YuvEncoding::export`]. Every later step takes this
169    /// snapshot (or a clone of it) and refuses any other with
170    /// [`EngineError::ExportProjectChanged`], so an export renders one
171    /// project, at the size checked here.
172    ///
173    /// # Errors
174    /// Returns an error for an empty range, invalid quality, timeline overflow,
175    /// or [`EngineError::OddExportDimensions`].
176    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    /// How this export's frames are packed into 4:2:0 YUV.
227    #[must_use]
228    pub const fn encoding(&self) -> YuvEncoding {
229        self.encoding
230    }
231
232    /// The timeline range this export covers: the requested range widened to
233    /// the frame grid, `[floor(start), ceil(end))`.
234    ///
235    /// A video export renders whole frames, so its first frame can start up to
236    /// one frame before the requested start. That frame's start is the
237    /// output's zero, and a host places audio, and reports the export, from
238    /// this range rather than from the request.
239    #[must_use]
240    pub const fn effective_range(&self) -> TimeRange {
241        self.effective_range
242    }
243
244    /// The samples this export's audio covers at `sample_rate`: the
245    /// [`export_audio_window`] of [`Self::effective_range`].
246    ///
247    /// # Errors
248    /// Returns [`EngineError::OutOfRange`] when the range does not fit in
249    /// memory-addressable samples.
250    pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
251        export_audio_window(self.effective_range, sample_rate)
252    }
253
254    /// Creates an exporter spanning everything a project shows, its clips
255    /// and caption cues ([`EngineProject::timeline_range`]), packed for
256    /// `codec`.
257    ///
258    /// # Errors
259    /// Returns an error for an empty project or invalid schedule.
260    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    /// The only policy an export can apply.
278    ///
279    /// Export output must not depend on decode timing: a pipelined export and
280    /// a synchronous one are required to be byte-identical, which
281    /// `scripts/test-host-cli-parity.mjs` asserts. Repeating the previous
282    /// frame when a source is late would make the result a function of how
283    /// fast the host decoded, so this is a constant rather than a setting.
284    /// [`Player`] configures the same policy because a dropped real-time frame
285    /// has no such guarantee to break.
286    ///
287    /// [`Player`]: crate::Player
288    pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
289
290    /// Returns the policy used when source frames are unavailable.
291    #[must_use]
292    pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
293        Self::MISSING_FRAME_POLICY
294    }
295
296    /// Returns current deterministic export progress.
297    #[must_use]
298    pub const fn progress(&self) -> ExportProgress {
299        ExportProgress {
300            completed: self.completed,
301            total: self.total,
302        }
303    }
304
305    /// The size frames of `project` render at: where a host prepares their
306    /// captions.
307    ///
308    /// # Errors
309    /// Returns an error when the quality profile cannot size the project.
310    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    /// Refuses a snapshot other than the one this export was made for.
318    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    /// Returns the pending frame timestamp, or `None` after completion.
327    #[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    /// Takes the pending frame as supplied from elsewhere (a copy baked by
335    /// an earlier export of the same snapshot) and advances the cursor
336    /// exactly as a render does. Returns its index and time, or `None` after
337    /// completion.
338    ///
339    /// # Errors
340    /// Returns [`EngineError::OutOfRange`] if the cursor would overflow.
341    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    /// The frame grid the export renders on.
355    #[must_use]
356    pub const fn frame_rate(&self) -> FrameRate {
357        self.frame_rate
358    }
359
360    /// The times of the pending frame and up to `frames - 1` after it, in
361    /// order, as [`Self::take_next`] and the renders after it will reach
362    /// them: fewer near the end of the range, none after completion. The
363    /// cursor is not changed, and `frames` is clamped as in
364    /// [`Self::prefetch_needs`].
365    ///
366    /// # Errors
367    /// Returns [`EngineError::OutOfRange`] if a time overflows.
368    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    /// Returns decode requirements for the pending frame and bounded lookahead.
381    ///
382    /// The exporter cursor is not changed. `frames` is the total number of
383    /// scheduled frames requested, including the current frame, and is clamped
384    /// to 120 to keep one request bounded.
385    ///
386    /// # Errors
387    /// Returns evaluation or timeline overflow errors, and
388    /// [`EngineError::ExportProjectChanged`] for another snapshot.
389    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    /// Attempts one frame without blocking on host decode.
421    ///
422    /// Waiting does not change progress. A successful render advances the
423    /// exact rational frame cursor once, so retries cannot skip timestamps.
424    ///
425    /// # Errors
426    /// Returns timeline evaluation, quality, or rendering failures, and
427    /// [`EngineError::ExportProjectChanged`] for another snapshot.
428    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    /// Attempts one frame and reports where the step spent its time.
438    ///
439    /// This renders exactly what [`Exporter::render_next`] renders. A step
440    /// that only waits leaves `timings` holding the stages that ran.
441    ///
442    /// # Errors
443    /// Returns timeline evaluation, quality, or rendering failures, and
444    /// [`EngineError::ExportProjectChanged`] for another snapshot.
445    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    /// Renders the next frame straight into caller-owned NV12 plane textures.
456    ///
457    /// The readback-free counterpart of [`Self::render_next_timed`]: the
458    /// cursor advances identically, so an export can be driven through either
459    /// without its frame accounting changing. `Rendered` carries no readback
460    /// because the picture stays on the GPU.
461    ///
462    /// # Errors
463    /// Returns an engine error when evaluation or rendering fails, and
464    /// [`EngineError::ExportProjectChanged`] for another snapshot.
465    #[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    /// Renders the next frame and hands its texture over, with no readback
520    /// and no pack: for a host that moves the picture on by other means, such
521    /// as presenting it to a canvas an encoder captures.
522    ///
523    /// The cursor advances exactly as it does for [`Self::render_next`].
524    ///
525    /// # Errors
526    /// Returns an engine error when evaluation or rendering fails, and
527    /// [`EngineError::ExportProjectChanged`] for another snapshot.
528    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/// The project-rate samples an export's audio covers.
633#[derive(Copy, Clone, Debug, Eq, PartialEq)]
634pub struct ExportAudioWindow {
635    /// First sample, at the project sample rate.
636    pub start_sample: i64,
637    /// Sample count.
638    pub frames: usize,
639}
640
641/// The samples an export over `range` writes as audio: both ends are the
642/// samples nearest the range's ends, half a sample rounding up.
643///
644/// Every host places export audio through this, from the export's
645/// [`Exporter::effective_range`], so the audio starts and ends within half a
646/// sample of the video's first frame and last frame end on every path that
647/// writes the file.
648///
649/// # Errors
650/// Returns [`EngineError::OutOfRange`] when the range does not fit in
651/// memory-addressable samples.
652pub 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        // Three frames: 0, 1/30 s, and 2/30 s.
896        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        // Fewer near the end; the cursor is not moved.
914        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}