use alpha2_eval::FrameNeed;
use alpha2_media::{PixelLayout, SourceId, VideoCodec, YuvEncoding};
use alpha2_render::{FrameSubmitTimings, Renderer, Yuv420pReadback, Yuv420pTargets};
use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
use std::time::Duration;
use web_time::Instant;
use crate::schedule::frame_times;
use crate::{
EngineError, EngineProject, FrameProvider, FrameRenderer, GpuFrameRenderer, MissingFramePolicy,
QualityProfile,
};
pub const MAX_EXPORT_FRAMES: u64 = 10_000_000;
const MAX_PREFETCH_FRAMES: u32 = 120;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExportFrameNeeds {
pub index: u64,
pub time: MediaTime,
pub needs: Vec<FrameNeed>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ExportProgress {
pub completed: u64,
pub total: u64,
}
impl ExportProgress {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn fraction(self) -> f64 {
if self.total == 0 {
1.0
} else {
self.completed as f64 / self.total as f64
}
}
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum ExportStep {
Waiting {
time: MediaTime,
missing_sources: Vec<SourceId>,
},
Rendered {
index: u64,
time: MediaTime,
readback: Yuv420pReadback,
},
Complete,
}
#[derive(Debug)]
pub enum ExportTextureStep<I = alpha2_render::RenderedTexture> {
Waiting {
time: MediaTime,
missing_sources: Vec<SourceId>,
},
Rendered {
index: u64,
time: MediaTime,
texture: I,
},
Complete,
}
#[derive(Debug)]
pub enum ExportPlaneStep {
Waiting {
time: MediaTime,
missing_sources: Vec<SourceId>,
},
Rendered {
index: u64,
time: MediaTime,
},
Complete,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct ExportStepTimings {
pub evaluate: Duration,
pub prepare: Duration,
pub submit: FrameSubmitTimings,
}
impl ExportStepTimings {
#[must_use]
pub fn merged(self, other: Self) -> Self {
Self {
evaluate: self.evaluate.saturating_add(other.evaluate),
prepare: self.prepare.saturating_add(other.prepare),
submit: self.submit.merged(other.submit),
}
}
}
#[derive(Clone, Debug)]
pub struct Exporter {
snapshot: u64,
quality: QualityProfile,
encoding: YuvEncoding,
pixels: PixelLayout,
background: bool,
frame_rate: FrameRate,
effective_range: TimeRange,
next_frame: i64,
end_frame: i64,
completed: u64,
total: u64,
}
impl Exporter {
pub fn new(
snapshot: &EngineProject,
range: TimeRange,
frame_rate: FrameRate,
quality: QualityProfile,
codec: VideoCodec,
) -> Result<Self, EngineError> {
quality.export_size(&snapshot.project().settings)?;
Self::over(
snapshot,
range,
frame_rate,
quality,
YuvEncoding::export(codec),
)
}
pub fn still(
snapshot: &EngineProject,
time: MediaTime,
frame_rate: FrameRate,
quality: QualityProfile,
) -> Result<Self, EngineError> {
quality.render_size(&snapshot.project().settings)?;
let range = TimeRange::new(
time,
MediaTime::from_ticks(time.ticks().checked_add(1).ok_or(EngineError::OutOfRange)?),
)
.map_err(|_| EngineError::OutOfRange)?;
Self::over(
snapshot,
range,
frame_rate,
quality,
YuvEncoding::export(VideoCodec::H264),
)
}
fn over(
snapshot: &EngineProject,
range: TimeRange,
frame_rate: FrameRate,
quality: QualityProfile,
encoding: YuvEncoding,
) -> Result<Self, EngineError> {
if range.is_empty() {
return Err(EngineError::EmptyExportRange);
}
let next_frame = frame_rate
.frame_at_or_before(range.start())
.ok_or(EngineError::OutOfRange)?;
let end_frame = first_frame_at_or_after(frame_rate, range.end())?;
let count = end_frame
.checked_sub(next_frame)
.ok_or(EngineError::OutOfRange)?;
let total = u64::try_from(count).map_err(|_| EngineError::OutOfRange)?;
if total == 0 {
return Err(EngineError::EmptyExportRange);
}
if total > MAX_EXPORT_FRAMES {
return Err(EngineError::ExportTooLarge {
frames: total,
maximum: MAX_EXPORT_FRAMES,
});
}
let effective_range = TimeRange::new(
frame_rate
.frame_start(next_frame)
.ok_or(EngineError::OutOfRange)?,
frame_rate
.frame_start(end_frame)
.ok_or(EngineError::OutOfRange)?,
)
.map_err(|_| EngineError::OutOfRange)?;
Ok(Self {
snapshot: snapshot.generation(),
quality,
encoding,
pixels: PixelLayout::YUV420P,
background: true,
frame_rate,
effective_range,
next_frame,
end_frame,
completed: 0,
total,
})
}
#[must_use]
pub const fn encoding(&self) -> YuvEncoding {
self.encoding
}
#[must_use]
pub const fn with_pixels(mut self, pixels: PixelLayout) -> Self {
self.pixels = pixels;
self
}
#[must_use]
pub const fn pixels(&self) -> PixelLayout {
self.pixels
}
#[must_use]
pub const fn with_background(mut self, drawn: bool) -> Self {
self.background = drawn;
self
}
fn evaluate<'a>(
&self,
project: &'a EngineProject,
time: MediaTime,
) -> Result<alpha2_eval::FrameDesc<'a>, EngineError> {
let mut frame = project.evaluate(time)?;
if self.pixels.alpha || !self.background {
frame.background = None;
}
Ok(frame)
}
#[must_use]
pub const fn effective_range(&self) -> TimeRange {
self.effective_range
}
pub fn audio_window(&self, sample_rate: u32) -> Result<ExportAudioWindow, EngineError> {
export_audio_window(self.effective_range, sample_rate)
}
pub fn for_project(
project: &EngineProject,
quality: QualityProfile,
codec: VideoCodec,
) -> Result<Self, EngineError> {
let range = project
.timeline_range()
.ok_or(EngineError::EmptyExportRange)?;
Self::new(
project,
range,
project.project().settings.frame_rate,
quality,
codec,
)
}
pub const MISSING_FRAME_POLICY: MissingFramePolicy = MissingFramePolicy::Wait;
#[must_use]
pub const fn missing_frame_policy(&self) -> MissingFramePolicy {
Self::MISSING_FRAME_POLICY
}
#[must_use]
pub const fn progress(&self) -> ExportProgress {
ExportProgress {
completed: self.completed,
total: self.total,
}
}
pub fn render_size(
&self,
project: &EngineProject,
) -> Result<alpha2_render::RenderSize, EngineError> {
self.quality.render_size(&project.project().settings)
}
fn check(&self, project: &EngineProject) -> Result<(), EngineError> {
if project.generation() == self.snapshot {
Ok(())
} else {
Err(EngineError::ExportProjectChanged)
}
}
#[must_use]
pub fn pending_time(&self) -> Option<MediaTime> {
(self.next_frame < self.end_frame)
.then(|| self.frame_rate.frame_start(self.next_frame))
.flatten()
}
pub fn take_next(&mut self) -> Result<Option<(u64, MediaTime)>, EngineError> {
let Some(time) = self.pending_time() else {
return Ok(None);
};
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(Some((index, time)))
}
#[must_use]
pub const fn frame_rate(&self) -> FrameRate {
self.frame_rate
}
pub fn upcoming_times(&self, frames: u32) -> Result<Vec<MediaTime>, EngineError> {
Ok(frame_times(
self.frame_rate,
self.next_frame,
frames.min(MAX_PREFETCH_FRAMES),
Some(self.end_frame),
)?
.into_iter()
.map(|(_, time)| time)
.collect())
}
pub fn prefetch_needs(
&self,
project: &EngineProject,
frames: u32,
) -> Result<Vec<ExportFrameNeeds>, EngineError> {
self.check(project)?;
let count = frames.min(MAX_PREFETCH_FRAMES);
let times = frame_times(
self.frame_rate,
self.next_frame,
count,
Some(self.end_frame),
)?;
times
.into_iter()
.enumerate()
.map(|(offset, (_, time))| {
let offset = u64::try_from(offset).map_err(|_| EngineError::OutOfRange)?;
let index = self
.completed
.checked_add(offset)
.ok_or(EngineError::OutOfRange)?;
Ok(ExportFrameNeeds {
index,
time,
needs: project.frame_needs(time)?,
})
})
.collect()
}
pub fn render_next(
&mut self,
project: &EngineProject,
renderer: &Renderer,
frames: &dyn FrameProvider,
) -> Result<ExportStep, EngineError> {
self.render_next_measured(project, renderer, frames, None)
}
pub fn render_next_timed(
&mut self,
project: &EngineProject,
renderer: &Renderer,
frames: &dyn FrameProvider,
timings: &mut ExportStepTimings,
) -> Result<ExportStep, EngineError> {
self.render_next_measured(project, renderer, frames, Some(timings))
}
#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
pub fn render_next_nv12_timed(
&mut self,
project: &EngineProject,
renderer: &Renderer,
frames: &dyn FrameProvider,
planes: alpha2_render::Nv12PlaneTargets<'_>,
timings: &mut ExportStepTimings,
) -> Result<ExportPlaneStep, EngineError> {
self.check(project)?;
let Some(time) = self.pending_time() else {
return Ok(ExportPlaneStep::Complete);
};
let evaluate_started = Instant::now();
let frame = self.evaluate(project, time)?;
crate::frame::check_frame_effects(renderer, &frame)?;
let prepare_started = Instant::now();
let evaluate = prepare_started.duration_since(evaluate_started);
let size = self.quality.render_size(&project.project().settings)?;
renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
renderer.set_quality_controls(
self.quality.max_filter_samples,
self.quality.motion_blur_subframes,
);
let prepare = prepare_started.elapsed();
let mut submit = FrameSubmitTimings::default();
let attempt = renderer.render_frame_nv12_timed(
size,
&frame,
frames,
planes,
self.encoding,
&mut submit,
)?;
*timings = ExportStepTimings {
evaluate,
prepare,
submit,
};
if !attempt.submitted {
return Ok(ExportPlaneStep::Waiting {
time,
missing_sources: attempt.report.missing_sources,
});
}
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(ExportPlaneStep::Rendered { index, time })
}
pub fn render_next_into_yuv420p<R: FrameRenderer + ?Sized>(
&mut self,
project: &EngineProject,
renderer: &R,
frames: &dyn FrameProvider,
targets: &mut Yuv420pTargets<'_>,
timings: Option<&mut ExportStepTimings>,
) -> Result<ExportPlaneStep, EngineError> {
self.check(project)?;
let Some(time) = self.pending_time() else {
return Ok(ExportPlaneStep::Complete);
};
let evaluate_started = Instant::now();
let frame = self.evaluate(project, time)?;
crate::frame::check_frame_effects(renderer, &frame)?;
let prepare_started = Instant::now();
let evaluate = prepare_started.duration_since(evaluate_started);
let size = self.quality.render_size(&project.project().settings)?;
renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
renderer.set_quality_controls(
self.quality.max_filter_samples,
self.quality.motion_blur_subframes,
);
let draw_started = Instant::now();
let prepare = draw_started.duration_since(prepare_started);
let report =
renderer.render_frame_into_yuv420p(size, &frame, frames, self.encoding, targets)?;
if let Some(timings) = timings {
*timings = ExportStepTimings {
evaluate,
prepare,
submit: FrameSubmitTimings {
command_encode: draw_started.elapsed(),
..FrameSubmitTimings::default()
},
};
}
if !report.is_complete() {
return Ok(ExportPlaneStep::Waiting {
time,
missing_sources: report.missing_sources,
});
}
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(ExportPlaneStep::Rendered { index, time })
}
pub fn render_next_into_planes<R: FrameRenderer + ?Sized>(
&mut self,
project: &EngineProject,
renderer: &R,
frames: &dyn FrameProvider,
targets: &mut [alpha2_render::PlaneTarget<'_>],
timings: Option<&mut ExportStepTimings>,
) -> Result<ExportPlaneStep, EngineError> {
if self.pixels.is_yuv420p() {
let [y, u, v] = targets else {
return Err(EngineError::Render(
alpha2_render::RenderError::TextureReadback(
alpha2_render::ReadbackFailure::Layout,
),
));
};
let mut planes = Yuv420pTargets {
y: y.reborrow(),
u: u.reborrow(),
v: v.reborrow(),
};
return self.render_next_into_yuv420p(project, renderer, frames, &mut planes, timings);
}
self.check(project)?;
let Some(time) = self.pending_time() else {
return Ok(ExportPlaneStep::Complete);
};
let evaluate_started = Instant::now();
let frame = self.evaluate(project, time)?;
crate::frame::check_frame_effects(renderer, &frame)?;
let prepare_started = Instant::now();
let evaluate = prepare_started.duration_since(evaluate_started);
let size = self.quality.render_size(&project.project().settings)?;
renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
renderer.set_quality_controls(
self.quality.max_filter_samples,
self.quality.motion_blur_subframes,
);
let draw_started = Instant::now();
let prepare = draw_started.duration_since(prepare_started);
let report = renderer.render_frame_into_planes(
size,
&frame,
frames,
self.encoding,
self.pixels,
targets,
)?;
if let Some(timings) = timings {
*timings = ExportStepTimings {
evaluate,
prepare,
submit: FrameSubmitTimings {
command_encode: draw_started.elapsed(),
..FrameSubmitTimings::default()
},
};
}
if !report.is_complete() {
return Ok(ExportPlaneStep::Waiting {
time,
missing_sources: report.missing_sources,
});
}
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(ExportPlaneStep::Rendered { index, time })
}
pub fn render_next_texture<R: crate::FrameRenderer + ?Sized>(
&mut self,
project: &EngineProject,
renderer: &R,
frames: &dyn FrameProvider,
) -> Result<ExportTextureStep<R::Image>, EngineError> {
self.check(project)?;
let Some(time) = self.pending_time() else {
return Ok(ExportTextureStep::Complete);
};
let frame = self.evaluate(project, time)?;
crate::frame::check_frame_effects(renderer, &frame)?;
let size = self.quality.render_size(&project.project().settings)?;
renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
renderer.set_quality_controls(
self.quality.max_filter_samples,
self.quality.motion_blur_subframes,
);
let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
let Some(texture) = render.texture else {
return Ok(ExportTextureStep::Waiting {
time,
missing_sources: render.report.missing_sources,
});
};
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(ExportTextureStep::Rendered {
index,
time,
texture,
})
}
fn render_next_measured(
&mut self,
project: &EngineProject,
renderer: &Renderer,
frames: &dyn FrameProvider,
timings: Option<&mut ExportStepTimings>,
) -> Result<ExportStep, EngineError> {
self.check(project)?;
let Some(time) = self.pending_time() else {
return Ok(ExportStep::Complete);
};
let evaluate_started = timings.as_ref().map(|_| Instant::now());
let frame = self.evaluate(project, time)?;
crate::frame::check_frame_effects(renderer, &frame)?;
let prepare_started = evaluate_started.map(|_| Instant::now());
let evaluate = evaluate_started
.zip(prepare_started)
.map_or(Duration::ZERO, |(started, ended)| {
ended.duration_since(started)
});
let size = self.quality.render_size(&project.project().settings)?;
renderer.set_texture_budget_bytes(self.quality.texture_budget_bytes);
renderer.set_quality_controls(
self.quality.max_filter_samples,
self.quality.motion_blur_subframes,
);
let prepare = prepare_started.map_or(Duration::ZERO, |started| started.elapsed());
if !self.pixels.is_yuv420p() {
let submit_started = Instant::now();
let render = crate::FrameRenderer::render_frame(renderer, size, &frame, frames)?;
let Some(texture) = render.texture else {
return Ok(ExportStep::Waiting {
time,
missing_sources: render.report.missing_sources,
});
};
let readback = renderer.begin_layout_readback(&texture, self.encoding, self.pixels)?;
if let Some(timings) = timings {
*timings = ExportStepTimings {
evaluate,
prepare,
submit: FrameSubmitTimings {
command_encode: submit_started.elapsed(),
..FrameSubmitTimings::default()
},
};
}
return self.rendered(time, readback);
}
let attempt = match timings {
Some(timings) => {
let mut submit = FrameSubmitTimings::default();
let attempt = renderer.render_frame_yuv420p_timed(
size,
&frame,
frames,
self.encoding,
&mut submit,
)?;
*timings = ExportStepTimings {
evaluate,
prepare,
submit,
};
attempt
}
None => renderer.render_frame_yuv420p(size, &frame, frames, self.encoding)?,
};
let Some(readback) = attempt.readback else {
return Ok(ExportStep::Waiting {
time,
missing_sources: attempt.report.missing_sources,
});
};
self.rendered(time, readback)
}
fn rendered(
&mut self,
time: MediaTime,
readback: alpha2_render::Yuv420pReadback,
) -> Result<ExportStep, EngineError> {
let index = self.completed;
self.next_frame = self
.next_frame
.checked_add(1)
.ok_or(EngineError::OutOfRange)?;
self.completed += 1;
Ok(ExportStep::Rendered {
index,
time,
readback,
})
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ExportAudioWindow {
pub start_sample: i64,
pub frames: usize,
}
pub fn export_audio_window(
range: TimeRange,
sample_rate: u32,
) -> Result<ExportAudioWindow, EngineError> {
let nearest = |ticks: i64| {
let doubled =
i128::from(ticks) * i128::from(sample_rate) * 2 + i128::from(TICKS_PER_SECOND);
i64::try_from(doubled.div_euclid(i128::from(TICKS_PER_SECOND) * 2))
.map_err(|_| EngineError::OutOfRange)
};
let start_sample = nearest(range.start().ticks())?;
let end = nearest(range.end().ticks())?;
let frames = end
.checked_sub(start_sample)
.and_then(|frames| usize::try_from(frames).ok())
.ok_or(EngineError::OutOfRange)?;
Ok(ExportAudioWindow {
start_sample,
frames,
})
}
fn first_frame_at_or_after(rate: FrameRate, time: MediaTime) -> Result<i64, EngineError> {
let floor = rate
.frame_at_or_before(time)
.ok_or(EngineError::OutOfRange)?;
let floor_time = rate.frame_start(floor).ok_or(EngineError::OutOfRange)?;
if floor_time < time {
floor.checked_add(1).ok_or(EngineError::OutOfRange)
} else {
Ok(floor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Player;
use alpha2_model::{Project, ProjectId, ProjectSettings};
fn snapshot(width: u32, height: u32) -> EngineProject {
EngineProject::new(Project::new(
ProjectId::new("export".to_owned()).expect("id"),
"export",
ProjectSettings::new(width, height, FrameRate::new(30, 1).expect("rate"), 48_000)
.expect("settings"),
))
.expect("valid project")
}
#[test]
fn an_odd_4_2_0_output_is_refused_before_anything_is_encoded() {
let range =
TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
let rate = FrameRate::new(30, 1).expect("valid rate");
for (size, suggested) in [((65, 49), (64, 48)), ((64, 49), (64, 48)), ((1, 3), (2, 2))] {
let error = Exporter::new(
&snapshot(size.0, size.1),
range,
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect_err("odd output");
assert_eq!(
error,
EngineError::OddExportDimensions {
width: size.0,
height: size.1,
suggested,
}
);
assert!(
error
.to_string()
.contains(&format!("{}x{}", suggested.0, suggested.1))
);
}
assert!(
Exporter::new(
&snapshot(64, 48),
range,
rate,
QualityProfile::export(),
VideoCodec::H264
)
.is_ok()
);
}
#[test]
fn runners_expose_distinct_missing_frame_policies() {
let rate = FrameRate::new(30, 1).expect("valid rate");
let range =
TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("valid range");
let exporter = Exporter::new(
&snapshot(16, 16),
range,
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("valid exporter");
assert_eq!(
Player::<alpha2_render::RenderedTexture>::default().missing_frame_policy(),
MissingFramePolicy::HoldPrevious
);
assert_eq!(exporter.missing_frame_policy(), MissingFramePolicy::Wait);
}
#[test]
fn schedule_uses_exact_rational_frame_boundaries() {
let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
let start = rate.frame_start(10).expect("representable");
let end = rate.frame_start(20).expect("representable");
let exporter = Exporter::new(
&snapshot(16, 16),
TimeRange::new(start, end).expect("valid range"),
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("valid exporter");
assert_eq!(exporter.pending_time(), Some(start));
assert_eq!(
exporter.progress(),
ExportProgress {
completed: 0,
total: 10,
}
);
}
#[test]
fn schedule_includes_every_frame_intersecting_an_unaligned_range() {
let rate = FrameRate::new(30, 1).expect("valid rate");
let subframe = Exporter::new(
&snapshot(16, 16),
TimeRange::new(MediaTime(2_000), MediaTime(4_000)).expect("non-empty range"),
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("one intersecting frame");
assert_eq!(subframe.pending_time(), Some(MediaTime::ZERO));
assert_eq!(subframe.progress().total, 1);
let unaligned = Exporter::new(
&snapshot(16, 16),
TimeRange::new(MediaTime(2_000), MediaTime(6_000)).expect("non-empty range"),
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("two intersecting frames");
assert_eq!(unaligned.pending_time(), Some(MediaTime::ZERO));
assert_eq!(unaligned.progress().total, 2);
assert_eq!(
unaligned.effective_range(),
TimeRange::new(MediaTime::ZERO, MediaTime(8_000)).expect("frame grid")
);
}
#[test]
fn effective_range_is_the_request_widened_to_the_frame_grid() {
let rate = FrameRate::new(30_000, 1_001).expect("valid rate");
let frame = |index| rate.frame_start(index).expect("representable").ticks();
for (start, end) in [
(frame(3), frame(9)),
(frame(3) + 1, frame(9) - 1),
(frame(4) - 1, frame(8) + 1),
] {
let exporter = Exporter::new(
&snapshot(16, 16),
TimeRange::new(MediaTime(start), MediaTime(end)).expect("non-empty range"),
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("valid exporter");
let effective = exporter.effective_range();
assert_eq!(
(effective.start(), effective.end()),
(MediaTime(frame(3)), MediaTime(frame(9))),
"{start}..{end}"
);
assert_eq!(exporter.pending_time(), Some(effective.start()));
}
}
#[test]
fn schedule_rejects_unbounded_frame_counts() {
let rate = FrameRate::new(30, 1).expect("valid rate");
let range = TimeRange::new(MediaTime::ZERO, MediaTime(i64::MAX)).expect("ordered range");
assert!(matches!(
Exporter::new(
&snapshot(16, 16),
range,
rate,
QualityProfile::export(),
VideoCodec::H264
),
Err(EngineError::ExportTooLarge { .. })
));
}
#[test]
fn prefetch_reports_future_indices_without_advancing_the_cursor() {
let rate = FrameRate::new(30, 1).expect("valid rate");
let project = Project::new(
ProjectId::new("prefetch").expect("project ID"),
"prefetch",
ProjectSettings::new(16, 16, rate, 48_000).expect("settings"),
);
let snapshot = EngineProject::new(project).expect("snapshot");
let range =
TimeRange::new(MediaTime::ZERO, MediaTime::from_seconds(1)).expect("non-empty range");
let exporter = Exporter::new(
&snapshot,
range,
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("valid exporter");
let prefetched = exporter.prefetch_needs(&snapshot, 2).expect("prefetch");
assert_eq!(
prefetched
.iter()
.map(|frame| (frame.index, frame.time))
.collect::<Vec<_>>(),
vec![(0, MediaTime::ZERO), (1, MediaTime::from_ticks(4_000))]
);
assert!(prefetched.iter().all(|frame| frame.needs.is_empty()));
assert_eq!(exporter.pending_time(), Some(MediaTime::ZERO));
assert_eq!(exporter.progress().completed, 0);
}
#[test]
fn upcoming_times_are_the_frames_the_cursor_reaches_next() {
let rate = FrameRate::new(30, 1).expect("valid rate");
let snapshot = snapshot(16, 16);
let range = TimeRange::new(MediaTime::ZERO, MediaTime::from_ticks(12_000))
.expect("non-empty range");
let mut exporter = Exporter::new(
&snapshot,
range,
rate,
QualityProfile::export(),
VideoCodec::H264,
)
.expect("valid exporter");
let frame = MediaTime::from_ticks;
assert_eq!(
exporter.upcoming_times(2).expect("times"),
vec![frame(0), frame(4_000)]
);
let (_, taken) = exporter.take_next().expect("take").expect("a frame");
assert_eq!(taken, frame(0));
assert_eq!(
exporter.upcoming_times(5).expect("times"),
vec![frame(4_000), frame(8_000)]
);
assert_eq!(exporter.pending_time(), Some(frame(4_000)));
exporter.take_next().expect("take");
exporter.take_next().expect("take");
assert_eq!(exporter.upcoming_times(2).expect("times"), Vec::new());
}
#[test]
fn a_whole_project_export_covers_its_caption_cues() {
let project = |clips: &str| {
let json = format!(
r#"{{"schema_version": 1, "id": "p", "name": "p",
"settings": {{"width": 16, "height": 16, "frame_rate": {{"num": 30, "den": 1}}, "sample_rate": 48000}},
"sources": [{{"id": "v", "kind": "video", "locator": "v.mp4"}}],
"timeline": {{"tracks": [
{{"id": "t", "name": "T", "kind": "video", "clips": [{clips}]}},
{{"id": "c", "name": "C", "kind": "caption", "captions": {{"cues": [
{{"timeline_range": {{"start": 1080000, "end": 1440000}}, "text": "last line"}}]}}}}]}}}}"#
);
EngineProject::new(Project::from_json(&json).expect("valid")).expect("valid project")
};
let clip = r#"{"id": "a", "source_id": "v", "timeline_range": {"start": 0, "end": 1200000}, "source_range": {"start": 0, "end": 1200000}}"#;
for (snapshot, start) in [(project(clip), 0), (project(""), 1_080_000)] {
let range = snapshot.timeline_range().expect("something is shown");
assert_eq!(
(range.start().ticks(), range.end().ticks()),
(start, 1_440_000)
);
let exporter =
Exporter::for_project(&snapshot, QualityProfile::export(), VideoCodec::H264)
.expect("an export of the whole project");
assert_eq!(exporter.effective_range(), range);
}
}
}