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<I = alpha2_render::RenderedTexture> {
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: on the GPU a texture in the
91        /// renderer's intermediate format, its work submitted; on the CPU its
92        /// image.
93        texture: I,
94    },
95    /// Every scheduled frame has been produced.
96    Complete,
97}
98
99/// One step of an export that packs its picture straight into caller-owned
100/// planes: an encoder's frame in memory, or GPU encoder planes.
101#[derive(Debug)]
102pub enum ExportPlaneStep {
103    /// Required decoded frames are not available; retry without advancing.
104    Waiting {
105        /// Timeline timestamp that remains pending.
106        time: MediaTime,
107        /// Sources the host must provide before retrying.
108        missing_sources: Vec<SourceId>,
109    },
110    /// One frame was packed into the caller's planes and the cursor advanced.
111    Rendered {
112        /// Zero-based position inside this export operation.
113        index: u64,
114        /// Exact rational-frame timestamp.
115        time: MediaTime,
116    },
117    /// Every scheduled frame has been produced.
118    Complete,
119}
120
121/// Where one [`Exporter::render_next_timed`] step spent its time.
122///
123/// `frameSubmitMs` is one number covering timeline evaluation, graph
124/// construction, and the queue submission; these spans split it so a
125/// regression can be attributed to one of them.
126#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
127pub struct ExportStepTimings {
128    /// Evaluating the timeline at this frame's timestamp.
129    pub evaluate: Duration,
130    /// Applying the quality profile, which also trims the texture pool to its
131    /// budget and so is not the constant-time setter it reads as.
132    pub prepare: Duration,
133    /// Everything the renderer did with the evaluated frame.
134    pub submit: FrameSubmitTimings,
135}
136
137impl ExportStepTimings {
138    /// Adds another step's spans to these, saturating on overflow.
139    #[must_use]
140    pub fn merged(self, other: Self) -> Self {
141        Self {
142            evaluate: self.evaluate.saturating_add(other.evaluate),
143            prepare: self.prepare.saturating_add(other.prepare),
144            submit: self.submit.merged(other.submit),
145        }
146    }
147}
148
149/// Deterministic, retryable frame-sequence runner.
150#[derive(Clone, Debug)]
151pub struct Exporter {
152    /// The [`EngineProject::generation`] it was made for, and renders.
153    snapshot: u64,
154    quality: QualityProfile,
155    encoding: YuvEncoding,
156    frame_rate: FrameRate,
157    effective_range: TimeRange,
158    next_frame: i64,
159    end_frame: i64,
160    completed: u64,
161    total: u64,
162}
163
164impl Exporter {
165    /// Creates an exporter for frame intervals intersecting a half-open timeline range.
166    ///
167    /// Everything an export can be refused for is refused here, before a
168    /// host opens an encoder: the output 4:2:0 frames are the project size at
169    /// `quality`, and they must be even in both dimensions. They are packed
170    /// for `codec`: [`YuvEncoding::export`]. Every later step takes this
171    /// snapshot (or a clone of it) and refuses any other with
172    /// [`EngineError::ExportProjectChanged`], so an export renders one
173    /// project, at the size checked here.
174    ///
175    /// # Errors
176    /// Returns an error for an empty range, invalid quality, timeline overflow,
177    /// or [`EngineError::OddExportDimensions`].
178    pub fn new(
179        snapshot: &EngineProject,
180        range: TimeRange,
181        frame_rate: FrameRate,
182        quality: QualityProfile,
183        codec: VideoCodec,
184    ) -> Result<Self, EngineError> {
185        quality.export_size(&snapshot.project().settings)?;
186        Self::over(
187            snapshot,
188            range,
189            frame_rate,
190            quality,
191            YuvEncoding::export(codec),
192        )
193    }
194
195    /// An exporter of the one frame of `frame_rate`'s grid that holds
196    /// `time`, the frame an export of a range around it renders there, for a
197    /// host that takes the frame's picture
198    /// ([`Self::render_next_texture`]) rather than 4:2:0 frames: so the
199    /// project may be any size, an odd one too. [`Self::pending_time`] is
200    /// the frame's time.
201    ///
202    /// # Errors
203    /// Returns an error for invalid quality or a time past the timeline.
204    pub fn still(
205        snapshot: &EngineProject,
206        time: MediaTime,
207        frame_rate: FrameRate,
208        quality: QualityProfile,
209    ) -> Result<Self, EngineError> {
210        quality.render_size(&snapshot.project().settings)?;
211        let range = TimeRange::new(
212            time,
213            MediaTime::from_ticks(time.ticks().checked_add(1).ok_or(EngineError::OutOfRange)?),
214        )
215        .map_err(|_| EngineError::OutOfRange)?;
216        Self::over(
217            snapshot,
218            range,
219            frame_rate,
220            quality,
221            YuvEncoding::export(VideoCodec::H264),
222        )
223    }
224
225    fn over(
226        snapshot: &EngineProject,
227        range: TimeRange,
228        frame_rate: FrameRate,
229        quality: QualityProfile,
230        encoding: YuvEncoding,
231    ) -> Result<Self, EngineError> {
232        if range.is_empty() {
233            return Err(EngineError::EmptyExportRange);
234        }
235        let next_frame = frame_rate
236            .frame_at_or_before(range.start())
237            .ok_or(EngineError::OutOfRange)?;
238        let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
239        let count = end_frame
240            .checked_sub(next_frame)
241            .ok_or(EngineError::OutOfRange)?;
242        let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
243        if total == 0 {
244            return Err(EngineError::EmptyExportRange);
245        }
246        if total > MAX_EXPORT_FRAMES {
247            return Err(EngineError::ExportTooLarge {
248                frames: total,
249                maximum: MAX_EXPORT_FRAMES,
250            });
251        }
252        let effective_range = TimeRange::new(
253            frame_rate
254                .frame_start(next_frame)
255                .ok_or(EngineError::OutOfRange)?,
256            frame_rate
257                .frame_start(end_frame)
258                .ok_or(EngineError::OutOfRange)?,
259        )
260        .map_err(|_| EngineError::OutOfRange)?;
261        Ok(Self {
262            snapshot: snapshot.generation(),
263            quality,
264            encoding,
265            frame_rate,
266            effective_range,
267            next_frame,
268            end_frame,
269            completed: 0,
270            total,
271        })
272    }
273
274    /// How this export's frames are packed into 4:2:0 YUV.
275    #[must_use]
276    pub const fn encoding(&self) -> YuvEncoding {
277        self.encoding
278    }
279
280    /// The timeline range this export covers: the requested range widened to
281    /// the frame grid, `[floor(start), ceil(end))`.
282    ///
283    /// A video export renders whole frames, so its first frame can start up to
284    /// one frame before the requested start. That frame's start is the
285    /// output's zero, and a host places audio, and reports the export, from
286    /// this range rather than from the request.
287    #[must_use]
288    pub const fn effective_range(&self) -> TimeRange {
289        self.effective_range
290    }
291
292    /// The samples this export's audio covers at `sample_rate`: the
293    /// [`export_audio_window`] of [`Self::effective_range`].
294    ///
295    /// # Errors
296    /// Returns [`EngineError::OutOfRange`] when the range does not fit in
297    /// memory-addressable samples.
298    pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
299        export_audio_window(self.effective_range, sample_rate)
300    }
301
302    /// Creates an exporter spanning everything a project shows, its clips
303    /// and caption cues ([`EngineProject::timeline_range`]), packed for
304    /// `codec`.
305    ///
306    /// # Errors
307    /// Returns an error for an empty project or invalid schedule.
308    pub fn for_project(
309        project: &EngineProject,
310        quality: QualityProfile,
311        codec: VideoCodec,
312    ) -> Result<Self, EngineError> {
313        let range = project
314            .timeline_range()
315            .ok_or(EngineError::EmptyExportRange)?;
316        Self::new(
317            project,
318            range,
319            project.project().settings.frame_rate,
320            quality,
321            codec,
322        )
323    }
324
325    /// The only policy an export can apply.
326    ///
327    /// Export output must not depend on decode timing: a pipelined export and
328    /// a synchronous one are required to be byte-identical, which
329    /// `scripts/test-host-cli-parity.mjs` asserts. Repeating the previous
330    /// frame when a source is late would make the result a function of how
331    /// fast the host decoded, so this is a constant rather than a setting.
332    /// [`Player`] configures the same policy because a dropped real-time frame
333    /// has no such guarantee to break.
334    ///
335    /// [`Player`]: crate::Player
336    pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
337
338    /// Returns the policy used when source frames are unavailable.
339    #[must_use]
340    pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
341        Self::MISSING_FRAME_POLICY
342    }
343
344    /// Returns current deterministic export progress.
345    #[must_use]
346    pub const fn progress(&self) -> ExportProgress {
347        ExportProgress {
348            completed: self.completed,
349            total: self.total,
350        }
351    }
352
353    /// The size frames of `project` render at: where a host prepares their
354    /// captions.
355    ///
356    /// # Errors
357    /// Returns an error when the quality profile cannot size the project.
358    pub fn render_size(
359        &self,
360        project: &EngineProject,
361    ) -> Result<alpha2_render::RenderSize, EngineError> {
362        self.quality.render_size(&project.project().settings)
363    }
364
365    /// Refuses a snapshot other than the one this export was made for.
366    fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
367        if project.generation() == self.snapshot {
368            Ok(())
369        } else {
370            Err(EngineError::ExportProjectChanged)
371        }
372    }
373
374    /// Returns the pending frame timestamp, or `None` after completion.
375    #[must_use]
376    pub fn pending_time(&self) -> Option<MediaTime> {
377        (self.next_frame < self.end_frame)
378            .then(|| self.frame_rate.frame_start(self.next_frame))
379            .flatten()
380    }
381
382    /// Takes the pending frame as supplied from elsewhere (a copy baked by
383    /// an earlier export of the same snapshot) and advances the cursor
384    /// exactly as a render does. Returns its index and time, or `None` after
385    /// completion.
386    ///
387    /// # Errors
388    /// Returns [`EngineError::OutOfRange`] if the cursor would overflow.
389    pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
390        let Some(time) = self.pending_time() else {
391            return Ok(None);
392        };
393        let index = self.completed;
394        self.next_frame = self
395            .next_frame
396            .checked_add(1)
397            .ok_or(EngineError::OutOfRange)?;
398        self.completed += 1;
399        Ok(Some((index, time)))
400    }
401
402    /// The frame grid the export renders on.
403    #[must_use]
404    pub const fn frame_rate(&self) -> FrameRate {
405        self.frame_rate
406    }
407
408    /// The times of the pending frame and up to `frames - 1` after it, in
409    /// order, as [`Self::take_next`] and the renders after it will reach
410    /// them: fewer near the end of the range, none after completion. The
411    /// cursor is not changed, and `frames` is clamped as in
412    /// [`Self::prefetch_needs`].
413    ///
414    /// # Errors
415    /// Returns [`EngineError::OutOfRange`] if a time overflows.
416    pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
417        Ok(frame_times(
418            self.frame_rate,
419            self.next_frame,
420            frames.min(MAX_PREFETCH_FRAMES),
421            Some(self.end_frame),
422        )?
423        .into_iter()
424        .map(|(_, time)| time)
425        .collect())
426    }
427
428    /// Returns decode requirements for the pending frame and bounded lookahead.
429    ///
430    /// The exporter cursor is not changed. `frames` is the total number of
431    /// scheduled frames requested, including the current frame, and is clamped
432    /// to 120 to keep one request bounded.
433    ///
434    /// # Errors
435    /// Returns evaluation or timeline overflow errors, and
436    /// [`EngineError::ExportProjectChanged`] for another snapshot.
437    pub fn prefetch_needs(
438        &self,
439        project: &EngineProject,
440        frames: u32,
441    ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
442        self.check(project)?;
443        let count = frames.min(MAX_PREFETCH_FRAMES);
444        let times = frame_times(
445            self.frame_rate,
446            self.next_frame,
447            count,
448            Some(self.end_frame),
449        )?;
450        times
451            .into_iter()
452            .enumerate()
453            .map(|(offset, (_, time))| {
454                let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
455                let index = self
456                    .completed
457                    .checked_add(offset)
458                    .ok_or(EngineError::OutOfRange)?;
459                Ok(ExportFrameNeeds {
460                    index,
461                    time,
462                    needs: project.frame_needs(time)?,
463                })
464            })
465            .collect()
466    }
467
468    /// Attempts one frame without blocking on host decode.
469    ///
470    /// Waiting does not change progress. A successful render advances the
471    /// exact rational frame cursor once, so retries cannot skip timestamps.
472    ///
473    /// # Errors
474    /// Returns timeline evaluation, quality, or rendering failures, and
475    /// [`EngineError::ExportProjectChanged`] for another snapshot.
476    pub fn render_next(
477        &mut self,
478        project: &EngineProject,
479        renderer: &Renderer,
480        frames: &dyn FrameProvider,
481    ) -> Result<ExportStep, EngineError> {
482        self.render_next_measured(project, renderer, frames, None)
483    }
484
485    /// Attempts one frame and reports where the step spent its time.
486    ///
487    /// This renders exactly what [`Exporter::render_next`] renders. A step
488    /// that only waits leaves `timings` holding the stages that ran.
489    ///
490    /// # Errors
491    /// Returns timeline evaluation, quality, or rendering failures, and
492    /// [`EngineError::ExportProjectChanged`] for another snapshot.
493    pub fn render_next_timed(
494        &mut self,
495        project: &EngineProject,
496        renderer: &Renderer,
497        frames: &dyn FrameProvider,
498        timings: &mut ExportStepTimings,
499    ) -> Result<ExportStep, EngineError> {
500        self.render_next_measured(project, renderer, frames, Some(timings))
501    }
502
503    /// Renders the next frame straight into caller-owned NV12 plane textures.
504    ///
505    /// The readback-free counterpart of [`Self::render_next_timed`]: the
506    /// cursor advances identically, so an export can be driven through either
507    /// without its frame accounting changing. `Rendered` carries no readback
508    /// because the picture stays on the GPU.
509    ///
510    /// # Errors
511    /// Returns an engine error when evaluation or rendering fails, and
512    /// [`EngineError::ExportProjectChanged`] for another snapshot.
513    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
514    pub fn render_next_nv12_timed(
515        &mut self,
516        project: &EngineProject,
517        renderer: &Renderer,
518        frames: &dyn FrameProvider,
519        planes: alpha2_render::Nv12PlaneTargets<'_>,
520        timings: &mut ExportStepTimings,
521    ) -> Result<ExportPlaneStep, EngineError> {
522        self.check(project)?;
523        let Some(time) = self.pending_time() else {
524            return Ok(ExportPlaneStep::Complete);
525        };
526        let evaluate_started = Instant::now();
527        let frame = project.evaluate(time)?;
528        crate::frame::check_frame_effects(renderer, &frame)?;
529        let prepare_started = Instant::now();
530        let evaluate = prepare_started.duration_since(evaluate_started);
531        let size = self.quality.render_size(&project.project().settings)?;
532        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
533        renderer.set_quality_controls(
534            self.quality.max_filter_samples,
535            self.quality.motion_blur_subframes,
536        );
537        let prepare = prepare_started.elapsed();
538        let mut submit = FrameSubmitTimings::default();
539        let attempt = renderer.render_frame_nv12_timed(
540            size,
541            &frame,
542            frames,
543            planes,
544            self.encoding,
545            &mut submit,
546        )?;
547        *timings = ExportStepTimings {
548            evaluate,
549            prepare,
550            submit,
551        };
552        if !attempt.submitted {
553            return Ok(ExportPlaneStep::Waiting {
554                time,
555                missing_sources: attempt.report.missing_sources,
556            });
557        }
558        let index = self.completed;
559        self.next_frame = self
560            .next_frame
561            .checked_add(1)
562            .ok_or(EngineError::OutOfRange)?;
563        self.completed += 1;
564        Ok(ExportPlaneStep::Rendered { index, time })
565    }
566
567    /// Renders the next frame straight into caller-owned YUV420 planes, on
568    /// whichever renderer draws it.
569    ///
570    /// The cursor advances exactly as it does for [`Self::render_next`]; a
571    /// step that waits writes nothing. `timings`, when given, receives the
572    /// evaluation and preparation spans, and the whole draw as its submit
573    /// span.
574    ///
575    /// # Errors
576    /// Returns an engine error when evaluation or rendering fails, and
577    /// [`EngineError::ExportProjectChanged`] for another snapshot.
578    pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
579        &mut self,
580        project: &EngineProject,
581        renderer: &R,
582        frames: &dyn FrameProvider,
583        targets: &mut Yuv420pTargets<'_>,
584        timings: Option<&mut ExportStepTimings>,
585    ) -> Result<ExportPlaneStep, EngineError> {
586        self.check(project)?;
587        let Some(time) = self.pending_time() else {
588            return Ok(ExportPlaneStep::Complete);
589        };
590        let evaluate_started = Instant::now();
591        let frame = project.evaluate(time)?;
592        crate::frame::check_frame_effects(renderer, &frame)?;
593        let prepare_started = Instant::now();
594        let evaluate = prepare_started.duration_since(evaluate_started);
595        let size = self.quality.render_size(&project.project().settings)?;
596        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
597        renderer.set_quality_controls(
598            self.quality.max_filter_samples,
599            self.quality.motion_blur_subframes,
600        );
601        let draw_started = Instant::now();
602        let prepare = draw_started.duration_since(prepare_started);
603        let report =
604            renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
605        if let Some(timings) = timings {
606            *timings = ExportStepTimings {
607                evaluate,
608                prepare,
609                submit: FrameSubmitTimings {
610                    command_encode: draw_started.elapsed(),
611                    ..FrameSubmitTimings::default()
612                },
613            };
614        }
615        if !report.is_complete() {
616            return Ok(ExportPlaneStep::Waiting {
617                time,
618                missing_sources: report.missing_sources,
619            });
620        }
621        let index = self.completed;
622        self.next_frame = self
623            .next_frame
624            .checked_add(1)
625            .ok_or(EngineError::OutOfRange)?;
626        self.completed += 1;
627        Ok(ExportPlaneStep::Rendered { index, time })
628    }
629
630    /// Renders the next frame and hands its texture over, with no readback
631    /// and no pack: for a host that moves the picture on by other means, such
632    /// as presenting it to a canvas an encoder captures.
633    ///
634    /// The cursor advances exactly as it does for [`Self::render_next`].
635    ///
636    /// # Errors
637    /// Returns an engine error when evaluation or rendering fails, and
638    /// [`EngineError::ExportProjectChanged`] for another snapshot.
639    pub fn render_next_texture<R: crate::FrameRenderer + ?Sized>(
640        &mut self,
641        project: &EngineProject,
642        renderer: &R,
643        frames: &dyn FrameProvider,
644    ) -> Result<ExportTextureStep<R::Image>, EngineError> {
645        self.check(project)?;
646        let Some(time) = self.pending_time() else {
647            return Ok(ExportTextureStep::Complete);
648        };
649        let frame = project.evaluate(time)?;
650        crate::frame::check_frame_effects(renderer, &frame)?;
651        let size = self.quality.render_size(&project.project().settings)?;
652        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
653        renderer.set_quality_controls(
654            self.quality.max_filter_samples,
655            self.quality.motion_blur_subframes,
656        );
657        let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
658        let Some(texture) = render.texture else {
659            return Ok(ExportTextureStep::Waiting {
660                time,
661                missing_sources: render.report.missing_sources,
662            });
663        };
664        let index = self.completed;
665        self.next_frame = self
666            .next_frame
667            .checked_add(1)
668            .ok_or(EngineError::OutOfRange)?;
669        self.completed += 1;
670        Ok(ExportTextureStep::Rendered {
671            index,
672            time,
673            texture,
674        })
675    }
676
677    fn render_next_measured(
678        &mut self,
679        project: &EngineProject,
680        renderer: &Renderer,
681        frames: &dyn FrameProvider,
682        timings: Option<&mut ExportStepTimings>,
683    ) -> Result<ExportStep, EngineError> {
684        self.check(project)?;
685        let Some(time) = self.pending_time() else {
686            return Ok(ExportStep::Complete);
687        };
688        let evaluate_started = timings.as_ref().map(|_| Instant::now());
689        let frame = project.evaluate(time)?;
690        crate::frame::check_frame_effects(renderer, &frame)?;
691        let prepare_started = evaluate_started.map(|_| Instant::now());
692        let evaluate = evaluate_started
693            .zip(prepare_started)
694            .map_or(Duration::ZERO, |(started, ended)| {
695                ended.duration_since(started)
696            });
697        let size = self.quality.render_size(&project.project().settings)?;
698        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
699        renderer.set_quality_controls(
700            self.quality.max_filter_samples,
701            self.quality.motion_blur_subframes,
702        );
703        let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
704        let attempt = match timings {
705            Some(timings) => {
706                let mut submit = FrameSubmitTimings::default();
707                let attempt = renderer.render_frame_yuv420p_timed(
708                    size,
709                    &frame,
710                    frames,
711                    self.encoding,
712                    &mut submit,
713                )?;
714                *timings = ExportStepTimings {
715                    evaluate,
716                    prepare,
717                    submit,
718                };
719                attempt
720            }
721            None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
722        };
723        let Some(readback) = attempt.readback else {
724            return Ok(ExportStep::Waiting {
725                time,
726                missing_sources: attempt.report.missing_sources,
727            });
728        };
729        let index = self.completed;
730        self.next_frame = self
731            .next_frame
732            .checked_add(1)
733            .ok_or(EngineError::OutOfRange)?;
734        self.completed += 1;
735        Ok(ExportStep::Rendered {
736            index,
737            time,
738            readback,
739        })
740    }
741}
742
743/// The project-rate samples an export's audio covers.
744#[derive(Copy, Clone, Debug, Eq, PartialEq)]
745pub struct ExportAudioWindow {
746    /// First sample, at the project sample rate.
747    pub start_sample: i64,
748    /// Sample count.
749    pub frames: usize,
750}
751
752/// The samples an export over `range` writes as audio: both ends are the
753/// samples nearest the range's ends, half a sample rounding up.
754///
755/// Every host places export audio through this, from the export's
756/// [`Exporter::effective_range`], so the audio starts and ends within half a
757/// sample of the video's first frame and last frame end on every path that
758/// writes the file.
759///
760/// # Errors
761/// Returns [`EngineError::OutOfRange`] when the range does not fit in
762/// memory-addressable samples.
763pub fn export_audio_window(
764    range: TimeRange,
765    sample_rate: u32,
766) -> Result<ExportAudioWindow, EngineError> {
767    let nearest = |ticks: i64| {
768        let doubled =
769            i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
770        i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
771            .map_err(|_| EngineError::OutOfRange)
772    };
773    let start_sample = nearest(range.start().ticks())?;
774    let end = nearest(range.end().ticks())?;
775    let frames = end
776        .checked_sub(start_sample)
777        .and_then(|frames| usize::try_from(frames).ok())
778        .ok_or(EngineError::OutOfRange)?;
779    Ok(ExportAudioWindow {
780        start_sample,
781        frames,
782    })
783}
784
785fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
786    let floor = rate
787        .frame_at_or_before(time)
788        .ok_or(EngineError::OutOfRange)?;
789    let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
790    if floor_time < time {
791        floor.checked_add(1).ok_or(EngineError::OutOfRange)
792    } else {
793        Ok(floor)
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::Player;
801    use alpha2_model::{Project, ProjectId, ProjectSettings};
802
803    fn snapshot(width: u32, height: u32) -> EngineProject {
804        EngineProject::new(Project::new(
805            ProjectId::new("export".to_owned()).expect("id"),
806            "export",
807            ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
808                .expect("settings"),
809        ))
810        .expect("valid project")
811    }
812
813    #[test]
814    fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
815        let range =
816            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
817        let rate = FrameRate::new(30, 1).expect("valid rate");
818        for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
819            let error = Exporter::new(
820                &snapshot(size.0, size.1),
821                range,
822                rate,
823                QualityProfile::export(),
824                VideoCodec::H264,
825            )
826            .expect_err("odd output");
827            assert_eq!(
828                error,
829                EngineError::OddExportDimensions {
830                    width: size.0,
831                    height: size.1,
832                    suggested,
833                }
834            );
835            assert!(
836                error
837                    .to_string()
838                    .contains(&format!("{}x{}", suggested.0, suggested.1))
839            );
840        }
841        assert!(
842            Exporter::new(
843                &snapshot(64, 48),
844                range,
845                rate,
846                QualityProfile::export(),
847                VideoCodec::H264
848            )
849            .is_ok()
850        );
851    }
852
853    #[test]
854    fn runners_expose_distinct_missing_frame_policies() {
855        let rate = FrameRate::new(30, 1).expect("valid rate");
856        let range =
857            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
858        let exporter = Exporter::new(
859            &snapshot(16, 16),
860            range,
861            rate,
862            QualityProfile::export(),
863            VideoCodec::H264,
864        )
865        .expect("valid exporter");
866        assert_eq!(
867            Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
868            MissingFramePolicy::HoldPrevious
869        );
870        assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
871    }
872
873    #[test]
874    fn schedule_uses_exact_rational_frame_boundaries() {
875        let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
876        let start = rate.frame_start(10).expect("representable");
877        let end = rate.frame_start(20).expect("representable");
878        let exporter = Exporter::new(
879            &snapshot(16, 16),
880            TimeRange::new(start, end).expect("valid range"),
881            rate,
882            QualityProfile::export(),
883            VideoCodec::H264,
884        )
885        .expect("valid exporter");
886        assert_eq!(exporter.pending_time(), Some(start));
887        assert_eq!(
888            exporter.progress(),
889            ExportProgress {
890                completed: 0,
891                total: 10,
892            }
893        );
894    }
895
896    #[test]
897    fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
898        let rate = FrameRate::new(30, 1).expect("valid rate");
899        let subframe = Exporter::new(
900            &snapshot(16, 16),
901            TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
902            rate,
903            QualityProfile::export(),
904            VideoCodec::H264,
905        )
906        .expect("one intersecting frame");
907        assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
908        assert_eq!(subframe.progress().total, 1);
909
910        let unaligned = Exporter::new(
911            &snapshot(16, 16),
912            TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
913            rate,
914            QualityProfile::export(),
915            VideoCodec::H264,
916        )
917        .expect("two intersecting frames");
918        assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
919        assert_eq!(unaligned.progress().total, 2);
920        assert_eq!(
921            unaligned.effective_range(),
922            TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
923        );
924    }
925
926    #[test]
927    fn effective_range_is_the_request_widened_to_the_frame_grid() {
928        let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
929        let frame = |index| rate.frame_start(index).expect("representable").ticks();
930        for (start, end) in [
931            (frame(3), frame(9)),
932            (frame(3) + 1, frame(9) - 1),
933            (frame(4) - 1, frame(8) + 1),
934        ] {
935            let exporter = Exporter::new(
936                &snapshot(16, 16),
937                TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
938                rate,
939                QualityProfile::export(),
940                VideoCodec::H264,
941            )
942            .expect("valid exporter");
943            let effective = exporter.effective_range();
944            assert_eq!(
945                (effective.start(), effective.end()),
946                (MediaTime(frame(3)), MediaTime(frame(9))),
947                "{start}..{end}"
948            );
949            assert_eq!(exporter.pending_time(), Some(effective.start()));
950        }
951    }
952
953    #[test]
954    fn schedule_rejects_unbounded_frame_counts() {
955        let rate = FrameRate::new(30, 1).expect("valid rate");
956        let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
957        assert!(matches!(
958            Exporter::new(
959                &snapshot(16, 16),
960                range,
961                rate,
962                QualityProfile::export(),
963                VideoCodec::H264
964            ),
965            Err(EngineError::ExportTooLarge { .. })
966        ));
967    }
968
969    #[test]
970    fn prefetch_reports_future_indices_without_advancing_the_cursor() {
971        let rate = FrameRate::new(30, 1).expect("valid rate");
972        let project = Project::new(
973            ProjectId::new("prefetch").expect("project ID"),
974            "prefetch",
975            ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
976        );
977        let snapshot = EngineProject::new(project).expect("snapshot");
978        let range =
979            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
980        let exporter = Exporter::new(
981            &snapshot,
982            range,
983            rate,
984            QualityProfile::export(),
985            VideoCodec::H264,
986        )
987        .expect("valid exporter");
988
989        let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
990        assert_eq!(
991            prefetched
992                .iter()
993                .map(|frame| (frame.index, frame.time))
994                .collect::<Vec<_>>(),
995            vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
996        );
997        assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
998        assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
999        assert_eq!(exporter.progress().completed, 0);
1000    }
1001
1002    #[test]
1003    fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
1004        let rate = FrameRate::new(30, 1).expect("valid rate");
1005        let snapshot = snapshot(16, 16);
1006        // Three frames: 0, 1/30 s, and 2/30 s.
1007        let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
1008            .expect("non-empty range");
1009        let mut exporter = Exporter::new(
1010            &snapshot,
1011            range,
1012            rate,
1013            QualityProfile::export(),
1014            VideoCodec::H264,
1015        )
1016        .expect("valid exporter");
1017        let frame = MediaTime::from_ticks;
1018        assert_eq!(
1019            exporter.upcoming_times(2).expect("times"),
1020            vec![frame(0), frame(4_000)]
1021        );
1022        let (_, taken) = exporter.take_next().expect("take").expect("a frame");
1023        assert_eq!(taken, frame(0));
1024        // Fewer near the end; the cursor is not moved.
1025        assert_eq!(
1026            exporter.upcoming_times(5).expect("times"),
1027            vec![frame(4_000), frame(8_000)]
1028        );
1029        assert_eq!(exporter.pending_time(), Some(frame(4_000)));
1030        exporter.take_next().expect("take");
1031        exporter.take_next().expect("take");
1032        assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
1033    }
1034
1035    #[test]
1036    fn a_whole_project_export_covers_its_caption_cues() {
1037        let project = |clips: &str| {
1038            let json = format!(
1039                r#"{{"schema_version": 1, "id": "p", "name": "p",
1040                  "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
1041                  "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
1042                  "timeline": {{"tracks": [
1043                    {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
1044                    {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
1045                      {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
1046            );
1047            EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
1048        };
1049        let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
1050        for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
1051            let range = snapshot.timeline_range().expect("something is shown");
1052            assert_eq!(
1053                (range.start().ticks(), range.end().ticks()),
1054                (start, 1_440_000)
1055            );
1056            let exporter =
1057                Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
1058                    .expect("an export of the whole project");
1059            assert_eq!(exporter.effective_range(), range);
1060        }
1061    }
1062}