use super::leaves::STUB_PROBE_SECONDS;
use super::*;
use crate::geometry::{Constraints, Vec2};
use crate::raster::{
CpuRasterImage, GpuSurface, PixelFormat, RasterComponent, RasterImage, Resolution,
};
use crate::render_context::RenderContext;
use crate::timeline_component::{
resolve, Arrangement, NodeKind, ResolveCtx, ResolveError, Timed, TimedBuilder,
TimelineComponent,
};
#[derive(PartialEq, Hash)]
struct Caption;
impl RasterComponent for Caption {
fn layout(&self, _c: Constraints) -> Vec2 {
Vec2(1.0, 1.0)
}
fn render(&self, _s: Vec2, _t: Resolution, _ctx: &mut dyn RenderContext) -> RasterImage {
RasterImage::cpu(1, 1, PixelFormat::Rgba8, vec![0u8; 4])
}
}
#[test]
fn timeline_resolves_to_max_non_fill_end() {
let tl = Timeline::builder()
.child(Caption.at(0.0..3.0))
.child(Caption.at(2.0..5.0))
.build();
assert_eq!(tl.measure(), Some(5.0));
let resolved = resolve(tl).expect("windowed, not timeless");
assert_eq!(resolved.duration(), 5.0);
}
#[test]
fn sequence_cursor_places_in_a_row_and_reflows() {
let seq = Sequence::builder()
.child(VideoFile::builder().path("a.mp4").duration(2.0))
.child(VideoFile::builder().path("b.mp4").duration(3.0))
.build();
assert_eq!(seq.measure(), Some(5.0));
let resolved = resolve(seq).expect("media-backed, not timeless");
assert_eq!(resolved.duration(), 5.0);
let seq = Sequence::builder()
.child(VideoFile::builder().path("a.mp4").duration(4.0))
.child(VideoFile::builder().path("b.mp4").duration(3.0))
.build();
assert_eq!(seq.measure(), Some(7.0));
}
#[test]
fn sequence_second_child_starts_at_first_end() {
let seq = Sequence::builder()
.child(Subtitle::builder().text("one").at(0.0..2.0))
.child(Subtitle::builder().text("two").at(0.0..3.0))
.build();
let cues = seq.cues(0.0);
assert_eq!(cues.len(), 2);
assert_eq!(cues[0].start, 0.0);
assert_eq!(cues[0].end, 2.0);
assert_eq!(cues[1].start, 2.0);
assert_eq!(cues[1].end, 5.0);
}
#[test]
fn sequence_spacing_inserts_gaps() {
let seq = Sequence::builder()
.spacing(0.5)
.child(VideoFile::builder().path("a.mp4").duration(2.0))
.child(VideoFile::builder().path("b.mp4").duration(3.0))
.build();
assert_eq!(seq.measure(), Some(5.5));
}
#[test]
fn fill_inside_sequence_is_a_resolve_error() {
let seq = Sequence::builder()
.child(VideoFile::builder().path("a.mp4").duration(2.0))
.child(Subtitle::builder().text("spanning").fill())
.build();
let err = resolve(seq).expect_err("a fill child in a Sequence is invalid");
assert!(matches!(err, ResolveError::Invalid(_)));
}
#[test]
fn fill_child_in_timeline_takes_container_length() {
let tl = Timeline::builder()
.child(AudioFile::builder().path("vo.wav").duration(3.0))
.child(Subtitle::builder().text("spanning").fill())
.build();
assert_eq!(tl.measure(), Some(3.0));
let resolved = resolve(tl).expect("media-backed, not timeless");
assert_eq!(resolved.duration(), 3.0);
assert!(resolved.warnings().is_empty());
let cues = resolved.source().cues(0.0);
let sub = cues
.iter()
.find(|c| c.text == "spanning")
.expect("subtitle cue");
assert_eq!(sub.start, 0.0);
assert_eq!(sub.end, 3.0);
}
#[test]
fn all_fill_timeline_warns_and_collapses_to_zero() {
let tl = Timeline::builder()
.child(Subtitle::builder().text("a").fill())
.child(Subtitle::builder().text("b").fill())
.build();
assert_eq!(tl.measure(), None);
let mut ctx = ResolveCtx::new();
let len = tl.resolve(0.0, &mut ctx);
assert_eq!(len, 0.0);
assert!(!ctx.warnings().is_empty());
}
#[test]
fn subtitle_cue_absolute_offset_through_nesting() {
let inner = Timeline::builder()
.child(VideoFile::builder().path("bg.mp4").duration(6.0))
.child(Subtitle::builder().text("hello").at(2.0..4.0))
.build();
let outer = Timeline::builder().child(inner).build();
let resolved = resolve(outer).expect("media-backed");
let cues = resolved.source().cues(0.0);
let hello = cues
.iter()
.find(|c| c.text == "hello")
.expect("subtitle cue");
assert_eq!(hello.start, 2.0);
assert_eq!(hello.end, 4.0);
}
#[test]
fn dialogue_shape_typechecks_and_resolves() {
let dialogue = Timeline::builder()
.child(Caption.fill())
.child(Subtitle::builder().text("a line").fill())
.child(AudioFile::builder().path("vo.wav").duration(4.5))
.build();
assert_eq!(dialogue.measure(), Some(4.5));
let resolved = resolve(dialogue).expect("the voice gives it a length");
assert_eq!(resolved.duration(), 4.5);
assert!(resolved.warnings().is_empty());
let cues = resolved.source().cues(0.0);
let line = cues
.iter()
.find(|c| c.text == "a line")
.expect("subtitle cue");
assert_eq!(line.start, 0.0);
assert_eq!(line.end, 4.5);
}
#[test]
fn arrangement_stamps_resolved_starts_ends_and_triggers() {
use crate::timeline_component::{Event, NodeKind, TriggerMark, Triggers};
let e = Event::new();
let root = Timeline::builder()
.child(
Sequence::builder()
.child(Caption.at(0.0..3.0))
.child(Caption.at(0.0..3.0).trigger_at_start(e))
.child(Caption.at(0.0..3.0))
.build(),
)
.child(Subtitle::builder().text("overlay").fill())
.build();
let resolved = resolve(root).expect("the sequence gives it a length");
assert_eq!(resolved.duration(), 9.0);
let arr = resolved.source().arrangement(0.0);
assert_eq!(arr.kind, NodeKind::Timeline);
assert_eq!(arr.start, 0.0);
assert_eq!(arr.end, 9.0);
let seq = &arr.children[0];
assert_eq!(seq.kind, NodeKind::Sequence);
assert_eq!(seq.start, 0.0);
assert_eq!(seq.end, 9.0);
assert_eq!(seq.children.len(), 3);
assert_eq!((seq.children[0].start, seq.children[0].end), (0.0, 3.0));
assert_eq!((seq.children[1].start, seq.children[1].end), (3.0, 6.0));
assert_eq!((seq.children[2].start, seq.children[2].end), (6.0, 9.0));
assert_eq!(
seq.children[1].triggers,
vec![TriggerMark {
time: 3.0,
name: None
}]
);
assert!(seq.children[0].triggers.is_empty());
assert!(seq.children[2].triggers.is_empty());
let overlay = &arr.children[1];
assert_eq!(overlay.kind, NodeKind::Subtitle);
assert_eq!(overlay.label, "overlay");
assert_eq!(overlay.start, 0.0);
assert_eq!(overlay.end, 9.0);
}
#[test]
#[rustfmt::skip]
fn arrangement_captures_child_call_site_source() {
use crate::timeline_component::NodeKind;
let seq_line = line!() + 3; let fill_line = line!() + 7; let root = Timeline::builder()
.child(
Sequence::builder()
.child(Caption.at(0.0..3.0))
.build(),
)
.child(Subtitle::builder().text("overlay").fill())
.build();
let resolved = resolve(root).expect("the sequence gives it a length");
let arr = resolved.source().arrangement(0.0);
assert_eq!(arr.source, None);
let seq = &arr.children[0];
assert_eq!(seq.kind, NodeKind::Sequence);
let seq_src = seq.source.as_ref().expect("sequence child has a source");
assert!(
seq_src.file.ends_with("timeline_container/tests.rs"),
"{}",
seq_src.file
);
assert_eq!(seq_src.line, seq_line);
let inner = &seq.children[0];
let inner_src = inner.source.as_ref().expect("placed caption has a source");
assert_eq!(inner_src.line, seq_line + 2);
let overlay = &arr.children[1];
assert_eq!(overlay.kind, NodeKind::Subtitle);
let overlay_src = overlay.source.as_ref().expect("fill overlay has a source");
assert_eq!(overlay_src.line, fill_line);
}
#[test]
fn media_leaf_probe_seam_is_injectable() {
assert_eq!(
VideoFile::builder().path("x.mp4").build().duration(),
Some(STUB_PROBE_SECONDS)
);
assert_eq!(
VideoFile::builder()
.path("x.mp4")
.duration(7.0)
.build()
.duration(),
Some(7.0)
);
assert_eq!(
AudioFile::builder()
.path("x.wav")
.gain(0.5)
.duration(9.0)
.build()
.duration(),
Some(9.0)
);
assert_eq!(Subtitle::builder().text("t").build().duration(), None);
}
#[test]
fn time_box_reports_the_given_duration() {
let time_box = TimeBox::builder().duration(2.5).build();
assert_eq!(time_box.duration(), Some(2.5));
assert_eq!(time_box.measure(), Some(2.5));
}
#[test]
fn time_box_arrangement_spans_its_duration_from_the_offset() {
let node = TimeBox::builder().duration(1.5).build().arrangement(10.0);
assert_eq!(node.kind, NodeKind::Timeline);
assert_eq!(node.start, 10.0);
assert_eq!(node.end, 11.5);
assert!(node.children.is_empty());
}
#[test]
fn time_box_gives_a_timeline_an_explicit_length() {
let tl = Timeline::builder()
.child(TimeBox::builder().duration(4.0).build().at(0.0))
.build();
assert_eq!(tl.measure(), Some(4.0));
let resolved = resolve(tl).expect("windowed, not timeless");
assert_eq!(resolved.duration(), 4.0);
}
#[test]
fn time_box_can_carry_a_trigger() {
use crate::timeline_component::{Event, Triggers};
let e = Event::new();
let triggered = TimeBox::builder().duration(3.0).build().trigger_at_end(e);
let mut ctx = ResolveCtx::new();
triggered.resolve(2.0, &mut ctx);
let table = ctx.into_triggers();
assert_eq!(table.get(e.id()).seconds(), 5.0);
}
#[test]
fn containers_and_leaves_box_via_from() {
let _boxed: Box<dyn TimelineComponent + Send> =
Timeline::builder().child(Caption.at(0.0..1.0)).into();
let _boxed2: Box<dyn TimelineComponent + Send> = VideoFile::builder().path("x.mp4").into();
}
use crate::time::{LocalTime, Time, TimelineTime};
use crate::timeline_component::{resolve as resolve_root, Clock, TriggerTable};
use std::sync::{Arc, Mutex};
#[derive(PartialEq, Hash)]
struct SolidColor {
rgba: [u8; 4],
}
impl RasterComponent for SolidColor {
fn layout(&self, _c: Constraints) -> Vec2 {
Vec2(1.0, 1.0)
}
fn render(&self, _s: Vec2, t: Resolution, _ctx: &mut dyn RenderContext) -> RasterImage {
let count = (t.width as usize) * (t.height as usize);
let mut pixels = Vec::with_capacity(count * 4);
for _ in 0..count {
pixels.extend_from_slice(&self.rgba);
}
RasterImage::cpu(t.width, t.height, PixelFormat::Rgba8, pixels)
}
}
fn first_pixel(image: &RasterImage) -> [u8; 4] {
let cpu = image.as_cpu().expect("cpu image");
[cpu.pixels[0], cpu.pixels[1], cpu.pixels[2], cpu.pixels[3]]
}
#[test]
fn timeline_overlays_two_solids_source_over() {
let tl = Timeline::builder()
.child(
SolidColor {
rgba: [255, 0, 0, 255],
}
.fill(),
)
.child(
SolidColor {
rgba: [0, 255, 0, 128],
}
.fill(),
)
.child(
SolidColor {
rgba: [0, 0, 255, 0],
}
.at(0.0..2.0),
)
.build();
let resolved = resolve_root(tl).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
let frame = resolved
.frame(TimelineTime::new(0.5), Resolution::new(4, 4), &mut ctx)
.expect("two visible solids contribute a frame");
let px = first_pixel(&frame);
assert!(px[3] >= 254, "result is opaque, got alpha {}", px[3]);
assert!(px[0] > 100 && px[0] < 160, "red ~half, got {}", px[0]);
assert!(px[1] > 100 && px[1] < 160, "green ~half, got {}", px[1]);
assert_eq!(px[2], 0, "no blue contributes");
}
#[crate::component(timeline)]
fn LocalProbe(#[clock] clock: Clock) -> impl TimelineComponent {
let secs = clock.local().seconds().round().clamp(0.0, 255.0) as u8;
SolidColor {
rgba: [secs, 0, 0, 255],
}
}
#[test]
fn placed_at_rebases_local_clock_to_zero_at_its_start() {
let probe = LocalProbe::builder().build().at(2.0..5.0);
let resolved = resolve_root(probe).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
let f0 = resolved
.frame(TimelineTime::new(2.0), Resolution::new(2, 2), &mut ctx)
.expect("contributes");
assert_eq!(first_pixel(&f0)[0], 0, "local ≈ 0 at its start");
let f2 = resolved
.frame(TimelineTime::new(4.0), Resolution::new(2, 2), &mut ctx)
.expect("contributes");
assert_eq!(first_pixel(&f2)[0], 2, "local advances 1:1 with global");
assert!(
resolved
.frame(TimelineTime::new(5.0), Resolution::new(2, 2), &mut ctx)
.is_none(),
"the exclusive window end contributes nothing",
);
}
#[test]
fn placed_frame_gates_outside_its_window() {
let probe = LocalProbe::builder().build().at(1.0..3.0);
let resolved = resolve_root(probe).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
assert!(
resolved
.frame(TimelineTime::new(0.5), Resolution::new(2, 2), &mut ctx)
.is_none(),
"before the window: nothing",
);
assert!(
resolved
.frame(TimelineTime::new(1.5), Resolution::new(2, 2), &mut ctx)
.is_some(),
"inside the window: contributes",
);
assert!(
resolved
.frame(TimelineTime::new(3.0), Resolution::new(2, 2), &mut ctx)
.is_none(),
"exclusive end (half-open): nothing",
);
assert!(
resolved
.frame(TimelineTime::new(3.5), Resolution::new(2, 2), &mut ctx)
.is_none(),
"past the window: nothing",
);
}
#[test]
fn timeline_caps_fill_child_at_container_length() {
let tl = Timeline::builder()
.child(
SolidColor {
rgba: [0, 0, 0, 255],
}
.at(0.0..2.0),
)
.child(
SolidColor {
rgba: [255, 0, 0, 255],
}
.fill(),
)
.build();
let resolved = resolve_root(tl).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
assert!(
resolved
.frame(TimelineTime::new(1.0), Resolution::new(2, 2), &mut ctx)
.is_some(),
"inside [0,2): the fill renders",
);
assert!(
resolved
.frame(TimelineTime::new(2.0), Resolution::new(2, 2), &mut ctx)
.is_none(),
"exclusive container end: hard cut (even for a fill)",
);
assert!(
resolved
.frame(TimelineTime::new(2.5), Resolution::new(2, 2), &mut ctx)
.is_none(),
"past the container length: nothing",
);
}
#[test]
fn sequence_gates_each_child_to_its_slot() {
let seq = Sequence::builder()
.child(LocalProbe::builder().build().at(0.0..2.0)) .child(LocalProbe::builder().build().at(0.0..2.0)) .build();
let resolved = resolve_root(seq).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
assert!(
resolved
.frame(TimelineTime::new(0.5), Resolution::new(2, 2), &mut ctx)
.is_some(),
"slot 1 active at 0.5",
);
let f = resolved
.frame(TimelineTime::new(3.0), Resolution::new(2, 2), &mut ctx)
.expect("slot 2 active");
assert_eq!(first_pixel(&f)[0], 1, "slot 2 local = 3.0 - 2.0 = 1.0");
assert!(
resolved
.frame(TimelineTime::new(4.0), Resolution::new(2, 2), &mut ctx)
.is_none(),
"past both slots: nothing",
);
}
#[derive(PartialEq, Hash)]
struct GpuFrame;
impl TimelineComponent for GpuFrame {
fn duration(&self) -> Option<f32> {
Some(1.0)
}
fn frame(
&self,
_clock: Clock<'_>,
_canvas: Vec2,
target: Resolution,
_ctx: &mut dyn RenderContext,
) -> Option<RasterImage> {
Some(RasterImage::Gpu(GpuSurface::new(
target.width,
target.height,
PixelFormat::Rgba8,
"test-gpu",
Arc::new(()),
)))
}
fn arrangement(&self, offset: f32) -> Arrangement {
Arrangement {
kind: NodeKind::Video,
label: String::new(),
name: None,
source: None,
start: offset,
end: offset + 1.0,
trim: None,
triggers: Vec::new(),
children: Vec::new(),
}
}
}
#[derive(Default)]
struct CountingReadbackContext {
readbacks: usize,
}
impl RenderContext for CountingReadbackContext {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn render(
&mut self,
component: &dyn RasterComponent,
size: Vec2,
target: Resolution,
) -> RasterImage {
component.render(size, target, self)
}
fn readback(&mut self, image: RasterImage) -> CpuRasterImage {
self.readbacks += 1;
match image {
RasterImage::Cpu(image) => image,
RasterImage::Gpu(surface) => {
let stride = match surface.format {
PixelFormat::Rgba8 => 4,
PixelFormat::Rgba16Float => 8,
};
let bytes = (surface.width as usize) * (surface.height as usize) * stride;
CpuRasterImage::new(
surface.width,
surface.height,
surface.format,
vec![0u8; bytes],
)
}
}
}
}
#[test]
fn sequence_single_active_frame_preserves_gpu_image() {
let seq = Sequence::builder()
.child(Box::new(GpuFrame) as Box<dyn TimelineComponent + Send>)
.build();
let resolved = resolve_root(seq).expect("windowed, not timeless");
let mut ctx = CountingReadbackContext::default();
let frame = resolved
.frame(TimelineTime::new(0.5), Resolution::new(2, 2), &mut ctx)
.expect("active slot contributes");
assert_eq!(ctx.readbacks, 0, "single active slot should not read back");
assert!(
matches!(frame, RasterImage::Gpu(_)),
"sequence should preserve a target-sized GPU frame"
);
}
#[derive(Clone)]
struct WindowProbe {
log: Arc<Mutex<Vec<Option<f32>>>>,
duration: f32,
}
impl PartialEq for WindowProbe {
fn eq(&self, other: &Self) -> bool {
self.duration == other.duration
}
}
impl std::hash::Hash for WindowProbe {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.duration.to_bits().hash(state);
}
}
impl TimelineComponent for WindowProbe {
fn duration(&self) -> Option<f32> {
Some(self.duration)
}
fn frame(
&self,
clock: Clock<'_>,
canvas: Vec2,
target: Resolution,
_ctx: &mut dyn RenderContext,
) -> Option<RasterImage> {
let _ = canvas;
self.log
.lock()
.unwrap()
.push(clock.window().map(|w| w.width()));
Some(RasterImage::cpu(
target.width,
target.height,
PixelFormat::Rgba8,
vec![255u8; (target.width as usize) * (target.height as usize) * 4],
))
}
fn arrangement(&self, offset: f32) -> Arrangement {
Arrangement {
kind: NodeKind::Video,
label: String::new(),
name: None,
source: None,
start: offset,
end: offset + self.duration,
trim: None,
triggers: Vec::new(),
children: Vec::new(),
}
}
}
#[test]
fn placed_surfaces_post_stretch_window_to_the_clock() {
let log = Arc::new(Mutex::new(Vec::new()));
let leaf = WindowProbe {
log: Arc::clone(&log),
duration: 2.0,
};
let resolved = resolve_root(leaf.at(0.0..1.0)).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
resolved.frame(TimelineTime::new(0.5), Resolution::new(1, 1), &mut ctx);
assert_eq!(
*log.lock().unwrap().last().expect("sampled"),
Some(2.0),
"(b - a) * speed = content seconds",
);
}
#[test]
fn sequence_rebases_second_child_local_clock() {
let seq = Sequence::builder()
.child(SolidColor { rgba: [0, 0, 0, 0] }.at(0.0..2.0))
.child(LocalProbe::builder().build().at(0.0..3.0))
.build();
let resolved = resolve_root(seq).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
let f = resolved
.frame(TimelineTime::new(2.0), Resolution::new(2, 2), &mut ctx)
.expect("contributes");
assert_eq!(
first_pixel(&f)[0],
0,
"second child's local ≈ 0 at the cursor"
);
let f2 = resolved
.frame(TimelineTime::new(4.0), Resolution::new(2, 2), &mut ctx)
.expect("contributes");
assert_eq!(first_pixel(&f2)[0], 2, "local tracks the cursor offset");
}
#[derive(Clone)]
struct RecordingLeaf {
log: Arc<Mutex<Vec<f32>>>,
duration: f32,
}
impl PartialEq for RecordingLeaf {
fn eq(&self, other: &Self) -> bool {
self.duration == other.duration
}
}
impl std::hash::Hash for RecordingLeaf {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.duration.to_bits().hash(state);
}
}
impl TimelineComponent for RecordingLeaf {
fn duration(&self) -> Option<f32> {
Some(self.duration)
}
fn frame(
&self,
clock: Clock<'_>,
canvas: Vec2,
target: Resolution,
_ctx: &mut dyn RenderContext,
) -> Option<RasterImage> {
let _ = canvas;
self.log.lock().unwrap().push(clock.local().seconds());
Some(RasterImage::cpu(
target.width,
target.height,
PixelFormat::Rgba8,
vec![255u8; (target.width as usize) * (target.height as usize) * 4],
))
}
fn arrangement(&self, offset: f32) -> Arrangement {
Arrangement {
kind: NodeKind::Video,
label: String::new(),
name: None,
source: None,
start: offset,
end: offset + self.duration,
trim: None,
triggers: Vec::new(),
children: Vec::new(),
}
}
}
#[test]
fn placement_window_stretch_reaches_the_leaf() {
let log = Arc::new(Mutex::new(Vec::new()));
let leaf = RecordingLeaf {
log: Arc::clone(&log),
duration: 2.0,
};
let placed = leaf.at(0.0..1.0);
assert_eq!(placed.speed(), 2.0);
let resolved = resolve_root(placed).expect("windowed, not timeless");
let mut ctx = crate::render_context::PassThrough;
resolved.frame(TimelineTime::new(0.5), Resolution::new(1, 1), &mut ctx);
let recorded = *log.lock().unwrap().last().expect("leaf was sampled");
assert!(
(recorded - 1.0).abs() < 1e-5,
"2× stretch: parent-local 0.5 → source-local {recorded} (want 1.0)",
);
resolved.frame(TimelineTime::new(0.25), Resolution::new(1, 1), &mut ctx);
let recorded = *log.lock().unwrap().last().expect("leaf was sampled");
assert!(
(recorded - 0.5).abs() < 1e-5,
"source-local {recorded} (want 0.5)"
);
}
#[test]
fn timeless_visual_frame_routes_through_ctx_render() {
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let mut ctx = crate::render_context::PassThrough;
let solid = SolidColor {
rgba: [10, 20, 30, 255],
};
let f = solid
.frame(clock, Vec2(2.0, 2.0), Resolution::new(2, 2), &mut ctx)
.expect("renders");
assert_eq!(first_pixel(&f), [10, 20, 30, 255]);
let probe = LocalProbe::builder().build();
let f = probe
.frame(clock, Vec2(2.0, 2.0), Resolution::new(2, 2), &mut ctx)
.expect("renders");
assert_eq!(first_pixel(&f)[0], 0, "local 0 → red 0");
}
use crate::timeline_component::resolve as resolve_audio;
const TEST_RATE: u32 = 48_000;
fn write_wav_fixture(name: &str, rate: u32, samples: &[i16]) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("tellur_audio_{}_{}.wav", name, std::process::id()));
let channels: u16 = 1;
let bits: u16 = 16;
let byte_rate = rate * channels as u32 * (bits as u32 / 8);
let block_align = channels * (bits / 8);
let data_bytes = (samples.len() * 2) as u32;
let mut bytes = Vec::with_capacity(44 + samples.len() * 2);
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36 + data_bytes).to_le_bytes());
bytes.extend_from_slice(b"WAVE");
bytes.extend_from_slice(b"fmt ");
bytes.extend_from_slice(&16u32.to_le_bytes()); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&channels.to_le_bytes());
bytes.extend_from_slice(&rate.to_le_bytes());
bytes.extend_from_slice(&byte_rate.to_le_bytes());
bytes.extend_from_slice(&block_align.to_le_bytes());
bytes.extend_from_slice(&bits.to_le_bytes());
bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_bytes.to_le_bytes());
for s in samples {
bytes.extend_from_slice(&s.to_le_bytes());
}
std::fs::write(&path, &bytes).expect("write wav fixture");
path
}
fn const_wav(name: &str, rate: u32, frames: usize, level: i16) -> std::path::PathBuf {
write_wav_fixture(name, rate, &vec![level; frames])
}
fn write_ffmpeg_sine_audio(name: &str, ext: &str, codec: &str, seconds: f32) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"tellur_audio_{}_{}.{}",
name,
std::process::id(),
ext
));
let source = format!("sine=frequency=440:sample_rate={TEST_RATE}:duration={seconds:.3}");
let status = std::process::Command::new("ffmpeg")
.args([
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
&source,
"-ac",
"1",
"-c:a",
codec,
])
.arg(&path)
.status()
.expect("spawn ffmpeg audio fixture");
assert!(status.success(), "ffmpeg audio fixture write failed");
path
}
#[test]
fn audiofile_probes_real_decoded_duration() {
let path = const_wav("probe", TEST_RATE, TEST_RATE as usize, 10_000);
let af = AudioFile::builder().path(path.to_str().unwrap()).build();
let d = af.duration().expect("audio has a duration");
assert!((d - 1.0).abs() < 1e-3, "decoded ~1.0s, got {d}");
let _ = std::fs::remove_file(&path);
}
#[test]
#[ignore = "requires ffmpeg with libmp3lame on PATH"]
fn audiofile_decodes_mp3_duration_and_mix() {
let path = write_ffmpeg_sine_audio("mp3", "mp3", "libmp3lame", 2.0);
let af = AudioFile::builder().path(path.to_str().unwrap()).build();
let d = af.duration().expect("mp3 audio has a duration");
assert!(
d > 1.5 && d < 2.3,
"mp3 should decode near 2s instead of falling back to 1s, got {d}"
);
let resolved = resolve_audio(Timeline::builder().child(af).build()).expect("media-backed");
let mixed = resolved.render_audio_window(0.25, 0.25, TEST_RATE, 1);
assert!(
mixed.samples.iter().any(|sample| sample.abs() > 0.001),
"decoded mp3 should contribute audible samples to the mix"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn audiofile_trim_crops_source_seconds() {
let path = const_wav("trim", TEST_RATE, (TEST_RATE * 2) as usize, 8_000);
let af = AudioFile::builder()
.path(path.to_str().unwrap())
.build()
.trim(0.5..1.0);
let d = af.duration().expect("trimmed duration");
assert!((d - 0.5).abs() < 1e-3, "trim to 0.5s, got {d}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn timeline_overlapping_audio_sums() {
let half = (0.5 * i16::MAX as f32) as i16;
let frames = (TEST_RATE / 2) as usize; let a = const_wav("mix_a", TEST_RATE, frames, half);
let b = const_wav("mix_b", TEST_RATE, frames, half);
let tl = Timeline::builder()
.child(AudioFile::builder().path(a.to_str().unwrap()))
.child(AudioFile::builder().path(b.to_str().unwrap()))
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let mid = mixed.samples[frames / 2];
assert!(
(mid - 1.0).abs() < 0.02,
"two +0.5 tones sum to ~1.0, got {mid}"
);
assert_eq!(mixed.rate, TEST_RATE);
assert_eq!(mixed.channels, 1);
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
}
#[crate::component(timeline)]
fn Voiced(#[builder(into)] path: String) -> impl crate::timeline_component::TimelineComponent {
AudioFile::builder().path(path).build()
}
#[test]
fn fn_form_component_forwards_audio_mix() {
let level = (0.6 * i16::MAX as f32) as i16;
let frames = (TEST_RATE / 2) as usize; let src = const_wav("fnform", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(Voiced::builder().path(src.to_str().unwrap()))
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let mid = mixed.samples[frames / 2];
assert!(
(mid - 0.6).abs() < 0.02,
"wrapped audio must reach the mix (~0.6), got {mid}"
);
let _ = std::fs::remove_file(&src);
}
#[test]
fn sequence_concatenates_audio() {
let frames = (TEST_RATE / 2) as usize; let lo = (0.3 * i16::MAX as f32) as i16;
let hi = (0.6 * i16::MAX as f32) as i16;
let a = const_wav("seq_a", TEST_RATE, frames, lo);
let b = const_wav("seq_b", TEST_RATE, frames, hi);
let seq = Sequence::builder()
.child(AudioFile::builder().path(a.to_str().unwrap()))
.child(AudioFile::builder().path(b.to_str().unwrap()))
.build();
let resolved = resolve_audio(seq).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let first = mixed.samples[frames / 2];
let second = mixed.samples[frames + frames / 2];
assert!(
(first - 0.3).abs() < 0.02,
"child 1 region ~0.3, got {first}"
);
assert!(
(second - 0.6).abs() < 0.02,
"child 2 region ~0.6, got {second}"
);
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
}
#[test]
fn render_audio_window_matches_full_mix_slice_and_pads_tail() {
let level = (0.5 * i16::MAX as f32) as i16;
let frames = TEST_RATE as usize;
let path = const_wav("window", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(AudioFile::builder().path(path.to_str().unwrap()))
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let full = resolved.render_audio(TEST_RATE, 1);
let window = resolved.render_audio_window(0.25, 0.5, TEST_RATE, 1);
let start = (TEST_RATE / 4) as usize;
let end = start + (TEST_RATE / 2) as usize;
assert_eq!(window.samples.len(), end - start);
for (a, b) in window.samples.iter().zip(&full.samples[start..end]) {
assert!(
(a - b).abs() < 1e-6,
"window sample {a} should match full mix slice sample {b}"
);
}
let padded = resolved.render_audio_window(1.25, 0.25, TEST_RATE, 1);
assert_eq!(padded.samples.len(), (TEST_RATE / 4) as usize);
assert!(padded.samples.iter().all(|sample| *sample == 0.0));
let _ = std::fs::remove_file(&path);
}
#[test]
fn gain_scales_audio() {
let frames = (TEST_RATE / 2) as usize;
let level = (0.8 * i16::MAX as f32) as i16;
let path = const_wav("gain", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(AudioFile::builder().path(path.to_str().unwrap()).gain(0.5))
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let mid = mixed.samples[frames / 2];
assert!(
(mid - 0.4).abs() < 0.02,
"gain 0.5 over 0.8 ⇒ ~0.4, got {mid}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn gain_can_exceed_unit_range_before_ffmpeg_output() {
let frames = (TEST_RATE / 2) as usize;
let level = (0.8 * i16::MAX as f32) as i16;
let path = const_wav("hot_gain", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(AudioFile::builder().path(path.to_str().unwrap()).gain(2.0))
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let mid = mixed.samples[frames / 2];
assert!(
(mid - 1.6).abs() < 0.04,
"gain 2.0 over 0.8 should keep f32 headroom (~1.6), got {mid}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn audiofile_applies_linear_fade_envelope() {
let frames = TEST_RATE as usize;
let level = (0.8 * i16::MAX as f32) as i16;
let path = const_wav("fade", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(
AudioFile::builder()
.path(path.to_str().unwrap())
.fade_in(0.25)
.fade_out(0.25),
)
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let mixed = resolved.render_audio(TEST_RATE, 1);
let full = mixed.samples[(TEST_RATE / 2) as usize];
let half_rise = mixed.samples[(TEST_RATE / 8) as usize];
let half_fall = mixed.samples[(TEST_RATE * 7 / 8) as usize];
let tail = mixed.samples[frames - 1];
assert!(
mixed.samples[0].abs() < 1e-6,
"fade-in starts silent, got {}",
mixed.samples[0]
);
assert!(
(half_rise - full * 0.5).abs() < 0.02,
"halfway through fade-in should be half gain: {half_rise} vs {full}",
);
assert!(
(half_fall - full * 0.5).abs() < 0.02,
"halfway through fade-out should be half gain: {half_fall} vs {full}",
);
assert!(tail.abs() < 0.001, "fade-out ends near silence, got {tail}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn audiofile_fade_matches_full_mix_when_rendering_window() {
let frames = TEST_RATE as usize;
let level = (0.7 * i16::MAX as f32) as i16;
let path = const_wav("fade_window", TEST_RATE, frames, level);
let tl = Timeline::builder()
.child(
AudioFile::builder()
.path(path.to_str().unwrap())
.fade_in(0.2)
.fade_out(0.3),
)
.build();
let resolved = resolve_audio(tl).expect("media-backed");
let full = resolved.render_audio(TEST_RATE, 1);
let window = resolved.render_audio_window(0.75, 0.2, TEST_RATE, 1);
let start = (TEST_RATE * 3 / 4) as usize;
let end = start + (TEST_RATE / 5) as usize;
assert_eq!(window.samples.len(), end - start);
for (a, b) in window.samples.iter().zip(&full.samples[start..end]) {
assert!(
(a - b).abs() < 1e-6,
"window sample {a} should match full mix slice sample {b}"
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn placement_speed_changes_sample_count() {
let src_frames = TEST_RATE as usize; let level = (0.5 * i16::MAX as f32) as i16;
let path = const_wav("speed", TEST_RATE, src_frames, level);
let native = AudioFile::builder().path(path.to_str().unwrap());
let r_native = resolve_audio(Timeline::builder().child(native).build()).expect("media-backed");
let mix_native = r_native.render_audio(TEST_RATE, 1);
let stretched = AudioFile::builder()
.path(path.to_str().unwrap())
.build()
.at(0.0..0.5);
let r_stretched = resolve_audio(stretched).expect("windowed, not timeless");
let mix_stretched = r_stretched.render_audio(TEST_RATE, 1);
assert!(
mix_native.samples.len() > mix_stretched.samples.len() * 3 / 2,
"2x speed ⇒ ~half the frames: native {} vs stretched {}",
mix_native.samples.len(),
mix_stretched.samples.len(),
);
let stretched_secs = mix_stretched.samples.len() as f32 / TEST_RATE as f32;
assert!(
(stretched_secs - 0.5).abs() < 0.02,
"stretched buffer ~0.5s, got {stretched_secs}",
);
let _ = std::fs::remove_file(&path);
}
fn write_testsrc_mp4(name: &str, secs: u32, w: u32, h: u32) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("tellur_video_{}_{}.mp4", name, std::process::id()));
let size = format!("size={w}x{h}:rate=30:duration={secs}");
let lavfi = format!("testsrc={size}");
let status = std::process::Command::new("ffmpeg")
.args(["-y", "-v", "error"])
.args(["-f", "lavfi", "-i", &lavfi])
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
.arg(&path)
.status()
.expect("spawn ffmpeg to write testsrc fixture");
assert!(status.success(), "ffmpeg testsrc fixture write failed");
path
}
fn frame_has_color(image: &RasterImage) -> bool {
let cpu = image.as_cpu().expect("cpu frame");
cpu.pixels
.chunks_exact(4)
.any(|px| px[3] > 0 && (px[0] > 0 || px[1] > 0 || px[2] > 0))
}
#[test]
#[ignore = "requires ffmpeg + ffprobe on PATH"]
fn videofile_probes_real_duration() {
let path = write_testsrc_mp4("probe", 2, 64, 48);
let vf = VideoFile::builder().path(path.to_str().unwrap()).build();
let d = vf.duration().expect("video has a duration");
assert!((d - 2.0).abs() < 0.2, "probed ~2.0s, got {d}");
let _ = std::fs::remove_file(&path);
}
#[test]
#[ignore = "requires ffmpeg + ffprobe on PATH"]
fn videofile_decodes_plausible_frames() {
let path = write_testsrc_mp4("decode", 2, 320, 240);
let target = Resolution::new(64, 48);
let tl = Timeline::builder()
.child(
VideoFile::builder()
.path(path.to_str().unwrap())
.at(0.0..2.0),
)
.build();
let resolved = resolve_root(tl).expect("media-backed");
let mut ctx = crate::render_context::PassThrough;
for &t in &[0.0_f32, 0.1, 0.2, 1.0] {
let frame = resolved
.frame(TimelineTime::new(t), target, &mut ctx)
.unwrap_or_else(|| panic!("decoded a frame at t={t}"));
assert_eq!(frame.width(), 64, "scaled to target width");
assert_eq!(frame.height(), 48, "scaled to target height");
assert!(frame_has_color(&frame), "frame at t={t} has real pixels");
}
let back = resolved
.frame(TimelineTime::new(0.0), target, &mut ctx)
.expect("backward scrub decodes");
assert!(
frame_has_color(&back),
"scrubbed-back frame has real pixels"
);
let _ = std::fs::remove_file(&path);
}