Skip to main content

alpha2_engine/
exporter.rs

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