Skip to main content

alpha2_engine/
exporter.rs

1use alpha2_eval::FrameNeed;
2use alpha2_media::{PixelLayout, 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)]
53// Almost every step renders a frame, so boxing the readback would allocate once per frame.
54#[allow(clippy::large_enum_variant)]
55pub enum ExportStep {
56    /// Required decoded frames are not available; retry without advancing.
57    Waiting {
58        /// Timeline timestamp that remains pending.
59        time: MediaTime,
60        /// Sources the host must provide before retrying.
61        missing_sources: Vec<SourceId>,
62    },
63    /// One frame was rendered and the cursor advanced exactly once.
64    Rendered {
65        /// Zero-based position inside this export operation.
66        index: u64,
67        /// Exact rational-frame timestamp.
68        time: MediaTime,
69        /// Submitted planar YUV420 readback for the encoder.
70        readback: Yuv420pReadback,
71    },
72    /// Every scheduled frame has been produced.
73    Complete,
74}
75
76/// One step of an export that leaves its picture as a rendered texture.
77#[derive(Debug)]
78pub enum ExportTextureStep<I = alpha2_render::RenderedTexture> {
79    /// Required decoded frames are not available; retry without advancing.
80    Waiting {
81        /// Timeline timestamp that remains pending.
82        time: MediaTime,
83        /// Sources the host must provide before retrying.
84        missing_sources: Vec<SourceId>,
85    },
86    /// One frame was rendered and the cursor advanced exactly once.
87    Rendered {
88        /// Zero-based position inside this export operation.
89        index: u64,
90        /// Exact rational-frame timestamp.
91        time: MediaTime,
92        /// The frame at export quality: a submitted texture in the intermediate format on the GPU,
93        /// an image on the CPU.
94        texture: I,
95    },
96    /// Every scheduled frame has been produced.
97    Complete,
98}
99
100/// One step of an export that packs its picture straight into caller-owned 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/// Splits `frameSubmitMs` (evaluation, graph construction and submission) so a regression can
124/// be attributed.
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 budget.
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 renders.
151    snapshot: u64,
152    quality: QualityProfile,
153    encoding: YuvEncoding,
154    pixels: PixelLayout,
155    /// Whether frames draw the project background (ignored when the layout keeps alpha).
156    background: bool,
157    frame_rate: FrameRate,
158    effective_range: TimeRange,
159    next_frame: i64,
160    end_frame: i64,
161    completed: u64,
162    total: u64,
163}
164
165impl Exporter {
166    /// Creates an exporter for frame intervals intersecting a half-open timeline range.
167    ///
168    /// All refusals happen here, before a host opens an encoder: 4:2:0 output at `quality` must
169    /// have even dimensions, and frames are packed per [`YuvEncoding::export`] for `codec`.
170    /// Later steps refuse any other snapshot with [`EngineError::ExportProjectChanged`].
171    ///
172    /// # Errors
173    /// Returns an error for an empty range, invalid quality, timeline overflow,
174    /// or [`EngineError::OddExportDimensions`].
175    pub fn new(
176        snapshot: &EngineProject,
177        range: TimeRange,
178        frame_rate: FrameRate,
179        quality: QualityProfile,
180        codec: VideoCodec,
181    ) -> Result<Self, EngineError> {
182        quality.export_size(&snapshot.project().settings)?;
183        Self::over(
184            snapshot,
185            range,
186            frame_rate,
187            quality,
188            YuvEncoding::export(codec),
189        )
190    }
191
192    /// An exporter of the single `frame_rate` frame containing `time`, for hosts that take the
193    /// picture via [`Self::render_next_texture`] rather than 4:2:0, so odd sizes are allowed.
194    ///
195    /// # Errors
196    /// Returns an error for invalid quality or a time past the timeline.
197    pub fn still(
198        snapshot: &EngineProject,
199        time: MediaTime,
200        frame_rate: FrameRate,
201        quality: QualityProfile,
202    ) -> Result<Self, EngineError> {
203        quality.render_size(&snapshot.project().settings)?;
204        let range = TimeRange::new(
205            time,
206            MediaTime::from_ticks(time.ticks().checked_add(1).ok_or(EngineError::OutOfRange)?),
207        )
208        .map_err(|_| EngineError::OutOfRange)?;
209        Self::over(
210            snapshot,
211            range,
212            frame_rate,
213            quality,
214            YuvEncoding::export(VideoCodec::H264),
215        )
216    }
217
218    fn over(
219        snapshot: &EngineProject,
220        range: TimeRange,
221        frame_rate: FrameRate,
222        quality: QualityProfile,
223        encoding: YuvEncoding,
224    ) -> Result<Self, EngineError> {
225        if range.is_empty() {
226            return Err(EngineError::EmptyExportRange);
227        }
228        let next_frame = frame_rate
229            .frame_at_or_before(range.start())
230            .ok_or(EngineError::OutOfRange)?;
231        let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
232        let count = end_frame
233            .checked_sub(next_frame)
234            .ok_or(EngineError::OutOfRange)?;
235        let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
236        if total == 0 {
237            return Err(EngineError::EmptyExportRange);
238        }
239        if total > MAX_EXPORT_FRAMES {
240            return Err(EngineError::ExportTooLarge {
241                frames: total,
242                maximum: MAX_EXPORT_FRAMES,
243            });
244        }
245        let effective_range = TimeRange::new(
246            frame_rate
247                .frame_start(next_frame)
248                .ok_or(EngineError::OutOfRange)?,
249            frame_rate
250                .frame_start(end_frame)
251                .ok_or(EngineError::OutOfRange)?,
252        )
253        .map_err(|_| EngineError::OutOfRange)?;
254        Ok(Self {
255            snapshot: snapshot.generation(),
256            quality,
257            encoding,
258            pixels: PixelLayout::YUV420P,
259            background: true,
260            frame_rate,
261            effective_range,
262            next_frame,
263            end_frame,
264            completed: 0,
265            total,
266        })
267    }
268
269    /// How this export's frames are packed into 4:2:0 YUV.
270    #[must_use]
271    pub const fn encoding(&self) -> YuvEncoding {
272        self.encoding
273    }
274
275    /// This exporter writing `pixels` ([`alpha2_media::resolve_pixels`]) instead of 8-bit 4:2:0.
276    /// A layout with alpha drops the project background. The even-size rule of [`Self::new`]
277    /// still applies, 4:4:4 included.
278    #[must_use]
279    pub const fn with_pixels(mut self, pixels: PixelLayout) -> Self {
280        self.pixels = pixels;
281        self
282    }
283
284    /// The layout this export's frames are packed in.
285    #[must_use]
286    pub const fn pixels(&self) -> PixelLayout {
287        self.pixels
288    }
289
290    /// This exporter with the project background drawn or, if `drawn` is false, replaced by
291    /// transparent black (for example a still taken via [`Self::render_next_texture`]).
292    #[must_use]
293    pub const fn with_background(mut self, drawn: bool) -> Self {
294        self.background = drawn;
295        self
296    }
297
298    fn evaluate<'a>(
299        &self,
300        project: &'a EngineProject,
301        time: MediaTime,
302    ) -> Result<alpha2_eval::FrameDesc<'a>, EngineError> {
303        let mut frame = project.evaluate(time)?;
304        if self.pixels.alpha || !self.background {
305            frame.background = None;
306        }
307        Ok(frame)
308    }
309
310    /// The timeline range this export covers: the requested range widened to
311    /// the frame grid, `[floor(start), ceil(end))`.
312    ///
313    /// The first frame may start up to one frame before the request. Its start is the output's
314    /// zero, so hosts place audio and report the export from this range.
315    #[must_use]
316    pub const fn effective_range(&self) -> TimeRange {
317        self.effective_range
318    }
319
320    /// The [`export_audio_window`] of [`Self::effective_range`] at `sample_rate`.
321    ///
322    /// # Errors
323    /// Returns [`EngineError::OutOfRange`] when the range does not fit in
324    /// memory-addressable samples.
325    pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
326        export_audio_window(self.effective_range, sample_rate)
327    }
328
329    /// Creates an exporter over [`EngineProject::timeline_range`], packed for `codec`.
330    ///
331    /// # Errors
332    /// Returns an error for an empty project or invalid schedule.
333    pub fn for_project(
334        project: &EngineProject,
335        quality: QualityProfile,
336        codec: VideoCodec,
337    ) -> Result<Self, EngineError> {
338        let range = project
339            .timeline_range()
340            .ok_or(EngineError::EmptyExportRange)?;
341        Self::new(
342            project,
343            range,
344            project.project().settings.frame_rate,
345            quality,
346            codec,
347        )
348    }
349
350    /// The only policy an export can apply.
351    ///
352    /// Export output must not depend on decode timing: pipelined and synchronous exports are
353    /// byte-identical. Repeating a late frame would break that, so this is a constant.
354    /// [`Player`] can choose differently because real-time playback has no such guarantee.
355    ///
356    /// [`Player`]: crate::Player
357    pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
358
359    /// Returns the policy used when source frames are unavailable.
360    #[must_use]
361    pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
362        Self::MISSING_FRAME_POLICY
363    }
364
365    /// Returns current deterministic export progress.
366    #[must_use]
367    pub const fn progress(&self) -> ExportProgress {
368        ExportProgress {
369            completed: self.completed,
370            total: self.total,
371        }
372    }
373
374    /// The size frames of `project` render at, for preparing captions.
375    ///
376    /// # Errors
377    /// Returns an error when the quality profile cannot size the project.
378    pub fn render_size(
379        &self,
380        project: &EngineProject,
381    ) -> Result<alpha2_render::RenderSize, EngineError> {
382        self.quality.render_size(&project.project().settings)
383    }
384
385    fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
386        if project.generation() == self.snapshot {
387            Ok(())
388        } else {
389            Err(EngineError::ExportProjectChanged)
390        }
391    }
392
393    /// Returns the pending frame timestamp, or `None` after completion.
394    #[must_use]
395    pub fn pending_time(&self) -> Option<MediaTime> {
396        (self.next_frame < self.end_frame)
397            .then(|| self.frame_rate.frame_start(self.next_frame))
398            .flatten()
399    }
400
401    /// Skips rendering the pending frame (supplied elsewhere, such as an earlier bake) and
402    /// advances the cursor as a render would. Returns its index and time, or `None` when done.
403    ///
404    /// # Errors
405    /// Returns [`EngineError::OutOfRange`] if the cursor would overflow.
406    pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
407        let Some(time) = self.pending_time() else {
408            return Ok(None);
409        };
410        let index = self.completed;
411        self.next_frame = self
412            .next_frame
413            .checked_add(1)
414            .ok_or(EngineError::OutOfRange)?;
415        self.completed += 1;
416        Ok(Some((index, time)))
417    }
418
419    /// The frame grid the export renders on.
420    #[must_use]
421    pub const fn frame_rate(&self) -> FrameRate {
422        self.frame_rate
423    }
424
425    /// The times of the pending frame and up to `frames - 1` after it, without moving the
426    /// cursor. `frames` is clamped as in [`Self::prefetch_needs`].
427    ///
428    /// # Errors
429    /// Returns [`EngineError::OutOfRange`] if a time overflows.
430    pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
431        Ok(frame_times(
432            self.frame_rate,
433            self.next_frame,
434            frames.min(MAX_PREFETCH_FRAMES),
435            Some(self.end_frame),
436        )?
437        .into_iter()
438        .map(|(_, time)| time)
439        .collect())
440    }
441
442    /// Returns decode requirements for the pending frame and bounded lookahead.
443    ///
444    /// The cursor is not changed. `frames` counts the current frame and is clamped to 120.
445    ///
446    /// # Errors
447    /// Returns evaluation or timeline overflow errors, and
448    /// [`EngineError::ExportProjectChanged`] for another snapshot.
449    pub fn prefetch_needs(
450        &self,
451        project: &EngineProject,
452        frames: u32,
453    ) -> Result<Vec<ExportFrameNeeds>, EngineError> {
454        self.check(project)?;
455        let count = frames.min(MAX_PREFETCH_FRAMES);
456        let times = frame_times(
457            self.frame_rate,
458            self.next_frame,
459            count,
460            Some(self.end_frame),
461        )?;
462        times
463            .into_iter()
464            .enumerate()
465            .map(|(offset, (_, time))| {
466                let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
467                let index = self
468                    .completed
469                    .checked_add(offset)
470                    .ok_or(EngineError::OutOfRange)?;
471                Ok(ExportFrameNeeds {
472                    index,
473                    time,
474                    needs: project.frame_needs(time)?,
475                })
476            })
477            .collect()
478    }
479
480    /// Attempts one frame without blocking on host decode.
481    ///
482    /// Waiting does not change progress. A successful render advances the
483    /// exact rational frame cursor once, so retries cannot skip timestamps.
484    ///
485    /// # Errors
486    /// Returns timeline evaluation, quality, or rendering failures, and
487    /// [`EngineError::ExportProjectChanged`] for another snapshot.
488    pub fn render_next(
489        &mut self,
490        project: &EngineProject,
491        renderer: &Renderer,
492        frames: &dyn FrameProvider,
493    ) -> Result<ExportStep, EngineError> {
494        self.render_next_measured(project, renderer, frames, None)
495    }
496
497    /// Attempts one frame and reports where the step spent its time.
498    ///
499    /// Renders exactly what [`Exporter::render_next`] does. A waiting step records only the
500    /// stages that ran.
501    ///
502    /// # Errors
503    /// Returns timeline evaluation, quality, or rendering failures, and
504    /// [`EngineError::ExportProjectChanged`] for another snapshot.
505    pub fn render_next_timed(
506        &mut self,
507        project: &EngineProject,
508        renderer: &Renderer,
509        frames: &dyn FrameProvider,
510        timings: &mut ExportStepTimings,
511    ) -> Result<ExportStep, EngineError> {
512        self.render_next_measured(project, renderer, frames, Some(timings))
513    }
514
515    /// Renders the next frame straight into caller-owned NV12 plane textures.
516    ///
517    /// Readback-free counterpart of [`Self::render_next_timed`] with identical cursor
518    /// accounting.
519    ///
520    /// # Errors
521    /// Returns an engine error when evaluation or rendering fails, and
522    /// [`EngineError::ExportProjectChanged`] for another snapshot.
523    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
524    pub fn render_next_nv12_timed(
525        &mut self,
526        project: &EngineProject,
527        renderer: &Renderer,
528        frames: &dyn FrameProvider,
529        planes: alpha2_render::Nv12PlaneTargets<'_>,
530        timings: &mut ExportStepTimings,
531    ) -> Result<ExportPlaneStep, EngineError> {
532        self.check(project)?;
533        let Some(time) = self.pending_time() else {
534            return Ok(ExportPlaneStep::Complete);
535        };
536        let evaluate_started = Instant::now();
537        let frame = self.evaluate(project, time)?;
538        crate::frame::check_frame_effects(renderer, &frame)?;
539        let prepare_started = Instant::now();
540        let evaluate = prepare_started.duration_since(evaluate_started);
541        let size = self.quality.render_size(&project.project().settings)?;
542        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
543        renderer.set_quality_controls(
544            self.quality.max_filter_samples,
545            self.quality.motion_blur_subframes,
546        );
547        let prepare = prepare_started.elapsed();
548        let mut submit = FrameSubmitTimings::default();
549        let attempt = renderer.render_frame_nv12_timed(
550            size,
551            &frame,
552            frames,
553            planes,
554            self.encoding,
555            &mut submit,
556        )?;
557        *timings = ExportStepTimings {
558            evaluate,
559            prepare,
560            submit,
561        };
562        if !attempt.submitted {
563            return Ok(ExportPlaneStep::Waiting {
564                time,
565                missing_sources: attempt.report.missing_sources,
566            });
567        }
568        let index = self.completed;
569        self.next_frame = self
570            .next_frame
571            .checked_add(1)
572            .ok_or(EngineError::OutOfRange)?;
573        self.completed += 1;
574        Ok(ExportPlaneStep::Rendered { index, time })
575    }
576
577    /// Renders the next frame straight into caller-owned YUV420 planes on either renderer.
578    ///
579    /// The cursor advances as for [`Self::render_next`]; a waiting step writes nothing.
580    /// `timings` records the whole draw as its submit span.
581    ///
582    /// # Errors
583    /// Returns an engine error when evaluation or rendering fails, and
584    /// [`EngineError::ExportProjectChanged`] for another snapshot.
585    pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
586        &mut self,
587        project: &EngineProject,
588        renderer: &R,
589        frames: &dyn FrameProvider,
590        targets: &mut Yuv420pTargets<'_>,
591        timings: Option<&mut ExportStepTimings>,
592    ) -> Result<ExportPlaneStep, EngineError> {
593        self.check(project)?;
594        let Some(time) = self.pending_time() else {
595            return Ok(ExportPlaneStep::Complete);
596        };
597        let evaluate_started = Instant::now();
598        let frame = self.evaluate(project, time)?;
599        crate::frame::check_frame_effects(renderer, &frame)?;
600        let prepare_started = Instant::now();
601        let evaluate = prepare_started.duration_since(evaluate_started);
602        let size = self.quality.render_size(&project.project().settings)?;
603        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
604        renderer.set_quality_controls(
605            self.quality.max_filter_samples,
606            self.quality.motion_blur_subframes,
607        );
608        let draw_started = Instant::now();
609        let prepare = draw_started.duration_since(prepare_started);
610        let report =
611            renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
612        if let Some(timings) = timings {
613            *timings = ExportStepTimings {
614                evaluate,
615                prepare,
616                submit: FrameSubmitTimings {
617                    command_encode: draw_started.elapsed(),
618                    ..FrameSubmitTimings::default()
619                },
620            };
621        }
622        if !report.is_complete() {
623            return Ok(ExportPlaneStep::Waiting {
624                time,
625                missing_sources: report.missing_sources,
626            });
627        }
628        let index = self.completed;
629        self.next_frame = self
630            .next_frame
631            .checked_add(1)
632            .ok_or(EngineError::OutOfRange)?;
633        self.completed += 1;
634        Ok(ExportPlaneStep::Rendered { index, time })
635    }
636
637    /// [`Self::render_next_into_yuv420p`] for this export's layout ([`Self::pixels`]), one buffer
638    /// per plane in [`PixelLayout::planes`] order.
639    ///
640    /// # Errors
641    /// As [`Self::render_next_into_yuv420p`].
642    pub fn render_next_into_planes<R: FrameRenderer + ?Sized>(
643        &mut self,
644        project: &EngineProject,
645        renderer: &R,
646        frames: &dyn FrameProvider,
647        targets: &mut [alpha2_render::PlaneTarget<'_>],
648        timings: Option<&mut ExportStepTimings>,
649    ) -> Result<ExportPlaneStep, EngineError> {
650        if self.pixels.is_yuv420p() {
651            let [y, u, v] = targets else {
652                return Err(EngineError::Render(
653                    alpha2_render::RenderError::TextureReadback(
654                        alpha2_render::ReadbackFailure::Layout,
655                    ),
656                ));
657            };
658            let mut planes = Yuv420pTargets {
659                y: y.reborrow(),
660                u: u.reborrow(),
661                v: v.reborrow(),
662            };
663            return self.render_next_into_yuv420p(project, renderer, frames, &mut planes, timings);
664        }
665        self.check(project)?;
666        let Some(time) = self.pending_time() else {
667            return Ok(ExportPlaneStep::Complete);
668        };
669        let evaluate_started = Instant::now();
670        let frame = self.evaluate(project, time)?;
671        crate::frame::check_frame_effects(renderer, &frame)?;
672        let prepare_started = Instant::now();
673        let evaluate = prepare_started.duration_since(evaluate_started);
674        let size = self.quality.render_size(&project.project().settings)?;
675        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
676        renderer.set_quality_controls(
677            self.quality.max_filter_samples,
678            self.quality.motion_blur_subframes,
679        );
680        let draw_started = Instant::now();
681        let prepare = draw_started.duration_since(prepare_started);
682        let report = renderer.render_frame_into_planes(
683            size,
684            &frame,
685            frames,
686            self.encoding,
687            self.pixels,
688            targets,
689        )?;
690        if let Some(timings) = timings {
691            *timings = ExportStepTimings {
692                evaluate,
693                prepare,
694                submit: FrameSubmitTimings {
695                    command_encode: draw_started.elapsed(),
696                    ..FrameSubmitTimings::default()
697                },
698            };
699        }
700        if !report.is_complete() {
701            return Ok(ExportPlaneStep::Waiting {
702                time,
703                missing_sources: report.missing_sources,
704            });
705        }
706        let index = self.completed;
707        self.next_frame = self
708            .next_frame
709            .checked_add(1)
710            .ok_or(EngineError::OutOfRange)?;
711        self.completed += 1;
712        Ok(ExportPlaneStep::Rendered { index, time })
713    }
714
715    /// Renders the next frame and hands over its texture without readback or packing.
716    ///
717    /// The cursor advances exactly as it does for [`Self::render_next`].
718    ///
719    /// # Errors
720    /// Returns an engine error when evaluation or rendering fails, and
721    /// [`EngineError::ExportProjectChanged`] for another snapshot.
722    pub fn render_next_texture<R: crate::FrameRenderer + ?Sized>(
723        &mut self,
724        project: &EngineProject,
725        renderer: &R,
726        frames: &dyn FrameProvider,
727    ) -> Result<ExportTextureStep<R::Image>, EngineError> {
728        self.check(project)?;
729        let Some(time) = self.pending_time() else {
730            return Ok(ExportTextureStep::Complete);
731        };
732        let frame = self.evaluate(project, time)?;
733        crate::frame::check_frame_effects(renderer, &frame)?;
734        let size = self.quality.render_size(&project.project().settings)?;
735        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
736        renderer.set_quality_controls(
737            self.quality.max_filter_samples,
738            self.quality.motion_blur_subframes,
739        );
740        let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
741        let Some(texture) = render.texture else {
742            return Ok(ExportTextureStep::Waiting {
743                time,
744                missing_sources: render.report.missing_sources,
745            });
746        };
747        let index = self.completed;
748        self.next_frame = self
749            .next_frame
750            .checked_add(1)
751            .ok_or(EngineError::OutOfRange)?;
752        self.completed += 1;
753        Ok(ExportTextureStep::Rendered {
754            index,
755            time,
756            texture,
757        })
758    }
759
760    fn render_next_measured(
761        &mut self,
762        project: &EngineProject,
763        renderer: &Renderer,
764        frames: &dyn FrameProvider,
765        timings: Option<&mut ExportStepTimings>,
766    ) -> Result<ExportStep, EngineError> {
767        self.check(project)?;
768        let Some(time) = self.pending_time() else {
769            return Ok(ExportStep::Complete);
770        };
771        let evaluate_started = timings.as_ref().map(|_| Instant::now());
772        let frame = self.evaluate(project, time)?;
773        crate::frame::check_frame_effects(renderer, &frame)?;
774        let prepare_started = evaluate_started.map(|_| Instant::now());
775        let evaluate = evaluate_started
776            .zip(prepare_started)
777            .map_or(Duration::ZERO, |(started, ended)| {
778                ended.duration_since(started)
779            });
780        let size = self.quality.render_size(&project.project().settings)?;
781        renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
782        renderer.set_quality_controls(
783            self.quality.max_filter_samples,
784            self.quality.motion_blur_subframes,
785        );
786        let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
787        if !self.pixels.is_yuv420p() {
788            // Read back as stored and pack on the CPU once the readback finishes.
789            let submit_started = Instant::now();
790            let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
791            let Some(texture) = render.texture else {
792                return Ok(ExportStep::Waiting {
793                    time,
794                    missing_sources: render.report.missing_sources,
795                });
796            };
797            let readback = renderer.begin_layout_readback(&texture, self.encoding, self.pixels)?;
798            if let Some(timings) = timings {
799                *timings = ExportStepTimings {
800                    evaluate,
801                    prepare,
802                    submit: FrameSubmitTimings {
803                        command_encode: submit_started.elapsed(),
804                        ..FrameSubmitTimings::default()
805                    },
806                };
807            }
808            return self.rendered(time, readback);
809        }
810        let attempt = match timings {
811            Some(timings) => {
812                let mut submit = FrameSubmitTimings::default();
813                let attempt = renderer.render_frame_yuv420p_timed(
814                    size,
815                    &frame,
816                    frames,
817                    self.encoding,
818                    &mut submit,
819                )?;
820                *timings = ExportStepTimings {
821                    evaluate,
822                    prepare,
823                    submit,
824                };
825                attempt
826            }
827            None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
828        };
829        let Some(readback) = attempt.readback else {
830            return Ok(ExportStep::Waiting {
831                time,
832                missing_sources: attempt.report.missing_sources,
833            });
834        };
835        self.rendered(time, readback)
836    }
837
838    /// Advances past the frame at `time`, whose `readback` was submitted.
839    fn rendered(
840        &mut self,
841        time: MediaTime,
842        readback: alpha2_render::Yuv420pReadback,
843    ) -> Result<ExportStep, EngineError> {
844        let index = self.completed;
845        self.next_frame = self
846            .next_frame
847            .checked_add(1)
848            .ok_or(EngineError::OutOfRange)?;
849        self.completed += 1;
850        Ok(ExportStep::Rendered {
851            index,
852            time,
853            readback,
854        })
855    }
856}
857
858/// The project-rate samples an export's audio covers.
859#[derive(Copy, Clone, Debug, Eq, PartialEq)]
860pub struct ExportAudioWindow {
861    /// First sample, at the project sample rate.
862    pub start_sample: i64,
863    /// Sample count.
864    pub frames: usize,
865}
866
867/// The audio samples an export over `range` writes: each end is the nearest sample, halves
868/// rounding up.
869///
870/// Every host places export audio through this from [`Exporter::effective_range`], so audio
871/// stays within half a sample of the video on every output path.
872///
873/// # Errors
874/// Returns [`EngineError::OutOfRange`] when the range does not fit in
875/// memory-addressable samples.
876pub fn export_audio_window(
877    range: TimeRange,
878    sample_rate: u32,
879) -> Result<ExportAudioWindow, EngineError> {
880    let nearest = |ticks: i64| {
881        let doubled =
882            i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
883        i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
884            .map_err(|_| EngineError::OutOfRange)
885    };
886    let start_sample = nearest(range.start().ticks())?;
887    let end = nearest(range.end().ticks())?;
888    let frames = end
889        .checked_sub(start_sample)
890        .and_then(|frames| usize::try_from(frames).ok())
891        .ok_or(EngineError::OutOfRange)?;
892    Ok(ExportAudioWindow {
893        start_sample,
894        frames,
895    })
896}
897
898fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
899    let floor = rate
900        .frame_at_or_before(time)
901        .ok_or(EngineError::OutOfRange)?;
902    let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
903    if floor_time < time {
904        floor.checked_add(1).ok_or(EngineError::OutOfRange)
905    } else {
906        Ok(floor)
907    }
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913    use crate::Player;
914    use alpha2_model::{Project, ProjectId, ProjectSettings};
915
916    fn snapshot(width: u32, height: u32) -> EngineProject {
917        EngineProject::new(Project::new(
918            ProjectId::new("export".to_owned()).expect("id"),
919            "export",
920            ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
921                .expect("settings"),
922        ))
923        .expect("valid project")
924    }
925
926    #[test]
927    fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
928        let range =
929            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
930        let rate = FrameRate::new(30, 1).expect("valid rate");
931        for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
932            let error = Exporter::new(
933                &snapshot(size.0, size.1),
934                range,
935                rate,
936                QualityProfile::export(),
937                VideoCodec::H264,
938            )
939            .expect_err("odd output");
940            assert_eq!(
941                error,
942                EngineError::OddExportDimensions {
943                    width: size.0,
944                    height: size.1,
945                    suggested,
946                }
947            );
948            assert!(
949                error
950                    .to_string()
951                    .contains(&format!("{}x{}", suggested.0, suggested.1))
952            );
953        }
954        assert!(
955            Exporter::new(
956                &snapshot(64, 48),
957                range,
958                rate,
959                QualityProfile::export(),
960                VideoCodec::H264
961            )
962            .is_ok()
963        );
964    }
965
966    #[test]
967    fn runners_expose_distinct_missing_frame_policies() {
968        let rate = FrameRate::new(30, 1).expect("valid rate");
969        let range =
970            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
971        let exporter = Exporter::new(
972            &snapshot(16, 16),
973            range,
974            rate,
975            QualityProfile::export(),
976            VideoCodec::H264,
977        )
978        .expect("valid exporter");
979        assert_eq!(
980            Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
981            MissingFramePolicy::HoldPrevious
982        );
983        assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
984    }
985
986    #[test]
987    fn schedule_uses_exact_rational_frame_boundaries() {
988        let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
989        let start = rate.frame_start(10).expect("representable");
990        let end = rate.frame_start(20).expect("representable");
991        let exporter = Exporter::new(
992            &snapshot(16, 16),
993            TimeRange::new(start, end).expect("valid range"),
994            rate,
995            QualityProfile::export(),
996            VideoCodec::H264,
997        )
998        .expect("valid exporter");
999        assert_eq!(exporter.pending_time(), Some(start));
1000        assert_eq!(
1001            exporter.progress(),
1002            ExportProgress {
1003                completed: 0,
1004                total: 10,
1005            }
1006        );
1007    }
1008
1009    #[test]
1010    fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
1011        let rate = FrameRate::new(30, 1).expect("valid rate");
1012        let subframe = Exporter::new(
1013            &snapshot(16, 16),
1014            TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
1015            rate,
1016            QualityProfile::export(),
1017            VideoCodec::H264,
1018        )
1019        .expect("one intersecting frame");
1020        assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
1021        assert_eq!(subframe.progress().total, 1);
1022
1023        let unaligned = Exporter::new(
1024            &snapshot(16, 16),
1025            TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
1026            rate,
1027            QualityProfile::export(),
1028            VideoCodec::H264,
1029        )
1030        .expect("two intersecting frames");
1031        assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
1032        assert_eq!(unaligned.progress().total, 2);
1033        assert_eq!(
1034            unaligned.effective_range(),
1035            TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
1036        );
1037    }
1038
1039    #[test]
1040    fn effective_range_is_the_request_widened_to_the_frame_grid() {
1041        let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
1042        let frame = |index| rate.frame_start(index).expect("representable").ticks();
1043        for (start, end) in [
1044            (frame(3), frame(9)),
1045            (frame(3) + 1, frame(9) - 1),
1046            (frame(4) - 1, frame(8) + 1),
1047        ] {
1048            let exporter = Exporter::new(
1049                &snapshot(16, 16),
1050                TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
1051                rate,
1052                QualityProfile::export(),
1053                VideoCodec::H264,
1054            )
1055            .expect("valid exporter");
1056            let effective = exporter.effective_range();
1057            assert_eq!(
1058                (effective.start(), effective.end()),
1059                (MediaTime(frame(3)), MediaTime(frame(9))),
1060                "{start}..{end}"
1061            );
1062            assert_eq!(exporter.pending_time(), Some(effective.start()));
1063        }
1064    }
1065
1066    #[test]
1067    fn schedule_rejects_unbounded_frame_counts() {
1068        let rate = FrameRate::new(30, 1).expect("valid rate");
1069        let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
1070        assert!(matches!(
1071            Exporter::new(
1072                &snapshot(16, 16),
1073                range,
1074                rate,
1075                QualityProfile::export(),
1076                VideoCodec::H264
1077            ),
1078            Err(EngineError::ExportTooLarge { .. })
1079        ));
1080    }
1081
1082    #[test]
1083    fn prefetch_reports_future_indices_without_advancing_the_cursor() {
1084        let rate = FrameRate::new(30, 1).expect("valid rate");
1085        let project = Project::new(
1086            ProjectId::new("prefetch").expect("project ID"),
1087            "prefetch",
1088            ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
1089        );
1090        let snapshot = EngineProject::new(project).expect("snapshot");
1091        let range =
1092            TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
1093        let exporter = Exporter::new(
1094            &snapshot,
1095            range,
1096            rate,
1097            QualityProfile::export(),
1098            VideoCodec::H264,
1099        )
1100        .expect("valid exporter");
1101
1102        let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
1103        assert_eq!(
1104            prefetched
1105                .iter()
1106                .map(|frame| (frame.index, frame.time))
1107                .collect::<Vec<_>>(),
1108            vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
1109        );
1110        assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
1111        assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
1112        assert_eq!(exporter.progress().completed, 0);
1113    }
1114
1115    #[test]
1116    fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
1117        let rate = FrameRate::new(30, 1).expect("valid rate");
1118        let snapshot = snapshot(16, 16);
1119        // Three frames: 0, 1/30 s, and 2/30 s.
1120        let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
1121            .expect("non-empty range");
1122        let mut exporter = Exporter::new(
1123            &snapshot,
1124            range,
1125            rate,
1126            QualityProfile::export(),
1127            VideoCodec::H264,
1128        )
1129        .expect("valid exporter");
1130        let frame = MediaTime::from_ticks;
1131        assert_eq!(
1132            exporter.upcoming_times(2).expect("times"),
1133            vec![frame(0), frame(4_000)]
1134        );
1135        let (_, taken) = exporter.take_next().expect("take").expect("a frame");
1136        assert_eq!(taken, frame(0));
1137        // Fewer near the end; the cursor is not moved.
1138        assert_eq!(
1139            exporter.upcoming_times(5).expect("times"),
1140            vec![frame(4_000), frame(8_000)]
1141        );
1142        assert_eq!(exporter.pending_time(), Some(frame(4_000)));
1143        exporter.take_next().expect("take");
1144        exporter.take_next().expect("take");
1145        assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
1146    }
1147
1148    #[test]
1149    fn a_whole_project_export_covers_its_caption_cues() {
1150        let project = |clips: &str| {
1151            let json = format!(
1152                r#"{{"schema_version": 1, "id": "p", "name": "p",
1153                  "settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
1154                  "sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
1155                  "timeline": {{"tracks": [
1156                    {{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
1157                    {{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
1158                      {{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
1159            );
1160            EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
1161        };
1162        let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
1163        for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
1164            let range = snapshot.timeline_range().expect("something is shown");
1165            assert_eq!(
1166                (range.start().ticks(), range.end().ticks()),
1167                (start, 1_440_000)
1168            );
1169            let exporter =
1170                Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
1171                    .expect("an export of the whole project");
1172            assert_eq!(exporter.effective_range(), range);
1173        }
1174    }
1175}