mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
Merge main into enhance/avi-import
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
//! Sample positions through the public native WAV exporter, using a tiny recorded impulse.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct Impulse {
|
||||
first: i64,
|
||||
last: i64,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for Impulse {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
let index = start.to_units_floor(i64::from(rate));
|
||||
self.calls.lock().unwrap().push((start, index, frames, rate));
|
||||
let mut samples = Vec::with_capacity(frames * 2);
|
||||
for offset in 0..frames {
|
||||
let sample = index + offset as i64;
|
||||
let value = if sample == self.first {
|
||||
0.75
|
||||
} else if sample == self.last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
samples.extend([value, -value]);
|
||||
}
|
||||
Some(samples)
|
||||
}
|
||||
}
|
||||
fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let mut offset = 12;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize;
|
||||
if &bytes[offset..offset + 4] == name {
|
||||
return &bytes[offset + 8..offset + 8 + length];
|
||||
}
|
||||
offset += 8 + length + length % 2;
|
||||
}
|
||||
panic!("native WAV chunk missing");
|
||||
}
|
||||
fn check(rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Match the exporter's existing floor-to-sample span convention; this proof tests whether
|
||||
// its selected samples survive subsequent Tick conversions, without changing that convention.
|
||||
let first = span.0.to_units_floor(i64::from(rate));
|
||||
let end = span.1.to_units_floor(i64::from(rate));
|
||||
let expected_frames = usize::try_from(end - first).unwrap();
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item(
|
||||
"Impulse",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Footage(Footage { path: "recorded-sample-impulse.wav".into(), kind: FootageKind::Audio, duration, has_audio: true, ..Default::default() }),
|
||||
);
|
||||
let mut comp = Comp::new(4, 4, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Impulse", LayerSource::Footage { item }, (4, 4), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::Wav);
|
||||
output.audio_sample_rate = rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let provider = Impulse { first, last: end - 1, calls: Mutex::default() };
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "impulse.wav",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio, "the production native audio exporter ran");
|
||||
let files = files.lock().unwrap();
|
||||
let bytes = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1;
|
||||
assert_eq!(&bytes[..4], b"RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE");
|
||||
let format = chunk(bytes, b"fmt ");
|
||||
assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3, "float PCM");
|
||||
assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), rate);
|
||||
let pcm = chunk(bytes, b"data");
|
||||
assert_eq!(pcm.len(), expected_frames * 8, "the selected work-area sample count is unchanged");
|
||||
let samples: Vec<f32> = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect();
|
||||
let impulses: Vec<usize> = samples.as_chunks::<2>().0.iter().enumerate().filter_map(|(i, pair)| (pair[0] != 0.0).then_some(i)).collect();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
eprintln!("rate={rate} first={first} end={end} frames={expected_frames} calls={calls:?} exported_impulses={impulses:?}");
|
||||
assert!(!calls.is_empty(), "native export actually requested provider samples");
|
||||
assert_eq!(
|
||||
samples[..2],
|
||||
[0.75, -0.75],
|
||||
"the first selected source sample must be the first exported sample at {rate} Hz; impulses={impulses:?}, calls={calls:?}"
|
||||
);
|
||||
assert_eq!(samples[samples.len() - 2..], [0.25, -0.25], "the final selected source sample must be preserved at {rate} Hz");
|
||||
assert_eq!(impulses, [0, expected_frames - 1], "no shifted or duplicated impulse");
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_44100_sample_positions() {
|
||||
check(44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_48000_sample_positions() {
|
||||
check(48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_44117_sample_positions() {
|
||||
check(44_117);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! HEVC (MP4 `hvc1`) and AV1 (MP4 `av01`, WebM `V_AV1`) export, decoded back through
|
||||
//! EffectCraft's own import path (FilmCraft's demuxers and HEVC / AV1 decoders) and — when
|
||||
//! installed — ffprobe / ffmpeg as external oracles (skipped otherwise).
|
||||
//!
|
||||
//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48
|
||||
//! centred, starting at frame 5 (inter frames code both static and changing blocks).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{
|
||||
AudioOutput, CodecProfile, OpusApplication, OutputFormat, OutputModule, RateControlMode, RenderSettings, TimeSpan, WebmVideoCodec,
|
||||
};
|
||||
use effectcraft_export::{Job, Progress, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{FootageSource, NoFootage};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
const BG: [f32; 3] = [0.1, 0.1, 0.4];
|
||||
const FRAMES: u64 = 12;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export-hevc-av1").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
fn project(audio: Option<&Path>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2));
|
||||
comp.background = BG;
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None);
|
||||
l.in_point = comp.frame_rate.tick_of(5);
|
||||
comp.layers.push(l);
|
||||
if let Some(path) = audio {
|
||||
let f = effectcraft_media::probe(path).expect("probe wav");
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
comp.layers.push(l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
let mut last = Progress::default();
|
||||
let r = export(&job, &mut |pr| {
|
||||
last = *pr;
|
||||
true
|
||||
})
|
||||
.expect("export");
|
||||
assert_eq!((last.done, r.frames), (FRAMES, FRAMES));
|
||||
r
|
||||
}
|
||||
|
||||
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[img.idx(x, y)]
|
||||
}
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn check_frame(img: &Image, k: u64, tol: f32) {
|
||||
let corner = px(img, 2, 2);
|
||||
assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}");
|
||||
let c = px(img, img.width / 2, img.height / 2);
|
||||
let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG };
|
||||
assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}");
|
||||
}
|
||||
|
||||
/// Import our file through the media layer and check every frame.
|
||||
fn decode_movie(path: &Path, tol: f32) -> effectcraft_project::Footage {
|
||||
let f = effectcraft_media::probe(path).expect("probe our output");
|
||||
assert_eq!((f.width, f.height), (W, H));
|
||||
assert!(f.has_video);
|
||||
let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1;
|
||||
assert_eq!(n as u64, FRAMES, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate);
|
||||
let pool = MediaPool::new();
|
||||
for k in 0..FRAMES {
|
||||
let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode");
|
||||
check_frame(&img, k, tol);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn have(tool: &str) -> bool {
|
||||
Command::new(tool).arg("-version").output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `codec|w|h|pix_fmt|frames` of the video stream, or `None` without ffprobe.
|
||||
fn ffprobe_video(path: &Path) -> Option<String> {
|
||||
if !have("ffprobe") {
|
||||
eprintln!("ffprobe not found: skipping the oracle");
|
||||
return None;
|
||||
}
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=codec_name,width,height,pix_fmt,nb_read_frames"])
|
||||
.args(["-of", "compact=p=0:nk=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.expect("ffprobe");
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Decode every frame with ffmpeg (no errors allowed) and check frames 0, 4, 5 and the last.
|
||||
fn ffmpeg_check(path: &Path, decoder: Option<&str>, tol: f32) {
|
||||
if !have("ffmpeg") {
|
||||
eprintln!("ffmpeg not found: skipping the oracle decode");
|
||||
return;
|
||||
}
|
||||
let dir = path.with_extension("ffmpeg");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error", "-xerror", "-y"]);
|
||||
if let Some(d) = decoder {
|
||||
cmd.args(["-c:v", d]);
|
||||
}
|
||||
let out = cmd.arg("-i").arg(path).args(["-pix_fmt", "rgba"]).arg(dir.join("f%03d.png")).output().expect("ffmpeg");
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
for k in [0, 4, 5, FRAMES - 1] {
|
||||
let img = image::open(dir.join(format!("f{:03}.png", k + 1))).expect("ffmpeg frame").to_rgba8();
|
||||
check_frame(&Image::from_rgba8(img.width(), img.height(), img.as_raw()), k, tol);
|
||||
}
|
||||
}
|
||||
|
||||
fn module(format: OutputFormat, keyint: u32) -> OutputModule {
|
||||
let mut om = OutputModule::for_format(format);
|
||||
om.keyframe_interval = keyint;
|
||||
om.codec.rate_control = RateControlMode::Quality;
|
||||
om.codec.quality = 80;
|
||||
om.audio = AudioOutput::Off;
|
||||
om
|
||||
}
|
||||
|
||||
/// Key-frame flags of the MP4 video samples (via ffprobe), or `None` without it.
|
||||
fn ffprobe_keyframes(path: &Path) -> Option<Vec<bool>> {
|
||||
if !have("ffprobe") {
|
||||
return None;
|
||||
}
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=flags", "-of", "csv=p=0"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.expect("ffprobe");
|
||||
Some(String::from_utf8_lossy(&out.stdout).lines().map(|l| l.starts_with('K')).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_mp4_roundtrip() {
|
||||
let d = out_dir("hevc");
|
||||
let (p, cid) = project(None);
|
||||
let om = module(OutputFormat::Hevc, 4);
|
||||
let path = d.join("comp.mp4");
|
||||
let r = run(&p, cid, &NoFootage, &om, &path);
|
||||
assert!(r.bytes > 100 && !r.audio);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.windows(4).any(|w| w == b"hvc1") && bytes.windows(4).any(|w| w == b"hvcC"));
|
||||
decode_movie(&path, 0.08);
|
||||
if let Some(s) = ffprobe_video(&path) {
|
||||
assert_eq!(s, "hevc|64|48|yuv420p|12");
|
||||
}
|
||||
if let Some(k) = ffprobe_keyframes(&path) {
|
||||
assert_eq!(k, (0..FRAMES).map(|i| i % 4 == 0).collect::<Vec<_>>(), "IDR every 4 frames");
|
||||
}
|
||||
ffmpeg_check(&path, None, 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_main10_bitrate_mode() {
|
||||
let d = out_dir("hevc10");
|
||||
let (p, cid) = project(None);
|
||||
let mut om = module(OutputFormat::Hevc, 0);
|
||||
om.codec.profile = CodecProfile::Main10;
|
||||
om.codec.rate_control = RateControlMode::Bitrate;
|
||||
om.bitrate_kbps = 2_000;
|
||||
om.codec.level = Some(31);
|
||||
let path = d.join("comp10.mp4");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
decode_movie(&path, 0.08);
|
||||
if let Some(s) = ffprobe_video(&path) {
|
||||
assert_eq!(s, "hevc|64|48|yuv420p10le|12");
|
||||
}
|
||||
ffmpeg_check(&path, None, 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_mp4_roundtrip() {
|
||||
let d = out_dir("av1");
|
||||
let (p, cid) = project(None);
|
||||
let om = module(OutputFormat::Av1, 6);
|
||||
let path = d.join("comp.mp4");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.windows(4).any(|w| w == b"av01") && bytes.windows(4).any(|w| w == b"av1C"));
|
||||
decode_movie(&path, 0.08);
|
||||
if let Some(s) = ffprobe_video(&path) {
|
||||
assert_eq!(s, "av1|64|48|yuv420p|12");
|
||||
}
|
||||
if let Some(k) = ffprobe_keyframes(&path) {
|
||||
assert_eq!(k, (0..FRAMES).map(|i| i % 6 == 0).collect::<Vec<_>>(), "key frame every 6 frames");
|
||||
}
|
||||
ffmpeg_check(&path, Some("libdav1d"), 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_webm_with_opus() {
|
||||
let d = out_dir("av1-webm");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
let mut om = module(OutputFormat::WebM, 0);
|
||||
om.webm_codec = WebmVideoCodec::Av1;
|
||||
om.codec.profile = CodecProfile::Main10;
|
||||
om.audio = AudioOutput::Auto;
|
||||
om.opus_bitrate_kbps = 96;
|
||||
let path = d.join("comp.webm");
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio);
|
||||
let f = decode_movie(&path, 0.08);
|
||||
assert!(f.has_audio);
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let l = rms(s.iter().step_by(2).copied());
|
||||
assert!((l - 0.3536).abs() < 0.06, "left RMS {l}");
|
||||
if let Some(s) = ffprobe_video(&path) {
|
||||
assert_eq!(s, "av1|64|48|yuv420p10le|12");
|
||||
}
|
||||
ffmpeg_check(&path, Some("libdav1d"), 0.08);
|
||||
}
|
||||
|
||||
/// Low-bitrate WebM audio goes through Opus SILK (voice) or hybrid (audio) coding and still
|
||||
/// decodes to the tone (FilmCraft's decoder, and ffmpeg when installed).
|
||||
#[test]
|
||||
fn webm_low_bitrate_opus_modes() {
|
||||
let d = out_dir("opus-modes");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (kbps, app, name) in [(24u32, OpusApplication::Voip, "voice24.webm"), (40, OpusApplication::Audio, "audio40.webm")] {
|
||||
let mut om = OutputModule::for_format(OutputFormat::WebM);
|
||||
om.audio = AudioOutput::On;
|
||||
om.opus_bitrate_kbps = kbps;
|
||||
om.opus_application = app;
|
||||
let path = d.join(name);
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let l = rms(s.iter().step_by(2).copied());
|
||||
assert!((l - 0.3536).abs() < 0.08, "{name}: left RMS {l}");
|
||||
if have("ffmpeg") {
|
||||
let out =
|
||||
Command::new("ffmpeg").args(["-v", "error", "-xerror", "-i"]).arg(&path).args(["-map", "0:a", "-f", "null", "-"]).output().expect("ffmpeg");
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rms(v: impl Iterator<Item = f32>) -> f32 {
|
||||
let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1));
|
||||
(s / n.max(1) as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right).
|
||||
fn write_tone(path: &Path, secs: f64) {
|
||||
let sr = 48_000u32;
|
||||
let n = (secs * sr as f64) as usize;
|
||||
let mut data = Vec::with_capacity(n * 4);
|
||||
for i in 0..n {
|
||||
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
data.extend_from_slice(&0i16.to_le_bytes());
|
||||
}
|
||||
let mut w = Vec::new();
|
||||
w.extend_from_slice(b"RIFF");
|
||||
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(b"WAVEfmt ");
|
||||
w.extend_from_slice(&16u32.to_le_bytes());
|
||||
w.extend_from_slice(&1u16.to_le_bytes());
|
||||
w.extend_from_slice(&2u16.to_le_bytes());
|
||||
w.extend_from_slice(&sr.to_le_bytes());
|
||||
w.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
w.extend_from_slice(&4u16.to_le_bytes());
|
||||
w.extend_from_slice(&16u16.to_le_bytes());
|
||||
w.extend_from_slice(b"data");
|
||||
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(&data);
|
||||
std::fs::write(path, w).expect("write wav");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
mod opus_roundtrip;
|
||||
mod render_settings;
|
||||
mod roundtrip;
|
||||
mod uncompressed_mov;
|
||||
mod vp9_roundtrip;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Tiny native WAV to ProRes PCM MOV sample-position proof.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingNative {
|
||||
pool: MediaPool,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate));
|
||||
Some(self.pool.audio_samples(footage, start, frames, rate))
|
||||
}
|
||||
}
|
||||
fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> {
|
||||
let mut pcm = Vec::with_capacity(native_rate as usize * 8);
|
||||
for index in 0..i64::from(native_rate) {
|
||||
let pair = if impulses {
|
||||
let value: f32 = if index == first {
|
||||
0.75
|
||||
} else if index == last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
[value, -value]
|
||||
} else {
|
||||
[(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0]
|
||||
};
|
||||
pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes));
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend((36 + pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16u32.to_le_bytes());
|
||||
wav.extend(3u16.to_le_bytes());
|
||||
wav.extend(2u16.to_le_bytes());
|
||||
wav.extend(native_rate.to_le_bytes());
|
||||
wav.extend((native_rate * 8).to_le_bytes());
|
||||
wav.extend(8u16.to_le_bytes());
|
||||
wav.extend(32u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend((pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(pcm);
|
||||
wav.into()
|
||||
}
|
||||
fn boxes(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let size = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let name = bytes[offset + 4..offset + 8].try_into().unwrap();
|
||||
let (size, header) = if size == 1 {
|
||||
(usize::try_from(u64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().unwrap())).unwrap(), 16)
|
||||
} else if size == 0 {
|
||||
(bytes.len() - offset, 8)
|
||||
} else {
|
||||
(size as usize, 8)
|
||||
};
|
||||
assert!(size >= header && offset + size <= bytes.len(), "valid returned MOV boxes");
|
||||
out.push((name, &bytes[offset + header..offset + size]));
|
||||
offset += size;
|
||||
}
|
||||
assert_eq!(offset, bytes.len());
|
||||
out
|
||||
}
|
||||
fn child<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
boxes(bytes).into_iter().find(|(kind, _)| kind == name).unwrap().1
|
||||
}
|
||||
fn movie_pcm_count(bytes: &[u8], rate: u32, frames: usize) {
|
||||
let moov = child(bytes, b"moov");
|
||||
let mdia = boxes(moov)
|
||||
.into_iter()
|
||||
.filter(|(kind, _)| kind == b"trak")
|
||||
.map(|(_, track)| child(track, b"mdia"))
|
||||
.find(|media| &child(media, b"hdlr")[8..12] == b"soun")
|
||||
.expect("the returned MOV has a sound track");
|
||||
let header = child(mdia, b"mdhd");
|
||||
let (timescale, duration) = if header[0] == 1 {
|
||||
(u32::from_be_bytes(header[20..24].try_into().unwrap()), u64::from_be_bytes(header[24..32].try_into().unwrap()))
|
||||
} else {
|
||||
(u32::from_be_bytes(header[12..16].try_into().unwrap()), u64::from(u32::from_be_bytes(header[16..20].try_into().unwrap())))
|
||||
};
|
||||
assert_eq!(timescale, rate, "PCM MOV audio timescale");
|
||||
assert_eq!(duration, frames as u64, "PCM MOV selected sample count");
|
||||
let table = child(child(mdia, b"minf"), b"stbl");
|
||||
let timing = child(table, b"stts");
|
||||
let count = u32::from_be_bytes(timing[4..8].try_into().unwrap()) as usize;
|
||||
let duration: u64 = timing[8..]
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|entry| u64::from(u32::from_be_bytes(entry[..4].try_into().unwrap())) * u64::from(u32::from_be_bytes(entry[4..].try_into().unwrap())))
|
||||
.sum();
|
||||
assert_eq!(duration, frames as u64, "all encoded PCM samples are counted");
|
||||
let sizes = child(table, b"stsz");
|
||||
let fixed = u32::from_be_bytes(sizes[4..8].try_into().unwrap()) as u64;
|
||||
let count = u32::from_be_bytes(sizes[8..12].try_into().unwrap()) as usize;
|
||||
let bytes = if fixed != 0 {
|
||||
fixed * count as u64
|
||||
} else {
|
||||
sizes[12..].as_chunks::<4>().0.iter().take(count).map(|entry| u64::from(u32::from_be_bytes(*entry))).sum()
|
||||
};
|
||||
assert_eq!(bytes, (frames * 8) as u64, "native stereo float PCM bytes");
|
||||
}
|
||||
fn first_sample_tick(index: i64, rate: u32) -> Tick {
|
||||
let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND);
|
||||
let denominator = i128::from(rate);
|
||||
Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64)
|
||||
}
|
||||
fn check(native_rate: u32, output_rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Keep the production exporter's floor-to-sample span selection. The oracle below requests
|
||||
// that same first selected integer sample directly from the actual native decoder.
|
||||
let first = span.0.to_units_floor(i64::from(output_rate));
|
||||
let end = span.1.to_units_floor(i64::from(output_rate));
|
||||
let frames = usize::try_from(end - first).unwrap();
|
||||
let impulses = native_rate == output_rate;
|
||||
let bytes = native_wav(native_rate, first, end - 1, impulses);
|
||||
let mut mismatches = Vec::new();
|
||||
for loops in [1, 2] {
|
||||
let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav");
|
||||
let mut footage = probe_bytes(&path, bytes.clone()).unwrap();
|
||||
assert!(footage.has_audio && !footage.missing);
|
||||
footage.loop_count = loops;
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes.clone());
|
||||
let oracle_tick = first_sample_tick(first, output_rate);
|
||||
assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first);
|
||||
let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate);
|
||||
assert_eq!(expected.len(), frames * 2);
|
||||
assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent");
|
||||
if impulses {
|
||||
assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse");
|
||||
assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse");
|
||||
}
|
||||
let provider = RecordingNative { pool, calls: Mutex::default() };
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage));
|
||||
let mut comp = Comp::new(16, 16, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (16, 16), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
output.audio_sample_rate = output_rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "native-impulse.mov",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio);
|
||||
let files = files.lock().unwrap();
|
||||
let movie = &files.iter().find(|(name, _)| name.ends_with(".mov")).unwrap().1;
|
||||
assert_eq!(report.frames, 1, "the one-frame ProRes movie was actually encoded");
|
||||
movie_pcm_count(movie, output_rate, frames);
|
||||
let decoded = probe_bytes("returned-native-impulse.mov", Arc::from(movie.as_slice())).unwrap();
|
||||
assert!(decoded.has_audio && decoded.has_video, "the native movie decoder recognized both tracks");
|
||||
let decoder = MediaPool::new();
|
||||
decoder.add_bytes(&decoded.path, Arc::from(movie.as_slice()));
|
||||
let decoded_pcm = decoder.audio_samples(&decoded, Tick::ZERO, frames + 8, output_rate);
|
||||
assert_eq!(decoded_pcm.len(), (frames + 8) * 2);
|
||||
assert!(decoded_pcm[frames * 2..].iter().all(|value| *value == 0.0), "native PCM ends at the selected sample count");
|
||||
let actual = decoded_pcm[..frames * 2].to_vec();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
assert!(!calls.is_empty(), "production export actually called the real native provider");
|
||||
eprintln!(
|
||||
"native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}",
|
||||
&actual[..2],
|
||||
&expected[..2],
|
||||
&actual[actual.len() - 2..],
|
||||
&expected[expected.len() - 2..]
|
||||
);
|
||||
if actual != expected {
|
||||
let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b);
|
||||
mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec()));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "production native MOV PCM export must preserve the native provider's selected sample positions: {mismatches:?}");
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_44100_positions() {
|
||||
check(44_100, 44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_48000_positions() {
|
||||
check(48_000, 48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_44117_positions() {
|
||||
check(44_117, 44_117);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging
|
||||
//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It
|
||||
//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's
|
||||
//! rate) renders every source frame once, and the H.264 output has that rate and frame count.
|
||||
//!
|
||||
//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing
|
||||
//! their index in binary (eight bars, white for a set bit).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
|
||||
const W: u32 = 128;
|
||||
const H: u32 = 32;
|
||||
const BARS: u32 = 8;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds.
|
||||
fn ms_rounded(rate: FrameRate, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num);
|
||||
(0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect()
|
||||
}
|
||||
|
||||
/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms);
|
||||
/// frame k shows k in binary.
|
||||
fn write_movie(path: &Path, durations: &[u32]) {
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
cfg.bframes = 0;
|
||||
cfg.keyint = 30;
|
||||
cfg.scenecut = false;
|
||||
let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder");
|
||||
let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]);
|
||||
let mut packets = Vec::new();
|
||||
for k in 0..durations.len() {
|
||||
let y: Vec<u8> = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect();
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 };
|
||||
packets.extend(enc.try_encode(&frame, k as i64).expect("encode"));
|
||||
}
|
||||
packets.extend(enc.flush());
|
||||
assert_eq!(packets.len(), durations.len(), "one access unit per frame");
|
||||
let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC");
|
||||
let file = std::fs::File::create(path).expect("create");
|
||||
let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux");
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track");
|
||||
for (p, &duration) in packets.iter().zip(durations) {
|
||||
mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample");
|
||||
}
|
||||
mux.finish().expect("finish");
|
||||
}
|
||||
|
||||
/// The frame index a decoded frame shows.
|
||||
fn shown(img: &Image) -> u32 {
|
||||
(0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum()
|
||||
}
|
||||
|
||||
fn frame_count(f: &Footage) -> i64 {
|
||||
f.frame_rate.frame_at(f.duration - Tick(1)) + 1
|
||||
}
|
||||
|
||||
/// Every frame of a movie in order, as indices.
|
||||
fn frames_of(f: &Footage) -> Vec<u32> {
|
||||
let pool = MediaPool::new();
|
||||
(0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect()
|
||||
}
|
||||
|
||||
/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate
|
||||
/// and duration), render it to H.264 and return the output's footage.
|
||||
fn comp_from_footage(path: &Path, out: &Path) -> Footage {
|
||||
let footage = effectcraft_media::probe(path).expect("probe");
|
||||
let mut p = Project::default();
|
||||
let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone()));
|
||||
let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate");
|
||||
let mut comp = Comp::new(W, H, rate, footage.duration);
|
||||
comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None));
|
||||
let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let pool = MediaPool::new();
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let out_path = out.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &pool,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: &out_path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames");
|
||||
effectcraft_media::probe(out).expect("probe the render")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() {
|
||||
let d = out_dir("ms_rounded");
|
||||
for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, frames));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage");
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into();
|
||||
let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes");
|
||||
assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory");
|
||||
let all: Vec<u32> = (0..frames as u32).collect();
|
||||
assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once");
|
||||
|
||||
let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num)));
|
||||
assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render");
|
||||
assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration");
|
||||
assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() {
|
||||
let d = out_dir("ms_rounded_controls");
|
||||
for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, 60));
|
||||
assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate);
|
||||
}
|
||||
// Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands.
|
||||
let path = d.join("variable.mp4");
|
||||
write_movie(&path, &[40, 45, 50].repeat(20));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Native MediaPool decoding and native WAV export preserve selected sample positions.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingNative {
|
||||
pool: MediaPool,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate));
|
||||
Some(self.pool.audio_samples(footage, start, frames, rate))
|
||||
}
|
||||
}
|
||||
fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> {
|
||||
let mut pcm = Vec::with_capacity(native_rate as usize * 8);
|
||||
for index in 0..i64::from(native_rate) {
|
||||
let pair = if impulses {
|
||||
let value: f32 = if index == first {
|
||||
0.75
|
||||
} else if index == last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
[value, -value]
|
||||
} else {
|
||||
[(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0]
|
||||
};
|
||||
pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes));
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend((36 + pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16u32.to_le_bytes());
|
||||
wav.extend(3u16.to_le_bytes());
|
||||
wav.extend(2u16.to_le_bytes());
|
||||
wav.extend(native_rate.to_le_bytes());
|
||||
wav.extend((native_rate * 8).to_le_bytes());
|
||||
wav.extend(8u16.to_le_bytes());
|
||||
wav.extend(32u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend((pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(pcm);
|
||||
wav.into()
|
||||
}
|
||||
fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let mut offset = 12;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize;
|
||||
if &bytes[offset..offset + 4] == name {
|
||||
return &bytes[offset + 8..offset + 8 + length];
|
||||
}
|
||||
offset += 8 + length + length % 2;
|
||||
}
|
||||
panic!("native WAV chunk missing");
|
||||
}
|
||||
fn first_sample_tick(index: i64, rate: u32) -> Tick {
|
||||
let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND);
|
||||
let denominator = i128::from(rate);
|
||||
Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64)
|
||||
}
|
||||
fn check(native_rate: u32, output_rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Keep the production exporter's floor-to-sample span selection. The oracle below requests
|
||||
// that same first selected integer sample directly from the actual native decoder.
|
||||
let first = span.0.to_units_floor(i64::from(output_rate));
|
||||
let end = span.1.to_units_floor(i64::from(output_rate));
|
||||
let frames = usize::try_from(end - first).unwrap();
|
||||
let impulses = native_rate == output_rate;
|
||||
let bytes = native_wav(native_rate, first, end - 1, impulses);
|
||||
let mut mismatches = Vec::new();
|
||||
for loops in [1, 2] {
|
||||
let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav");
|
||||
let mut footage = probe_bytes(&path, bytes.clone()).unwrap();
|
||||
assert!(footage.has_audio && !footage.missing);
|
||||
footage.loop_count = loops;
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes.clone());
|
||||
let oracle_tick = first_sample_tick(first, output_rate);
|
||||
assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first);
|
||||
let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate);
|
||||
assert_eq!(expected.len(), frames * 2);
|
||||
assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent");
|
||||
if impulses {
|
||||
assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse");
|
||||
assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse");
|
||||
}
|
||||
let provider = RecordingNative { pool, calls: Mutex::default() };
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage));
|
||||
let mut comp = Comp::new(4, 4, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (4, 4), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::Wav);
|
||||
output.audio_sample_rate = output_rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "native-impulse.wav",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio);
|
||||
let files = files.lock().unwrap();
|
||||
let wav = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1;
|
||||
assert_eq!(&wav[..4], b"RIFF");
|
||||
assert_eq!(&wav[8..12], b"WAVE");
|
||||
let format = chunk(wav, b"fmt ");
|
||||
assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3);
|
||||
assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), output_rate);
|
||||
let pcm = chunk(wav, b"data");
|
||||
assert_eq!(pcm.len(), frames * 8, "native work-area sample count");
|
||||
let actual: Vec<f32> = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
assert!(!calls.is_empty(), "production export actually called the real native provider");
|
||||
eprintln!(
|
||||
"native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}",
|
||||
&actual[..2],
|
||||
&expected[..2],
|
||||
&actual[actual.len() - 2..],
|
||||
&expected[expected.len() - 2..]
|
||||
);
|
||||
if actual != expected {
|
||||
let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b);
|
||||
mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec()));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "production native WAV export must preserve the native provider's selected sample positions: {mismatches:?}");
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_44100_positions() {
|
||||
check(44_100, 44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_48000_positions() {
|
||||
check(48_000, 48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_44117_positions() {
|
||||
check(44_117, 44_117);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Round trip of `effectcraft-opusenc` through FilmCraft's spec-derived Opus decoder, plus an
|
||||
//! ffmpeg oracle (skipped when ffmpeg is not installed) reading an Ogg Opus file written here.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_opusenc::OpusEncoder;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const FRAME: usize = OpusEncoder::FRAME_SIZE;
|
||||
|
||||
/// Encodes interleaved `pcm`, padding the tail so the encoder's look-ahead is flushed.
|
||||
fn encode(pcm: &[f32], channels: u8, bitrate: u32) -> (OpusEncoder, Vec<Vec<u8>>) {
|
||||
let c = channels as usize;
|
||||
let mut enc = OpusEncoder::new(channels, bitrate);
|
||||
let total = pcm.len() / c + enc.pre_skip() as usize;
|
||||
let frames = total.div_ceil(FRAME);
|
||||
let mut padded = pcm.to_vec();
|
||||
padded.resize(frames * FRAME * c, 0.0);
|
||||
let packets = padded.chunks(FRAME * c).map(|f| enc.encode_float(f)).collect();
|
||||
(enc, packets)
|
||||
}
|
||||
|
||||
/// Decodes with filmcraft-opus, drops the pre-skip and returns planar channels of `len` samples.
|
||||
fn decode(head: &[u8], packets: &[Vec<u8>], len: usize) -> Vec<Vec<f32>> {
|
||||
let mut dec = filmcraft_opus::Decoder::new(head).expect("OpusHead");
|
||||
dec.set_trim_pre_skip(true);
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::new(); dec.channels()];
|
||||
for p in packets {
|
||||
let planes = dec.decode(Some(p)).expect("decode");
|
||||
for (o, p) in out.iter_mut().zip(planes) {
|
||||
o.extend_from_slice(&p);
|
||||
}
|
||||
}
|
||||
for o in &mut out {
|
||||
assert!(o.len() >= len, "decoded {} < {len}", o.len());
|
||||
o.truncate(len);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn roundtrip(planar: &[Vec<f32>], bitrate: u32) -> (Vec<Vec<u8>>, Vec<Vec<f32>>) {
|
||||
let c = planar.len();
|
||||
let len = planar[0].len();
|
||||
let mut pcm = vec![0f32; len * c];
|
||||
for (ch, p) in planar.iter().enumerate() {
|
||||
for (i, &v) in p.iter().enumerate() {
|
||||
pcm[i * c + ch] = v;
|
||||
}
|
||||
}
|
||||
let (enc, packets) = encode(&pcm, c as u8, bitrate);
|
||||
let decoded = decode(&enc.opus_head(), &packets, len);
|
||||
(packets, decoded)
|
||||
}
|
||||
|
||||
/// SNR in dB over the steady part (the first and last 20 ms are excluded).
|
||||
fn snr(orig: &[f32], dec: &[f32]) -> f64 {
|
||||
let (a, b) = (FRAME, orig.len() - FRAME);
|
||||
let mut s = 0f64;
|
||||
let mut e = 0f64;
|
||||
for i in a..b {
|
||||
s += (orig[i] as f64).powi(2);
|
||||
e += (orig[i] as f64 - dec[i] as f64).powi(2);
|
||||
}
|
||||
10.0 * (s / e.max(1e-30)).log10()
|
||||
}
|
||||
|
||||
fn sine(freq: f32, amp: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| amp * (2.0 * std::f32::consts::PI * freq * i as f32 / SR).sin()).collect()
|
||||
}
|
||||
|
||||
fn chirp(f0: f32, f1: f32, amp: f32, len: usize) -> Vec<f32> {
|
||||
let dur = len as f32 / SR;
|
||||
let k = (f1 - f0) / dur;
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
let t = i as f32 / SR;
|
||||
amp * (2.0 * std::f32::consts::PI * (f0 * t + 0.5 * k * t * t)).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn noise(amp: f32, len: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
amp * ((s >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Energy (dB) of `x` in each of a few octave bands up to 16 kHz, from a Hann-windowed FFT
|
||||
/// averaged over 2048-sample blocks.
|
||||
fn band_energies(x: &[f32]) -> Vec<f64> {
|
||||
const N: usize = 2048;
|
||||
let edges = [100.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
let mut acc = vec![0f64; edges.len() - 1];
|
||||
let mut start = N;
|
||||
while start + N <= x.len() - N {
|
||||
let mut re: Vec<f64> = (0..N).map(|i| x[start + i] as f64 * (0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / N as f64).cos())).collect();
|
||||
let mut im = vec![0f64; N];
|
||||
fft(&mut re, &mut im);
|
||||
for k in 1..N / 2 {
|
||||
let f = k as f64 * SR as f64 / N as f64;
|
||||
if let Some(b) = edges.windows(2).position(|w| f >= w[0] && f < w[1]) {
|
||||
acc[b] += re[k] * re[k] + im[k] * im[k];
|
||||
}
|
||||
}
|
||||
start += N;
|
||||
}
|
||||
acc.iter().map(|e| 10.0 * (e + 1e-20).log10()).collect()
|
||||
}
|
||||
|
||||
fn fft(re: &mut [f64], im: &mut [f64]) {
|
||||
let n = re.len();
|
||||
let mut j = 0;
|
||||
for i in 1..n {
|
||||
let mut bit = n >> 1;
|
||||
while j & bit != 0 {
|
||||
j ^= bit;
|
||||
bit >>= 1;
|
||||
}
|
||||
j |= bit;
|
||||
if i < j {
|
||||
re.swap(i, j);
|
||||
im.swap(i, j);
|
||||
}
|
||||
}
|
||||
let mut len = 2;
|
||||
while len <= n {
|
||||
let ang = -2.0 * std::f64::consts::PI / len as f64;
|
||||
for s in (0..n).step_by(len) {
|
||||
for k in 0..len / 2 {
|
||||
let (wr, wi) = ((ang * k as f64).cos(), (ang * k as f64).sin());
|
||||
let (a, b) = (s + k, s + k + len / 2);
|
||||
let tr = re[b] * wr - im[b] * wi;
|
||||
let ti = re[b] * wi + im[b] * wr;
|
||||
re[b] = re[a] - tr;
|
||||
im[b] = im[a] - ti;
|
||||
re[a] += tr;
|
||||
im[a] += ti;
|
||||
}
|
||||
}
|
||||
len <<= 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sine_440_mono() {
|
||||
let x = sine(440.0, 0.5, 48_000);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (packets, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
assert!(packets.iter().all(|p| p.len() == rate as usize / 400));
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("sine 440 Hz mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 20.0, "sine SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chirp_mono() {
|
||||
let x = chirp(100.0, 10_000.0, 0.4, 96_000);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("chirp 100 Hz-10 kHz mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "chirp SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_noise_mono() {
|
||||
let x = noise(0.3, 48_000, 12345);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
let (eo, ed) = (band_energies(&x), band_energies(&d[0]));
|
||||
let err: Vec<f32> = x.iter().zip(&d[0]).map(|(a, b)| a - b).collect();
|
||||
let ee = band_energies(&err);
|
||||
let octave_snr: Vec<f64> = eo.iter().zip(&ee).map(|(a, b)| a - b).collect();
|
||||
let worst = eo.iter().zip(&ed).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
|
||||
let min_octave = octave_snr.iter().copied().fold(f64::MAX, f64::min);
|
||||
eprintln!(
|
||||
"white noise mono @ {} kb/s: full-band SNR {s:.1} dB, octave SNRs 100 Hz-16 kHz {:.1?} dB, worst octave energy error {worst:.2} dB",
|
||||
rate / 1000,
|
||||
octave_snr
|
||||
);
|
||||
// Full-band noise loses everything above 20 kHz (CELT's last band ends there) and spends
|
||||
// few bits per coefficient on 16-20 kHz, so the full-band waveform SNR is low; the band
|
||||
// energies must match closely and the octaves below 16 kHz must be waveform-coded.
|
||||
assert!(worst < 1.5, "band energy error {worst:.2} dB at {rate}: {eo:?} vs {ed:?}");
|
||||
assert!(s > 0.5, "noise SNR {s:.1} dB at {rate}");
|
||||
assert!(min_octave > if rate >= 128_000 { 10.0 } else { 3.0 }, "octave SNRs {octave_snr:?} at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowpass_noise_mono() {
|
||||
// White noise through a 4-pole low-pass at about 2 kHz: a dense spectrum the encoder can
|
||||
// afford to code as a waveform.
|
||||
let w = noise(0.8, 48_000, 999);
|
||||
let a = (-2.0 * std::f32::consts::PI * 2_000.0 / SR).exp();
|
||||
let mut st = [0f32; 4];
|
||||
let x: Vec<f32> = w
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let mut y = v;
|
||||
for s in st.iter_mut() {
|
||||
*s = (1.0 - a) * y + a * *s;
|
||||
y = *s;
|
||||
}
|
||||
y
|
||||
})
|
||||
.collect();
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("low-pass noise mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "low-pass noise SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_stays_silent() {
|
||||
let x = vec![0f32; 48_000];
|
||||
let (packets, d) = roundtrip(&[x.clone(), x], 128_000);
|
||||
assert!(packets.iter().all(|p| p.len() == 3), "silence frames are 3-byte packets");
|
||||
for ch in &d {
|
||||
let peak = ch.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak < 1e-6, "peak {peak}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_distinct_channels() {
|
||||
let l = sine(440.0, 0.5, 48_000);
|
||||
let r = chirp(200.0, 6_000.0, 0.3, 48_000);
|
||||
let (packets, d) = roundtrip(&[l.clone(), r.clone()], 256_000);
|
||||
assert!(packets.iter().all(|p| p[0] == 0xFC && p.len() == 640));
|
||||
let (sl, sr) = (snr(&l, &d[0]), snr(&r, &d[1]));
|
||||
eprintln!("stereo @ 256 kb/s: L (sine) SNR {sl:.1} dB, R (chirp) SNR {sr:.1} dB");
|
||||
assert!(sl > 20.0 && sr > 15.0, "stereo SNR L {sl:.1} R {sr:.1}");
|
||||
// No leakage of the chirp into the left channel (2-4 kHz octave: chirp only).
|
||||
let (el, er) = (band_energies(&d[0]), band_energies(&r));
|
||||
assert!(el[4] < er[4] - 30.0, "chirp energy leaked into L: {} vs {}", el[4], er[4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_128k_total() {
|
||||
let l = sine(1000.0, 0.4, 48_000);
|
||||
let r = sine(330.0, 0.4, 48_000);
|
||||
let (_, d) = roundtrip(&[l.clone(), r.clone()], 128_000);
|
||||
let (sl, sr) = (snr(&l, &d[0]), snr(&r, &d[1]));
|
||||
eprintln!("stereo @ 128 kb/s: L SNR {sl:.1} dB, R SNR {sr:.1} dB");
|
||||
assert!(sl > 15.0 && sr > 15.0, "stereo SNR L {sl:.1} R {sr:.1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_after_silence_and_loud_transient() {
|
||||
// Silence, then a burst at full scale, then silence again: energy prediction must recover.
|
||||
let mut x = vec![0f32; 24_000];
|
||||
x.extend(sine(2_000.0, 0.99, 24_000));
|
||||
x.extend(vec![0f32; 24_000]);
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), 96_000);
|
||||
let s = snr(&x[24_000 + 2 * FRAME..48_000], &d[0][24_000 + 2 * FRAME..48_000]);
|
||||
eprintln!("burst after silence @ 96 kb/s: SNR {s:.1} dB");
|
||||
assert!(s > 15.0, "burst SNR {s:.1}");
|
||||
let tail_peak = d[0][48_000 + 2 * FRAME..].iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(tail_peak < 1e-3, "tail peak {tail_peak}");
|
||||
}
|
||||
|
||||
// ---- Ogg Opus (RFC 3533 + RFC 7845) for the ffmpeg oracle ----
|
||||
|
||||
fn ogg_crc(data: &[u8]) -> u32 {
|
||||
let mut crc = 0u32;
|
||||
for &b in data {
|
||||
crc ^= (b as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
fn ogg_page(out: &mut Vec<u8>, packet: &[u8], header_type: u8, granule: u64, seq: u32) {
|
||||
let mut lacing = vec![255u8; packet.len() / 255];
|
||||
lacing.push((packet.len() % 255) as u8);
|
||||
let start = out.len();
|
||||
out.extend_from_slice(b"OggS");
|
||||
out.push(0);
|
||||
out.push(header_type);
|
||||
out.extend_from_slice(&granule.to_le_bytes());
|
||||
out.extend_from_slice(&0x4543_4F50u32.to_le_bytes()); // serial
|
||||
out.extend_from_slice(&seq.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // CRC placeholder
|
||||
out.push(lacing.len() as u8);
|
||||
out.extend_from_slice(&lacing);
|
||||
out.extend_from_slice(packet);
|
||||
let crc = ogg_crc(&out[start..]);
|
||||
out[start + 22..start + 26].copy_from_slice(&crc.to_le_bytes());
|
||||
}
|
||||
|
||||
fn ogg_opus(enc: &OpusEncoder, packets: &[Vec<u8>], len: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
ogg_page(&mut out, &enc.opus_head(), 0x02, 0, 0);
|
||||
let vendor = b"effectcraft-opusenc";
|
||||
let mut tags = b"OpusTags".to_vec();
|
||||
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
tags.extend_from_slice(vendor);
|
||||
tags.extend_from_slice(&0u32.to_le_bytes());
|
||||
ogg_page(&mut out, &tags, 0, 0, 1);
|
||||
let pre_skip = enc.pre_skip() as u64;
|
||||
for (i, p) in packets.iter().enumerate() {
|
||||
let last = i + 1 == packets.len();
|
||||
let granule = if last { pre_skip + len as u64 } else { ((i + 1) * FRAME) as u64 };
|
||||
ogg_page(&mut out, p, if last { 0x04 } else { 0 }, granule, i as u32 + 2);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ogg_crc_known_value() {
|
||||
// CRC-32 (poly 0x04C11DB7, no reflection, init 0) of "123456789".
|
||||
assert_eq!(ogg_crc(b"123456789"), 0x89A1_897F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_oracle_decodes_ogg_opus() {
|
||||
if Command::new("ffmpeg").arg("-version").output().is_err() {
|
||||
eprintln!("ffmpeg not installed; skipping the oracle test");
|
||||
return;
|
||||
}
|
||||
for (channels, rate) in [(1u8, 96_000u32), (2, 192_000)] {
|
||||
let len = 48_000;
|
||||
let planar: Vec<Vec<f32>> =
|
||||
if channels == 1 { vec![chirp(150.0, 9_000.0, 0.4, len)] } else { vec![sine(440.0, 0.5, len), chirp(150.0, 9_000.0, 0.3, len)] };
|
||||
let c = channels as usize;
|
||||
let mut pcm = vec![0f32; len * c];
|
||||
for (ch, p) in planar.iter().enumerate() {
|
||||
for (i, &v) in p.iter().enumerate() {
|
||||
pcm[i * c + ch] = v;
|
||||
}
|
||||
}
|
||||
let (enc, packets) = encode(&pcm, channels, rate);
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{channels}.opus", std::process::id()));
|
||||
std::fs::write(&path, ogg_opus(&enc, &packets, len)).unwrap();
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "f32le", "-ac", &channels.to_string(), "-ar", "48000", "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let dec: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
assert_eq!(dec.len(), len * c, "ffmpeg must honour pre-skip and the end granule");
|
||||
for ch in 0..c {
|
||||
let d: Vec<f32> = (0..len).map(|i| dec[i * c + ch]).collect();
|
||||
let s = snr(&planar[ch], &d);
|
||||
eprintln!("ffmpeg {channels} ch @ {} kb/s, channel {ch}: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "ffmpeg-decoded SNR {s:.1} dB (channel {ch})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches
|
||||
//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only
|
||||
//! and premultiplied output, the solo and guide-layer overrides, the render log, storage
|
||||
//! overflow, PCM audio formats and the compositor a job renders on.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_export::render_queue::*;
|
||||
use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::{Accelerator, AutoPick, FxStep, Image, NoFootage, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
|
||||
/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a
|
||||
/// static red block at the bottom left; `extra` adds layers.
|
||||
fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0));
|
||||
comp.background = [0.0, 0.0, 0.0];
|
||||
let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None);
|
||||
l.props.prop_mut("transform/position").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))];
|
||||
comp.layers.push(l);
|
||||
let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 }));
|
||||
let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None);
|
||||
b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]);
|
||||
comp.layers.push(b);
|
||||
extra(&mut p, &mut comp);
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn custom(start: f64, end: f64) -> RenderSettings {
|
||||
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
|
||||
}
|
||||
|
||||
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
|
||||
run_on(p, cid, s, om, path, options, None, cancel)
|
||||
}
|
||||
|
||||
/// [`run_with`] with an accelerator.
|
||||
fn run_on(
|
||||
p: &Project,
|
||||
cid: ItemId,
|
||||
s: &RenderSettings,
|
||||
om: &OutputModule,
|
||||
path: &str,
|
||||
options: JobOptions,
|
||||
accel: Option<&dyn Accelerator>,
|
||||
cancel: bool,
|
||||
) -> (Result<Report, String>, Files) {
|
||||
let files: Files = Default::default();
|
||||
let f = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel,
|
||||
comp: cid,
|
||||
settings: s,
|
||||
output: om,
|
||||
path,
|
||||
sink: Some(&*sink),
|
||||
options,
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string());
|
||||
(r, files)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files {
|
||||
let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false);
|
||||
r.expect("export");
|
||||
files
|
||||
}
|
||||
|
||||
fn png(files: &Files, i: usize) -> image::RgbaImage {
|
||||
let f = files.lock().unwrap();
|
||||
let pngs: Vec<&(String, Vec<u8>)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect();
|
||||
let mut pngs = pngs;
|
||||
pngs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
image::load_from_memory(&pngs[i].1).expect("png").to_rgba8()
|
||||
}
|
||||
|
||||
fn row(img: &image::RgbaImage, y: u32) -> Vec<u8> {
|
||||
(0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_render_interleaves_two_times() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
// Frame 10 at 25 fps with fields: 0.40 s and 0.42 s.
|
||||
for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] {
|
||||
let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) };
|
||||
let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0);
|
||||
// Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s).
|
||||
let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) };
|
||||
let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png");
|
||||
let (a, b) = (png(&refs, 0), png(&refs, 1));
|
||||
assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields");
|
||||
for y in 0..H {
|
||||
let first = (y % 2 == 0) == upper;
|
||||
assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulldown_follows_the_cadence() {
|
||||
let ntsc = FrameRate::new(30000, 1001);
|
||||
let (p, cid) = project(ntsc, |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let five = 5.0 * 1001.0 / 30000.0;
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) };
|
||||
let out = run(&p, cid, &s, &om, "/p/pd_[#####].png");
|
||||
assert_eq!(out.lock().unwrap().len(), 5);
|
||||
// Film frames at 23.976 fps.
|
||||
let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) };
|
||||
let film = run(&p, cid, &r, &om, "/p/film_[#####].png");
|
||||
let mut pattern = String::new();
|
||||
for k in 0..5 {
|
||||
let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap();
|
||||
pattern.push(if a == b { 'W' } else { 'S' });
|
||||
let img = png(&out, k);
|
||||
let (fa, fb) = (png(&film, a as usize), png(&film, b as usize));
|
||||
for y in 0..H {
|
||||
assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}");
|
||||
}
|
||||
}
|
||||
assert_eq!(pattern, "WSSWW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_roi_and_resize() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let s = custom(0.4, 0.44);
|
||||
let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0);
|
||||
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() };
|
||||
let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0);
|
||||
assert_eq!((c.width(), c.height()), (52, 38));
|
||||
for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] {
|
||||
assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})");
|
||||
}
|
||||
// Region of interest at half resolution.
|
||||
om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() };
|
||||
let half = RenderSettings { resolution: 0.5, ..s.clone() };
|
||||
let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0);
|
||||
assert_eq!((r.width(), r.height()), (16, 16));
|
||||
// Resize (fixed size, low and high quality) after the crop.
|
||||
om.crop = Crop::default();
|
||||
for q in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q };
|
||||
let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0);
|
||||
assert_eq!((z.width(), z.height()), (32, 20));
|
||||
// The red block (bottom left) survives the resize.
|
||||
let px = z.get_pixel(4, 18).0;
|
||||
assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}");
|
||||
}
|
||||
om.resize.lock_aspect = true;
|
||||
let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0);
|
||||
assert_eq!((z.width(), z.height()), (32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_only_and_premultiplied() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
// A half-transparent green layer over the right half.
|
||||
let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]);
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
|
||||
comp.layers.insert(0, l);
|
||||
});
|
||||
let s = custom(0.0, 0.04);
|
||||
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
om.channels = Channels::Alpha;
|
||||
let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0);
|
||||
assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green");
|
||||
assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent");
|
||||
assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block");
|
||||
om.channels = Channels::Rgba;
|
||||
let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0);
|
||||
om.alpha_mode = AlphaMode::Premultiplied;
|
||||
let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0);
|
||||
assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]);
|
||||
assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solo_and_guide_layer_overrides() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]);
|
||||
l.switches.guide = true;
|
||||
comp.layers.insert(0, l);
|
||||
// Solo the moving bar.
|
||||
comp.layers[1].switches.solo = true;
|
||||
});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = custom(0.4, 0.44);
|
||||
let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0);
|
||||
assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden");
|
||||
assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default");
|
||||
let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() };
|
||||
let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0);
|
||||
assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off");
|
||||
// (with solo off the guide renders too: Guide Layers: Current Settings)
|
||||
assert_eq!(all.get_pixel(56, 44).0[2], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_logs() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) };
|
||||
let log_of = |files: &Files| {
|
||||
files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap()))
|
||||
};
|
||||
// Errors Only + success: no log.
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false);
|
||||
assert!(r.unwrap().log.is_none());
|
||||
assert!(log_of(&files).is_none());
|
||||
// Plus Per Frame Info: settings and one line per frame.
|
||||
let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false);
|
||||
let r = r.unwrap();
|
||||
let (path, text) = log_of(&files).expect("log");
|
||||
assert_eq!(r.log.as_deref(), Some(path.as_str()));
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/b_RenderLog.txt"));
|
||||
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
|
||||
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
|
||||
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
|
||||
assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}");
|
||||
// Plus Settings: no per-frame lines.
|
||||
let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() };
|
||||
let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false);
|
||||
let (_, text) = log_of(&files).expect("log");
|
||||
assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}");
|
||||
// Errors Only + failure (the composition is gone): the error is logged.
|
||||
let gif = OutputModule::for_format(OutputFormat::Gif);
|
||||
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
|
||||
assert!(r.is_err());
|
||||
let (path, text) = log_of(&files).expect("error log");
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/d_RenderLog.txt"));
|
||||
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
|
||||
// A user stop is not an error: no log.
|
||||
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
|
||||
assert!(r.is_err());
|
||||
assert!(log_of(&files).is_none());
|
||||
}
|
||||
|
||||
/// The primary folder holds `limit` bytes; overflow folders are unlimited.
|
||||
struct Quota {
|
||||
limit: u64,
|
||||
used: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl StorageQuota for Quota {
|
||||
fn has_room(&self, path: &str, bytes: u64) -> bool {
|
||||
if !std::path::Path::new(path).starts_with("/primary") {
|
||||
return true;
|
||||
}
|
||||
let mut u = self.used.lock().unwrap();
|
||||
if *u + bytes > self.limit {
|
||||
return false;
|
||||
}
|
||||
*u += bytes;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_overflow_moves_files_to_the_overflow_folder() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) };
|
||||
let q = Quota { limit: 1, used: Mutex::new(0) };
|
||||
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false);
|
||||
let r = r.unwrap();
|
||||
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
|
||||
let f = files.lock().unwrap();
|
||||
assert_eq!(
|
||||
f.iter()
|
||||
.filter(|(p, _)| std::path::Path::new(p).parent() == Some(std::path::Path::new("/overflow"))
|
||||
&& std::path::Path::new(p).file_name().is_some_and(|n| n.to_string_lossy().starts_with("seq_"))
|
||||
&& p.ends_with(".png"))
|
||||
.count(),
|
||||
10
|
||||
);
|
||||
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
|
||||
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
|
||||
drop(f);
|
||||
// Use Storage Overflow off: everything stays in place.
|
||||
let off = RenderSettings { storage_overflow: false, ..s };
|
||||
let q = Quota { limit: 1, used: Mutex::new(0) };
|
||||
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
|
||||
assert!(r.unwrap().overflow.is_empty());
|
||||
assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(p).starts_with("/primary")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_channels_and_formats() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let s = custom(0.0, 0.5);
|
||||
let mut om = OutputModule::for_format(OutputFormat::Wav);
|
||||
om.audio_channels = 1;
|
||||
om.audio_format = AudioFormat::S24;
|
||||
om.audio_sample_rate = 44_100;
|
||||
let files = run(&p, cid, &s, &om, "/w/a.wav");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
assert_eq!(&d[..4], b"RIFF");
|
||||
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
||||
assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24));
|
||||
assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit");
|
||||
om.audio_channels = 2;
|
||||
om.audio_format = AudioFormat::F32;
|
||||
let files = run(&p, cid, &s, &om, "/w/b.wav");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo");
|
||||
// Plain AIFF has no float: 32-bit float is AIFF-C with `fl32` samples (#317).
|
||||
let mut aiff = OutputModule::for_format(OutputFormat::Aiff);
|
||||
aiff.audio_format = AudioFormat::F32;
|
||||
let files = run(&p, cid, &s, &aiff, "/w/c.aif");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
let u32_at = |o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
||||
assert_eq!((&d[..4], u32_at(4) as usize + 8, &d[8..12]), (&b"FORM"[..], d.len(), &b"AIFC"[..]));
|
||||
assert_eq!((&d[12..16], u32_at(16), u32_at(20)), (&b"FVER"[..], 4, 0xA280_5140));
|
||||
assert_eq!(&d[24..28], b"COMM");
|
||||
let comm = 32;
|
||||
assert_eq!(u32_at(28) as usize, 18 + 4 + 22, "COMM: AIFF fields, compression type, name");
|
||||
assert_eq!(u16::from_be_bytes([d[comm], d[comm + 1]]), 2, "stereo");
|
||||
assert_eq!(u32_at(comm + 2), 24_000, "half a second at 48 kHz");
|
||||
assert_eq!(u16::from_be_bytes([d[comm + 6], d[comm + 7]]), 32, "sample size");
|
||||
assert_eq!(&d[comm + 18..comm + 22], b"fl32");
|
||||
assert_eq!(&d[comm + 22..comm + 44], b"\x1532-bit floating point");
|
||||
let ssnd = comm + 44;
|
||||
assert_eq!((&d[ssnd..ssnd + 4], u32_at(ssnd + 4) as usize), (&b"SSND"[..], 8 + 24_000 * 2 * 4));
|
||||
// 16- and 24-bit stay plain AIFF.
|
||||
aiff.audio_format = AudioFormat::S24;
|
||||
let files = run(&p, cid, &s, &aiff, "/w/d.aif");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
assert_eq!(&d[8..12], b"AIFF");
|
||||
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_export_removes_only_files_it_opened() {
|
||||
use effectcraft_export::ExportError;
|
||||
let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
struct Overflow;
|
||||
impl StorageQuota for Overflow {
|
||||
fn has_room(&self, path: &str, _: u64) -> bool {
|
||||
path.contains("overflow")
|
||||
}
|
||||
}
|
||||
for overflow in [false, true] {
|
||||
let output_dir = if overflow { dir.join("overflow") } else { dir.clone() };
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
let settings = RenderSettings { skip_existing: true, storage_overflow: overflow, ..Default::default() };
|
||||
for format in [OutputFormat::PngSequence, OutputFormat::H264] {
|
||||
let om = OutputModule::for_format(format);
|
||||
let path = dir.join(if format.is_sequence() { "clip_[#####].png" } else { "clip.mp4" });
|
||||
let path = path.to_str().unwrap();
|
||||
let decoys = ["clip.aep", "clip2.psd", "clip000.mp4", "clip_99999.png", "clip_00000.png", "clip.txt", "clip_RenderLog.txt"];
|
||||
for name in decoys {
|
||||
std::fs::write(output_dir.join(name), b"keep").unwrap();
|
||||
}
|
||||
// Skip-existing checks the primary destination even when the new frames overflow.
|
||||
std::fs::write(dir.join("clip_00000.png"), b"keep").unwrap();
|
||||
let result = export(
|
||||
&Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path,
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: JobOptions {
|
||||
log: RenderLog::PlusPerFrameInfo,
|
||||
storage: Some(&Overflow),
|
||||
overflow: vec![output_dir.to_string_lossy().into_owned()],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
&mut |progress| progress.done == 0,
|
||||
);
|
||||
assert!(matches!(result, Err(ExportError::Cancelled)), "{result:?}");
|
||||
for name in decoys {
|
||||
assert_eq!(std::fs::read(output_dir.join(name)).unwrap(), b"keep", "{name}");
|
||||
}
|
||||
let remaining: Vec<_> = std::fs::read_dir(&output_dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(remaining.len(), decoys.len(), "partial files remain: {remaining:?}");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A GPU compositor stand-in that renders every frame solid green (the CPU draws the bar and the
|
||||
/// block on black) and keeps Auto's timing history.
|
||||
#[derive(Default)]
|
||||
struct GreenAccel {
|
||||
pick: AutoPick,
|
||||
frames: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Accelerator for GreenAccel {
|
||||
fn name(&self) -> String {
|
||||
"green".into()
|
||||
}
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, _t: Tick) -> Option<Image> {
|
||||
self.frames.fetch_add(1, Ordering::SeqCst);
|
||||
let c = r.project.comp(comp)?;
|
||||
Some(Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0]))
|
||||
}
|
||||
fn supports_effect(&self, _id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option<f32>) -> Option<Buf> {
|
||||
None
|
||||
}
|
||||
fn auto_pick(&self) -> Option<&AutoPick> {
|
||||
Some(&self.pick)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_renders_on_one_compositor() {
|
||||
// #414: Auto chose the CPU or the GPU for each frame from measured frame times, so GPU
|
||||
// renders of the same frames differed run to run. A job renders every frame on the
|
||||
// project's renderer.
|
||||
let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = custom(0.0, 0.4);
|
||||
let render = |p: &Project, accel: &GreenAccel, path: &str| {
|
||||
let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), Some(accel), false);
|
||||
r.expect("export");
|
||||
(0..10).map(|i| png(&files, i)).collect::<Vec<_>>()
|
||||
};
|
||||
// Mercury GPU Acceleration: every frame on the GPU, the same in every run.
|
||||
p.settings.gpu_acceleration = true;
|
||||
let gpu = GreenAccel::default();
|
||||
let a = render(&p, &gpu, "/g/a_[#####].png");
|
||||
assert_eq!(gpu.frames.load(Ordering::SeqCst), 10);
|
||||
for (i, f) in a.iter().enumerate() {
|
||||
assert!(f.pixels().all(|px| px.0 == [0, 255, 0, 255]), "frame {i} rendered on the CPU");
|
||||
}
|
||||
assert_eq!(a, render(&p, &gpu, "/g/b_[#####].png"));
|
||||
// Mercury Software Only: every frame on the CPU.
|
||||
p.settings.gpu_acceleration = false;
|
||||
let cpu = GreenAccel::default();
|
||||
let c = render(&p, &cpu, "/g/c_[#####].png");
|
||||
assert_eq!(cpu.frames.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block");
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Encode a short comp to every output format and decode it back: through EffectCraft's own
|
||||
//! media layer (FilmCraft decoders) for MP4/MOV, the `image`/`gif` crates for stills and GIF,
|
||||
//! and — when installed — ffprobe as an external oracle (skipped otherwise).
|
||||
//!
|
||||
//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48
|
||||
//! centred, starting at frame 5. So frame < 5 is background everywhere; from frame 5 the centre
|
||||
//! is red and the corners stay background.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, Progress, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{FootageSource, NoFootage};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
const BG: [f32; 3] = [0.1, 0.1, 0.4];
|
||||
const FRAMES: u64 = 12;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
fn project(audio: Option<&Path>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2));
|
||||
comp.background = BG;
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None);
|
||||
l.in_point = comp.frame_rate.tick_of(5);
|
||||
comp.layers.push(l);
|
||||
if let Some(path) = audio {
|
||||
let f = effectcraft_media::probe(path).expect("probe wav");
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
comp.layers.push(l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn settings() -> RenderSettings {
|
||||
RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
run_frames(p, cid, footage, om, path, FRAMES)
|
||||
}
|
||||
|
||||
/// [`run`] for a comp of `frames` frames.
|
||||
fn run_frames(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path, frames: u64) -> effectcraft_export::Report {
|
||||
let s = settings();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let mut last = Progress::default();
|
||||
let mut calls = 0;
|
||||
let r = export(&job, &mut |pr| {
|
||||
assert!(pr.done >= last.done, "progress is monotonic");
|
||||
last = *pr;
|
||||
calls += 1;
|
||||
true
|
||||
})
|
||||
.expect("export");
|
||||
assert_eq!(last.done, frames);
|
||||
assert_eq!(last.total, frames);
|
||||
assert!(calls >= 2);
|
||||
assert_eq!(r.frames, frames);
|
||||
r
|
||||
}
|
||||
|
||||
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[img.idx(x, y)]
|
||||
}
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
/// Check a decoded (premultiplied, opaque) frame `k`.
|
||||
fn check_frame(img: &Image, k: u64, tol: f32) {
|
||||
let corner = px(img, 2, 2);
|
||||
assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}");
|
||||
let c = px(img, img.width / 2, img.height / 2);
|
||||
let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG };
|
||||
assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}");
|
||||
}
|
||||
|
||||
fn decode_movie(path: &Path, frames: u64, tol: f32) -> effectcraft_project::Footage {
|
||||
let f = effectcraft_media::probe(path).expect("probe our output");
|
||||
assert_eq!((f.width, f.height), (W, H));
|
||||
assert!(f.has_video);
|
||||
let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1;
|
||||
assert_eq!(n as u64, frames, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate);
|
||||
let pool = MediaPool::new();
|
||||
for k in 0..frames {
|
||||
let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode");
|
||||
check_frame(&img, k, tol);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn ffprobe(path: &Path) -> Option<String> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-count_frames", "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "compact=p=0:nk=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
panic!("ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Frame `k` decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
|
||||
fn ffmpeg_frame(path: &Path, k: u64) -> Option<Image> {
|
||||
let png = path.with_extension(format!("ffmpeg-{k}.png"));
|
||||
let st = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(path)
|
||||
.args(["-vf", &format!("select=eq(n\\,{k})"), "-frames:v", "1", "-pix_fmt", "rgba"])
|
||||
.arg(&png)
|
||||
.status()
|
||||
.ok()?;
|
||||
assert!(st.success(), "ffmpeg failed to decode {}", path.display());
|
||||
let img = image::open(&png).expect("ffmpeg png").to_rgba8();
|
||||
Some(Image::from_rgba8(img.width(), img.height(), img.as_raw()))
|
||||
}
|
||||
|
||||
fn ffmpeg_check(path: &Path, tol: f32) {
|
||||
for k in [0, 4, 5, FRAMES - 1] {
|
||||
match ffmpeg_frame(path, k) {
|
||||
Some(img) => check_frame(&img, k, tol),
|
||||
None => {
|
||||
eprintln!("ffmpeg not found: skipping oracle decode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_mp4_roundtrip() {
|
||||
let d = out_dir("h264");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let path = d.join("comp.mp4");
|
||||
let r = run(&p, cid, &NoFootage, &om, &path);
|
||||
assert!(r.bytes > 100 && !r.audio);
|
||||
decode_movie(&path, FRAMES, 0.08);
|
||||
ffmpeg_check(&path, 0.08);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("h264|64|48|yuv420p"), "{s}");
|
||||
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_odd_size_is_cropped_even() {
|
||||
let d = out_dir("h264-odd");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule::for_format(OutputFormat::H264);
|
||||
let s = RenderSettings { resolution: 31.0 / 64.0, ..settings() };
|
||||
let comp = p.comp(cid).expect("comp");
|
||||
assert_eq!(s.output_size(comp).0 % 2, 1);
|
||||
let path = d.join("odd.mp4").to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!((r.width % 2, r.height % 2), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_mov_roundtrip() {
|
||||
let d = out_dir("prores");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule::for_format(OutputFormat::ProRes);
|
||||
let path = d.join("comp.mov");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
decode_movie(&path, FRAMES, 0.03);
|
||||
ffmpeg_check(&path, 0.03);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("prores|64|48"), "{s}");
|
||||
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_4444_with_alpha() {
|
||||
let d = out_dir("prores4444");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { channels: Channels::Rgba, prores_profile: ProResProfile::Hq, ..OutputModule::for_format(OutputFormat::ProRes) };
|
||||
let path = d.join("alpha.mov");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
let img = MediaPool::new().frame_at(&f, f.frame_rate.tick_of(7)).expect("decode");
|
||||
assert!(px(&img, 2, 2)[3] < 0.02, "corner transparent: {:?}", px(&img, 2, 2));
|
||||
let c = px(&img, W / 2, H / 2);
|
||||
assert!(c[3] > 0.98 && c[0] > 0.95 && c[1] < 0.05, "centre opaque red: {c:?}");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("prores|64|48|yuva444"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) {
|
||||
for k in 0..FRAMES {
|
||||
let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64);
|
||||
let img = image::open(&path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8();
|
||||
assert_eq!(img.dimensions(), (W, H));
|
||||
let img = Image::from_rgba8(W, H, img.as_raw());
|
||||
if alpha {
|
||||
let corner = px(&img, 2, 2);
|
||||
assert!(corner[3] < 0.01, "{path}: corner {corner:?}");
|
||||
let c = px(&img, W / 2, H / 2);
|
||||
assert_eq!(c[3] > 0.99, k >= 5, "{path}: centre {c:?}");
|
||||
} else {
|
||||
check_frame(&img, k, tol);
|
||||
}
|
||||
}
|
||||
assert!(!dir.join(format!("f_{:05}.{ext}", FRAMES)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequences_roundtrip() {
|
||||
let (p, cid) = project(None);
|
||||
for (fmt, ext, tol, ch) in [
|
||||
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgb),
|
||||
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgba),
|
||||
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
|
||||
] {
|
||||
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
|
||||
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
|
||||
let r = run(&p, cid, &NoFootage, &om, &d.join(format!("f_[#####].{ext}")));
|
||||
assert!(r.path.ends_with(&format!("f_00000.{ext}")), "{}", r.path);
|
||||
check_still_dir(&d, ext, tol, ch == Channels::Rgba);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_sequence_is_linear() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("seq-exr");
|
||||
let om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
|
||||
let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f();
|
||||
assert_eq!(img.dimensions(), (W, H));
|
||||
let c = img.get_pixel(2, 2).0;
|
||||
// sRGB 0.4 → linear ≈ 0.133
|
||||
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour.
|
||||
fn bright_project(color: [f32; 3]) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
|
||||
let sid = p.add_item("Bright", Label::Red, None, ItemKind::Solid(Solid { color, width: W, height: H, pixel_aspect: 1.0 }));
|
||||
let mut comp = comp;
|
||||
let l = build::layer(&mut p, &comp, "Bright", LayerSource::Solid { item: sid }, (W, H), None);
|
||||
comp.layers.push(l);
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Export one frame of a 32 bpc solid to an EXR sequence and read its centre pixel.
|
||||
fn exr_centre(color: [f32; 3], channels: Channels, name: &str) -> [f32; 4] {
|
||||
let (p, cid) = bright_project(color);
|
||||
let d = out_dir(name);
|
||||
let mut om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
om.channels = channels;
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = d.join("f_[#####].exr").to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f();
|
||||
img.get_pixel(W / 2, H / 2).0
|
||||
}
|
||||
|
||||
/// The sRGB decode of the frame the writer is handed (the solid is sRGB-encoded in the frame).
|
||||
fn lin(v: f32) -> f32 {
|
||||
effectcraft_color::srgb_to_linear(v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_sequence_keeps_values_above_one() {
|
||||
// Issue #339: a 32 bpc project's over-range colour must reach the EXR, in scene-linear light.
|
||||
let src = [1.5, 1.25, 0.75];
|
||||
for channels in [Channels::Rgba, Channels::Rgb] {
|
||||
let c = exr_centre(src, channels, "seq-exr-over");
|
||||
for i in 0..3 {
|
||||
assert!((c[i] - lin(src[i])).abs() < 1e-3 * lin(src[i]).max(1.0), "{channels:?} channel {i}: {c:?}");
|
||||
}
|
||||
assert!(c[0] > 1.5 && c[1] > 1.25, "{channels:?} not clamped: {c:?}");
|
||||
assert!((c[3] - 1.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_sequence_values_at_or_below_one_are_unchanged() {
|
||||
for channels in [Channels::Rgba, Channels::Rgb] {
|
||||
let c = exr_centre([1.0, 0.4, 0.0], channels, "seq-exr-in-range");
|
||||
assert!((c[0] - 1.0).abs() < 1e-4 && (c[1] - lin(0.4)).abs() < 1e-4 && c[2].abs() < 1e-6, "{channels:?}: {c:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gif_roundtrip() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("gif");
|
||||
let om = OutputModule::for_format(OutputFormat::Gif);
|
||||
let path = d.join("anim.gif");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let mut opts = gif::DecodeOptions::new();
|
||||
opts.set_color_output(gif::ColorOutput::RGBA);
|
||||
let mut dec = opts.read_info(std::fs::File::open(&path).expect("open")).expect("gif header");
|
||||
assert_eq!((dec.width() as u32, dec.height() as u32), (W, H));
|
||||
let mut k = 0;
|
||||
let mut total_delay = 0u32;
|
||||
while let Some(f) = dec.read_next_frame().expect("frame") {
|
||||
assert_eq!((f.width as u32, f.height as u32), (W, H));
|
||||
total_delay += f.delay as u32;
|
||||
let img = Image::from_rgba8(W, H, &f.buffer);
|
||||
check_frame(&img, k, 0.06);
|
||||
k += 1;
|
||||
}
|
||||
assert_eq!(k, FRAMES);
|
||||
assert_eq!(total_delay, 120, "1.2 s in centiseconds");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("gif|64|48"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_early() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("cancel");
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = settings();
|
||||
let path = d.join("c_[#####].png").to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| false);
|
||||
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
|
||||
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);
|
||||
assert_eq!(written, 0, "cancelled before the first batch");
|
||||
}
|
||||
|
||||
/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right).
|
||||
fn write_tone(path: &Path, secs: f64) {
|
||||
let sr = 48_000u32;
|
||||
let n = (secs * sr as f64) as usize;
|
||||
let mut data = Vec::with_capacity(n * 4);
|
||||
for i in 0..n {
|
||||
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
data.extend_from_slice(&0i16.to_le_bytes());
|
||||
}
|
||||
let mut w = Vec::new();
|
||||
w.extend_from_slice(b"RIFF");
|
||||
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(b"WAVEfmt ");
|
||||
w.extend_from_slice(&16u32.to_le_bytes());
|
||||
w.extend_from_slice(&1u16.to_le_bytes());
|
||||
w.extend_from_slice(&2u16.to_le_bytes());
|
||||
w.extend_from_slice(&sr.to_le_bytes());
|
||||
w.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
w.extend_from_slice(&4u16.to_le_bytes());
|
||||
w.extend_from_slice(&16u16.to_le_bytes());
|
||||
w.extend_from_slice(b"data");
|
||||
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(&data);
|
||||
std::fs::write(path, w).expect("write wav");
|
||||
}
|
||||
|
||||
fn rms(v: impl Iterator<Item = f32>) -> f32 {
|
||||
let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1));
|
||||
(s / n.max(1) as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movies_carry_audio() {
|
||||
let d = out_dir("audio");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (fmt, name) in [(OutputFormat::H264, "a.mp4"), (OutputFormat::ProRes, "a.mov")] {
|
||||
let om = OutputModule { audio: AudioOutput::Auto, ..OutputModule::for_format(fmt) };
|
||||
let path = d.join(name);
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio, "{name}: auto audio is on for a comp with a sound layer");
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert!(f.has_audio, "{name}");
|
||||
let dec = MediaPool::new();
|
||||
let s = dec.audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
// sine of amplitude 0.5 → RMS ≈ 0.354
|
||||
assert!((l - 0.3536).abs() < 0.03, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
if Command::new("ffprobe").arg("-version").output().is_ok() {
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "compact=p=0:nk=1"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("ffprobe");
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
let want = if fmt == OutputFormat::H264 { "aac|48000|2" } else { "pcm_s16le|48000|2" };
|
||||
assert!(s.trim() == want, "{name}: {s}");
|
||||
}
|
||||
// Audio off.
|
||||
let om = OutputModule { audio: AudioOutput::Off, ..om };
|
||||
let path = d.join(format!("silent-{name}"));
|
||||
assert!(!run(&p, cid, &pool, &om, &path).audio);
|
||||
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
|
||||
}
|
||||
}
|
||||
|
||||
/// `src`'s audio decoded by ffmpeg as interleaved stereo `f32` at 48 kHz (external oracle).
|
||||
fn ffmpeg_audio(src: &Path) -> Vec<f32> {
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
||||
.arg(src)
|
||||
.args(["-f", "f32le", "-ac", "2", "-ar", "48000", "-"])
|
||||
.output()
|
||||
.expect("ffmpeg");
|
||||
assert!(out.status.success(), "ffmpeg failed to decode {}", src.display());
|
||||
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||||
}
|
||||
|
||||
/// Movies of a 29.97 fps comp whose footage has AAC audio at 44.1 kHz sound like the footage
|
||||
/// (both decoded by ffmpeg): reading footage audio at another rate ramped from a wrong position
|
||||
/// at the start of every batch of frames, so exports stuttered (#274).
|
||||
#[test]
|
||||
fn movies_carry_resampled_footage_audio() {
|
||||
let d = out_dir("audio-44k");
|
||||
let clip = d.join("clip.mp4");
|
||||
let made = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=30000/1001:duration=3", "-f", "lavfi", "-i"])
|
||||
.args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&clip)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if !made {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
return;
|
||||
}
|
||||
let fps = FrameRate::new(30000, 1001);
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, fps, fps.tick_of(60));
|
||||
let fid = p.add_item("clip.mp4", Label::SeaFoam, None, ItemKind::Footage(effectcraft_media::probe(&clip).expect("probe clip")));
|
||||
let l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (64, 48), None);
|
||||
comp.layers.push(l);
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let want = ffmpeg_audio(&clip);
|
||||
let pool = MediaPool::new();
|
||||
// AAC is lossy; PCM differs from ffmpeg's resampler by little more than 16-bit rounding.
|
||||
for (fmt, name, tol) in [(OutputFormat::H264, "a.mp4", 0.06), (OutputFormat::ProRes, "a.mov", 0.01)] {
|
||||
let path = d.join(name);
|
||||
assert!(run_frames(&p, cid, &pool, &OutputModule::for_format(fmt), &path, 60).audio);
|
||||
let got = ffmpeg_audio(&path);
|
||||
// 60 frames at 29.97 fps: 96 096 samples at 48 kHz.
|
||||
assert!((got.len() as i64 / 2 - 96_096).abs() < 1024, "{name}: {} samples", got.len() / 2);
|
||||
// (Up to the last AAC frames, where the cut to silence smears back into the sound.)
|
||||
let worst = got.iter().zip(&want).take(94_000 * 2).map(|(g, w)| (g - w).abs()).fold(0.0f32, f32::max);
|
||||
eprintln!("{name}: max error {worst:.4}");
|
||||
assert!(worst < tol, "{name}: max error {worst} against the footage");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webm_vp9_opus_roundtrip() {
|
||||
let d = out_dir("webm");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
let om = OutputModule { quality: 90, audio: AudioOutput::Auto, ..OutputModule::for_format(OutputFormat::WebM) };
|
||||
let path = d.join("comp.webm");
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio && r.bytes > 200, "{r:?}");
|
||||
// Decoded back through FilmCraft (Matroska demuxer, VP9 and Opus decoders).
|
||||
let f = decode_movie(&path, FRAMES, 0.08);
|
||||
assert!(f.has_audio);
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let l = rms(s.iter().step_by(2).copied());
|
||||
assert!((l - 0.3536).abs() < 0.06, "left RMS {l}");
|
||||
ffmpeg_check(&path, 0.08);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("vp9|64|48|yuv420p"), "{s}");
|
||||
assert!(s.contains("opus"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webm_with_alpha() {
|
||||
let d = out_dir("webm-alpha");
|
||||
let (p, cid) = project(None);
|
||||
// Transparent outside the red solid.
|
||||
let om = OutputModule { channels: Channels::Rgba, ..OutputModule::for_format(OutputFormat::WebM) };
|
||||
let path = d.join("alpha.webm");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let f = effectcraft_media::probe(&path).expect("probe our output");
|
||||
assert_eq!((f.width, f.height), (W, H));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
// AlphaMode element (0x53C0) and BlockAdditions (0x75A1).
|
||||
assert!(bytes.windows(3).any(|w| w == [0x53, 0xC0, 0x81]), "AlphaMode 1");
|
||||
assert!(bytes.windows(2).any(|w| w == [0x75, 0xA1]), "BlockAdditions");
|
||||
// ffmpeg's libvpx decoder reads the alpha channel.
|
||||
let png = path.with_extension("alpha.png");
|
||||
let ok = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "libvpx-vp9", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-vf", "select=eq(n\\,8)", "-frames:v", "1", "-pix_fmt", "rgba"])
|
||||
.arg(&png)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
let img = image::open(&png).unwrap().to_rgba8();
|
||||
assert!(img.get_pixel(2, 2)[3] < 20, "corner transparent");
|
||||
assert!(img.get_pixel(W / 2, H / 2)[3] > 235, "centre opaque");
|
||||
} else {
|
||||
eprintln!("ffmpeg with libvpx not available: skipping alpha oracle");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_audio_only() {
|
||||
let d = out_dir("audio-only");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (fmt, name, magic) in [(OutputFormat::Wav, "out.wav", &b"RIFF"[..]), (OutputFormat::Aiff, "out.aif", &b"FORM"[..])] {
|
||||
let om = OutputModule::for_format(fmt);
|
||||
let path = d.join(name);
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&bytes[..4], magic);
|
||||
// 1.2 s of 48 kHz 16-bit stereo.
|
||||
assert!((bytes.len() as i64 - 57_600 * 4).abs() < 200, "{name}: {}", bytes.len());
|
||||
let l = if fmt == OutputFormat::Wav {
|
||||
// Decoded by FilmCraft.
|
||||
let f = effectcraft_media::probe(&path).expect("probe audio");
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
rms(s.iter().step_by(2).copied())
|
||||
} else {
|
||||
// AIFF: big-endian samples after the SSND chunk's offset/block size.
|
||||
let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16;
|
||||
let left = bytes[at..].as_chunks::<4>().0.iter().skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
|
||||
rms(left)
|
||||
};
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("pcm_s16"), "{s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`.
|
||||
#[test]
|
||||
fn rendered_aiff_imports_back() {
|
||||
use effectcraft_export::render_queue::AudioFormat;
|
||||
let d = out_dir("aiff-import");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] {
|
||||
// 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration.
|
||||
let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) };
|
||||
let path = d.join(name);
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz");
|
||||
// Tone on the left, silence on the right: the two channels are read as two.
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Uncompressed QuickTime (`raw `) output (#372): every frame stored is exactly the frame the
|
||||
//! lossless PNG sequence of the same job writes, read back with FilmCraft's demuxer and — when
|
||||
//! installed — decoded by ffmpeg as an external oracle. Audio rides along as PCM.
|
||||
//!
|
||||
//! The comp is 33×20 so 24-bit rows (99 bytes) need their padding byte, and a 16-px solid centred
|
||||
//! in it sits on a half pixel, so its edges carry partial alpha.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AudioFormat, AudioOutput, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::TrackKind;
|
||||
|
||||
const W: u32 = 33;
|
||||
const H: u32 = 20;
|
||||
const FRAMES: u64 = 6;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// 16-bit stereo 48 kHz WAV: a 440 Hz sine at 0.5 on the left, silence on the right.
|
||||
fn write_tone(path: &Path, secs: f64) {
|
||||
let sr = 48_000u32;
|
||||
let n = (secs * sr as f64) as usize;
|
||||
let mut data = Vec::with_capacity(n * 4);
|
||||
for i in 0..n {
|
||||
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
data.extend_from_slice(&0i16.to_le_bytes());
|
||||
}
|
||||
let mut w = Vec::new();
|
||||
w.extend_from_slice(b"RIFF");
|
||||
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(b"WAVEfmt ");
|
||||
w.extend_from_slice(&16u32.to_le_bytes());
|
||||
w.extend_from_slice(&1u16.to_le_bytes());
|
||||
w.extend_from_slice(&2u16.to_le_bytes());
|
||||
w.extend_from_slice(&sr.to_le_bytes());
|
||||
w.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
w.extend_from_slice(&4u16.to_le_bytes());
|
||||
w.extend_from_slice(&16u16.to_le_bytes());
|
||||
w.extend_from_slice(b"data");
|
||||
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(&data);
|
||||
std::fs::write(path, w).expect("write wav");
|
||||
}
|
||||
|
||||
fn project(audio: Option<&Path>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0));
|
||||
comp.background = [0.1, 0.3, 0.6];
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 16, height: 12, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (16, 12), None);
|
||||
l.in_point = comp.frame_rate.tick_of(2);
|
||||
comp.layers.push(l);
|
||||
if let Some(path) = audio {
|
||||
let f = effectcraft_media::probe(path).expect("probe wav");
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
comp.layers.push(l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export")
|
||||
}
|
||||
|
||||
/// The job's frames as the PNG sequence writes them (lossless 8-bit), as RGBA.
|
||||
fn reference_frames(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, dir: &Path) -> Vec<Vec<u8>> {
|
||||
let png = OutputModule { channels: om.channels, alpha_mode: om.alpha_mode, ..OutputModule::for_format(OutputFormat::PngSequence) };
|
||||
let pattern = dir.join("ref_[#####].png").to_string_lossy().to_string();
|
||||
run(p, cid, footage, &png, Path::new(&pattern));
|
||||
(0..FRAMES).map(|k| image::open(sequence_path(&pattern, k as i64)).expect("png").to_rgba8().into_raw()).collect()
|
||||
}
|
||||
|
||||
/// The frames as stored in the movie, unpacked to RGBA (rows padded to even bytes).
|
||||
fn stored_frames(path: &Path, alpha: bool) -> Vec<Vec<u8>> {
|
||||
let bytes = std::fs::read(path).expect("read movie");
|
||||
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
assert!(mov.is_quicktime);
|
||||
let t = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
|
||||
let e = &t.entries[0];
|
||||
assert_eq!(&e.format.0, b"raw ");
|
||||
let v = e.video.as_ref().expect("visual entry");
|
||||
assert_eq!((v.width, v.height, v.depth), (W as u16, H as u16, if alpha { 32 } else { 24 }));
|
||||
assert_eq!(t.samples.len() as u64, FRAMES);
|
||||
let bpp = if alpha { 4 } else { 3 };
|
||||
let row = (W as usize * bpp).next_multiple_of(2);
|
||||
t.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
assert_eq!(s.size as usize, row * H as usize);
|
||||
let data = &bytes[s.offset as usize..][..s.size as usize];
|
||||
let mut rgba = Vec::new();
|
||||
for r in data.chunks_exact(row) {
|
||||
for p in r[..W as usize * bpp].chunks_exact(bpp) {
|
||||
rgba.extend_from_slice(&if alpha { [p[1], p[2], p[3], p[0]] } else { [p[0], p[1], p[2], 255] });
|
||||
}
|
||||
}
|
||||
rgba
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All frames decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
|
||||
fn ffmpeg_frames(path: &Path) -> Option<Vec<Vec<u8>>> {
|
||||
let out =
|
||||
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(path).args(["-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().ok()?;
|
||||
assert!(out.status.success(), "ffmpeg failed to decode {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
Some(out.stdout.as_chunks::<{ (W * H * 4) as usize }>().0.iter().map(|f| f.to_vec()).collect())
|
||||
}
|
||||
|
||||
fn ffprobe_streams(path: &Path) -> Option<String> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name,codec_tag_string,pix_fmt,width,height,sample_rate,channels",
|
||||
"-of",
|
||||
"compact=p=0:nk=1",
|
||||
])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
assert!(out.status.success(), "ffprobe rejected {}", path.display());
|
||||
Some(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
fn check(name: &str, channels: Channels) {
|
||||
let d = out_dir(name);
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { channels, ..OutputModule::for_format(OutputFormat::Uncompressed) };
|
||||
let path = d.join("comp.mov");
|
||||
let r = run(&p, cid, &effectcraft_render::NoFootage, &om, &path);
|
||||
assert_eq!((r.width, r.height, r.frames, r.audio), (W, H, FRAMES, false));
|
||||
let mut want_rgba = reference_frames(&p, cid, &effectcraft_render::NoFootage, &om, &d);
|
||||
let alpha = channels == Channels::Rgba;
|
||||
if !alpha {
|
||||
// The PNG is RGB (opaque); `to_rgba8` already filled alpha with 255.
|
||||
assert!(want_rgba.iter().all(|f| f.as_chunks::<4>().0.iter().all(|p| p[3] == 255)));
|
||||
} else {
|
||||
let a: Vec<u8> = want_rgba[3].as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
assert!(a.contains(&0) && a.contains(&255) && a.iter().any(|v| *v > 0 && *v < 255), "partial alpha at the solid's edges");
|
||||
}
|
||||
assert_eq!(stored_frames(&path, alpha), want_rgba, "stored frames are the rendered frames");
|
||||
if let Some(got) = ffmpeg_frames(&path) {
|
||||
assert_eq!(got.len() as u64, FRAMES);
|
||||
want_rgba.truncate(got.len());
|
||||
assert!(got == want_rgba, "ffmpeg decodes the rendered frames exactly");
|
||||
let s = ffprobe_streams(&path).unwrap_or_default();
|
||||
let fmt = if alpha { "argb" } else { "rgb24" };
|
||||
assert_eq!(s.trim(), format!("rawvideo|video|raw |{W}|{H}|{fmt}"), "{s}");
|
||||
} else {
|
||||
eprintln!("ffmpeg not found: skipping oracle decode");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_rgb_odd_width() {
|
||||
check("uncompressed-rgb", Channels::Rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_rgba_straight() {
|
||||
check("uncompressed-rgba", Channels::Rgba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_with_pcm_audio() {
|
||||
let d = out_dir("uncompressed-audio");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 1.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
let om = OutputModule { audio: AudioOutput::Auto, audio_format: AudioFormat::S24, ..OutputModule::for_format(OutputFormat::Uncompressed) };
|
||||
let path = d.join("comp.mov");
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let bytes = std::fs::read(&path).expect("read");
|
||||
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
let a = mov.tracks.iter().find(|t| t.kind == TrackKind::Audio).expect("audio track");
|
||||
assert_eq!(a.timescale, 48_000);
|
||||
// 0.6 s of audio at 48 kHz.
|
||||
let frames: u64 = a.samples.iter().map(|s| s.duration as u64).sum();
|
||||
assert!((frames as i64 - 28_800).abs() <= 1, "{frames} audio frames");
|
||||
if let Some(s) = ffprobe_streams(&path) {
|
||||
assert!(s.lines().any(|l| l == "pcm_s24le|audio|lpcm|48000|2"), "{s}");
|
||||
let out = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(&path).args(["-f", "f32le", "-"]).output().expect("ffmpeg");
|
||||
let pcm: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
|
||||
let rms = |v: Vec<f32>| (v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>() / v.len().max(1) as f64).sqrt();
|
||||
let left = rms(pcm.iter().step_by(2).copied().collect());
|
||||
// A sine of amplitude 0.5 → RMS ≈ 0.354.
|
||||
assert!((left - 0.3536).abs() < 0.01, "left RMS {left}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! The VP9 intra encoder (`effectcraft-vp9enc`) round-trips through FilmCraft's VP9 decoder (and
|
||||
//! ffmpeg as an external oracle when installed): sizes, lossless exactness, PSNR.
|
||||
|
||||
use effectcraft_vp9enc::{EncoderConfig, Vp9Encoder};
|
||||
use filmcraft_vp9::{Decoder, Plane};
|
||||
|
||||
fn plane_u8(p: &Plane) -> Vec<u8> {
|
||||
match p {
|
||||
Plane::U8(v) => v.clone(),
|
||||
Plane::U16(v) => v.iter().map(|x| *x as u8).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic planes: gradients, noise and sharp edges.
|
||||
fn source(w: usize, h: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut s = 99u32 + kind;
|
||||
let mut rnd = || {
|
||||
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(s >> 24) as u8
|
||||
};
|
||||
let y: Vec<u8> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, yy) = (i % w, i / w);
|
||||
match kind {
|
||||
0 => ((x * 255) / w.max(1)) as u8 / 2 + ((yy * 255) / h.max(1)) as u8 / 2,
|
||||
1 => rnd(),
|
||||
_ => {
|
||||
if (x / 7 + yy / 5) % 2 == 0 {
|
||||
30
|
||||
} else {
|
||||
220
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let u: Vec<u8> = (0..cw * ch).map(|i| (64 + (i % cw) * 128 / cw.max(1)) as u8).collect();
|
||||
let v: Vec<u8> = (0..cw * ch).map(|i| (200 - (i / cw) * 100 / ch.max(1)) as u8).collect();
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
fn decode(frame: &[u8]) -> filmcraft_vp9::Picture {
|
||||
let mut d = Decoder::with_threads(1);
|
||||
let mut pics = d.decode(frame, 0).expect("decode");
|
||||
assert_eq!(pics.len(), 1);
|
||||
pics.remove(0)
|
||||
}
|
||||
|
||||
fn crop(p: &[u8], stride: usize, w: usize, h: usize) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| p[y * stride..y * stride + w].to_vec()).collect()
|
||||
}
|
||||
|
||||
fn psnr(a: &[u8], b: &[u8]) -> f64 {
|
||||
let mse: f64 = a.iter().zip(b).map(|(x, y)| (*x as f64 - *y as f64).powi(2)).sum::<f64>() / a.len() as f64;
|
||||
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_is_exact_for_odd_sizes() {
|
||||
for (w, h) in [(1, 1), (33, 17), (64, 64), (200, 120), (7, 70)] {
|
||||
for kind in 0..3 {
|
||||
let (y, u, v) = source(w, h, kind);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
assert_eq!((p.width, p.height), (w as u32, h as u32));
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
assert_eq!(crop(&plane_u8(&p.y), p.y_stride, w, h), y, "{w}x{h} kind {kind}: luma");
|
||||
assert_eq!(crop(&plane_u8(&p.u), p.uv_stride, cw, ch), u, "{w}x{h} kind {kind}: u");
|
||||
assert_eq!(crop(&plane_u8(&p.v), p.uv_stride, cw, ch), v, "{w}x{h} kind {kind}: v");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_quality_and_size() {
|
||||
let (w, h) = (200, 120);
|
||||
for kind in [0, 2] {
|
||||
let (y, u, v) = source(w, h, kind);
|
||||
let mut last = 0usize;
|
||||
for q in [30u8, 60, 80, 95] {
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false, ..Default::default() });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
let got = crop(&plane_u8(&p.y), p.y_stride, w, h);
|
||||
let db = psnr(&got, &y);
|
||||
let min = match q {
|
||||
30 => 20.0,
|
||||
60 => 28.0,
|
||||
80 => 32.0,
|
||||
_ => 38.0,
|
||||
};
|
||||
assert!(db > min, "kind {kind} q{q}: PSNR {db:.1} dB, {} bytes", f.len());
|
||||
assert!(f.len() >= last, "higher quality, more bytes");
|
||||
last = f.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_frames_use_tile_columns() {
|
||||
// 4160 px > 64 superblocks: two tile columns are required.
|
||||
let (w, h) = (4160, 16);
|
||||
let (y, u, v) = source(w, h, 0);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false, ..Default::default() });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
assert!(psnr(&crop(&plane_u8(&p.y), p.y_stride, w, h), &y) > 35.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_decodes_our_stream() {
|
||||
let (w, h) = (96, 64);
|
||||
let (y, u, v) = source(w, h, 0);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
|
||||
let frame = e.encode_yuv420(&y, &u, &v);
|
||||
// IVF container (one frame).
|
||||
let mut ivf = Vec::new();
|
||||
ivf.extend_from_slice(b"DKIF");
|
||||
ivf.extend_from_slice(&0u16.to_le_bytes());
|
||||
ivf.extend_from_slice(&32u16.to_le_bytes());
|
||||
ivf.extend_from_slice(b"VP90");
|
||||
ivf.extend_from_slice(&(w as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&(h as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&30u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&0u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&0u64.to_le_bytes());
|
||||
ivf.extend_from_slice(&frame);
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.ivf");
|
||||
std::fs::write(&path, &ivf).unwrap();
|
||||
let out = std::process::Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
eprintln!("ffmpeg not found: skipping oracle");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)");
|
||||
assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- inter frames
|
||||
|
||||
/// Frame `t` of a moving sequence: a diagonal gradient with a sinusoidal texture and bright
|
||||
/// squares, all translating by a fractional amount per frame (`kind` 1 adds noise that changes
|
||||
/// every frame).
|
||||
fn moving(w: usize, h: usize, t: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let (dx, dy) = (1.75 * t as f64, 0.6 * t as f64);
|
||||
let mut s = 1234u32 + t as u32 * 77;
|
||||
let mut rnd = || {
|
||||
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(s >> 28) as f64
|
||||
};
|
||||
let lum = |x: f64, y: f64| -> f64 {
|
||||
let g = 40.0 + 120.0 * (x / w as f64) + 50.0 * (y / h as f64);
|
||||
let tex = 18.0 * ((x * 0.31).sin() * (y * 0.23).cos());
|
||||
let sq = if (x - 20.0).rem_euclid(64.0) < 18.0 && (y - 10.0).rem_euclid(48.0) < 14.0 { 45.0 } else { 0.0 };
|
||||
g + tex + sq
|
||||
};
|
||||
let mut y = vec![0u8; w * h];
|
||||
for yy in 0..h {
|
||||
for x in 0..w {
|
||||
let n = if kind == 1 { rnd() - 7.5 } else { 0.0 };
|
||||
y[yy * w + x] = (lum(x as f64 - dx, yy as f64 - dy) + n).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
let u: Vec<u8> = (0..cw * ch).map(|i| (100.0 + 40.0 * (((i % cw) as f64 * 2.0 - dx) / w as f64)).round() as u8).collect();
|
||||
let v: Vec<u8> = (0..cw * ch).map(|i| (150.0 - 30.0 * (((i / cw) as f64 * 2.0 - dy) / h as f64)).round() as u8).collect();
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec<u8>; 3] {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
[crop(&plane_u8(&p.y), p.y_stride, w, h), crop(&plane_u8(&p.u), p.uv_stride, cw, ch), crop(&plane_u8(&p.v), p.uv_stride, cw, ch)]
|
||||
}
|
||||
|
||||
/// Encode `n` frames and decode them with one decoder: every decoded frame must equal the
|
||||
/// encoder's reconstruction. Returns (total bytes, mean luma PSNR vs the source).
|
||||
fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) {
|
||||
let (w, h) = (cfg.width as usize, cfg.height as usize);
|
||||
let mut e = Vp9Encoder::new(cfg);
|
||||
let mut d = Decoder::with_threads(1);
|
||||
let (mut bytes, mut db) = (0usize, 0.0);
|
||||
for t in 0..n {
|
||||
let (y, u, v) = moving(w, h, t, kind);
|
||||
let f = e.encode_frame(&y, &u, &v, false);
|
||||
bytes += f.data.len();
|
||||
let kind_s = if f.key { "key" } else { "inter" };
|
||||
let mut pics = d.decode(&f.data, t as i64).unwrap_or_else(|err| panic!("{w}x{h} frame {t} ({kind_s}): {err:?}"));
|
||||
assert_eq!(pics.len(), 1);
|
||||
let got = planes_of(&pics.remove(0), w, h);
|
||||
let rec = e.reconstruction().expect("reconstruction");
|
||||
for k in 0..3 {
|
||||
if got[k] != rec[k] {
|
||||
let first = got[k].iter().zip(&rec[k]).position(|(a, b)| a != b).unwrap_or(0);
|
||||
let pw = if k == 0 { w } else { w.div_ceil(2) };
|
||||
panic!("{w}x{h} frame {t} ({kind_s}, q{} lf{}): plane {k} differs first at ({}, {})", f.q_idx, f.filter_level, first % pw, first / pw);
|
||||
}
|
||||
}
|
||||
db += psnr(&got[0], &y);
|
||||
}
|
||||
(bytes, db / n as f64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_frames_decode_exactly() {
|
||||
for (w, h) in [(64, 64), (96, 72), (33, 17), (130, 66)] {
|
||||
for (q, kind) in [(30u8, 0u32), (70, 1), (92, 0), (100, 0)] {
|
||||
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 6, ..Default::default() };
|
||||
run_sequence(cfg, 8, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_frames_with_tile_columns_decode_exactly() {
|
||||
let cfg = EncoderConfig { width: 4160, height: 24, quality: 80, keyframe_interval: 0, ..Default::default() };
|
||||
run_sequence(cfg, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_psnr_rises_with_bitrate() {
|
||||
let (w, h) = (160, 96);
|
||||
let mut last = (0usize, 0.0f64);
|
||||
let mut rows = vec![];
|
||||
for kbps in [60u32, 150, 400, 1200] {
|
||||
let cfg = EncoderConfig { width: w, height: h, quality: 60, target_kbps: Some(kbps), frame_rate: 30.0, keyframe_interval: 30, ..Default::default() };
|
||||
let (bytes, db) = run_sequence(cfg, 20, 0);
|
||||
rows.push(format!("{kbps} kbps target: {:.0} kbps actual, {db:.2} dB", bytes as f64 * 8.0 * 30.0 / 20.0 / 1000.0));
|
||||
assert!(bytes > last.0 && db > last.1, "{rows:#?}");
|
||||
last = (bytes, db);
|
||||
}
|
||||
eprintln!("{rows:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_frames_are_much_smaller_than_intra() {
|
||||
let (w, h) = (192u32, 108u32);
|
||||
let mut report = vec![];
|
||||
for q in [50u8, 75, 90] {
|
||||
let intra = EncoderConfig { width: w, height: h, quality: q, keyframe_interval: 1, ..Default::default() };
|
||||
let inter = EncoderConfig { keyframe_interval: 0, ..intra.clone() };
|
||||
let (bi, pi) = run_sequence(intra, 24, 0);
|
||||
let (bp, pp) = run_sequence(inter, 24, 0);
|
||||
report.push(format!("q{q}: intra {bi} B {pi:.2} dB, inter {bp} B {pp:.2} dB, ratio {:.2}", bi as f64 / bp as f64));
|
||||
assert!(pp > pi - 1.0, "{report:#?}");
|
||||
assert!(bi as f64 >= 3.0 * bp as f64, "{report:#?}");
|
||||
}
|
||||
eprintln!("{report:#?}");
|
||||
}
|
||||
|
||||
/// Encode `n` frames of `moving(.., kind)`: (frames, the encoder's reconstruction of each).
|
||||
fn encode_moving(cfg: EncoderConfig, n: usize, kind: u32) -> (Vec<Vec<u8>>, Vec<[Vec<u8>; 3]>) {
|
||||
let (w, h) = (cfg.width as usize, cfg.height as usize);
|
||||
let mut e = Vp9Encoder::new(cfg);
|
||||
(0..n)
|
||||
.map(|t| {
|
||||
let (y, u, v) = moving(w, h, t, kind);
|
||||
let f = e.encode_frame(&y, &u, &v, false);
|
||||
(f.data, e.reconstruction().expect("recon"))
|
||||
})
|
||||
.unzip()
|
||||
}
|
||||
|
||||
/// Decode `frames` with ffmpeg (an IVF file) and check every frame against `recon`, with nothing
|
||||
/// on ffmpeg's error log. Skipped without ffmpeg.
|
||||
fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec<u8>], recon: &[[Vec<u8>; 3]], name: &str) {
|
||||
let mut ivf = Vec::new();
|
||||
ivf.extend_from_slice(b"DKIF");
|
||||
ivf.extend_from_slice(&0u16.to_le_bytes());
|
||||
ivf.extend_from_slice(&32u16.to_le_bytes());
|
||||
ivf.extend_from_slice(b"VP90");
|
||||
ivf.extend_from_slice(&(w as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&(h as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&30u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&(frames.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&0u32.to_le_bytes());
|
||||
for (t, f) in frames.iter().enumerate() {
|
||||
ivf.extend_from_slice(&(f.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&(t as u64).to_le_bytes());
|
||||
ivf.extend_from_slice(f);
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.ivf");
|
||||
std::fs::write(&path, &ivf).unwrap();
|
||||
let out = std::process::Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
eprintln!("ffmpeg not found: skipping oracle");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let fsz = w * h + 2 * w.div_ceil(2) * h.div_ceil(2);
|
||||
assert_eq!(out.stdout.len(), fsz * frames.len());
|
||||
for (t, r) in recon.iter().enumerate() {
|
||||
let frame = &out.stdout[t * fsz..(t + 1) * fsz];
|
||||
assert_eq!(&frame[..w * h], &r[0][..], "ffmpeg luma of frame {t}");
|
||||
assert_eq!(&frame[w * h..w * h + r[1].len()], &r[1][..], "ffmpeg U of frame {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_decodes_inter_frames_like_the_encoder() {
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() };
|
||||
let (frames, recon) = encode_moving(cfg, 7, 0);
|
||||
ffmpeg_matches(w, h, &frames, &recon, "inter");
|
||||
}
|
||||
|
||||
/// A frame whose data would end in a byte that reads as a superframe marker (0b110xxxxx) gets a
|
||||
/// zero byte after it; otherwise decoders take its tail for a superframe index and reject it
|
||||
/// (#161: 3 of 60 frames of a WebM failed to decode). Such frames still decode exactly.
|
||||
#[test]
|
||||
fn frames_never_end_in_a_superframe_marker() {
|
||||
let marker = |b: u8| b & 0xe0 == 0xc0;
|
||||
let mut padded = None;
|
||||
for (w, h) in [(96usize, 64usize), (130, 66), (160, 96)] {
|
||||
for q in [40u8, 60, 75, 90] {
|
||||
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 12, ..Default::default() };
|
||||
let (frames, recon) = encode_moving(cfg, 24, 1);
|
||||
for (t, f) in frames.iter().enumerate() {
|
||||
assert!(!f.last().copied().is_some_and(marker), "{w}x{h} q{q} frame {t} ends in {:#x}", f[f.len() - 1]);
|
||||
}
|
||||
let needed = frames.iter().any(|f| f.len() >= 2 && f[f.len() - 1] == 0 && marker(f[f.len() - 2]));
|
||||
if needed && padded.is_none() {
|
||||
padded = Some((w, h, q, frames, recon));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") };
|
||||
// The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg.
|
||||
let mut d = Decoder::with_threads(1);
|
||||
for (t, (f, r)) in frames.iter().zip(&recon).enumerate() {
|
||||
let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}"));
|
||||
assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}");
|
||||
}
|
||||
ffmpeg_matches(w, h, &frames, &recon, "marker");
|
||||
}
|
||||
|
||||
/// Encoder speed on 720p (run with `--ignored --nocapture`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_720p() {
|
||||
let (w, h) = (1280usize, 720usize);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 80, keyframe_interval: 0, ..Default::default() });
|
||||
for t in 0..6 {
|
||||
let (y, u, v) = moving(w, h, t, 0);
|
||||
let t0 = std::time::Instant::now();
|
||||
let f = e.encode_frame(&y, &u, &v, false);
|
||||
eprintln!("frame {t} ({}): {} B in {:.0} ms", if f.key { "key" } else { "inter" }, f.data.len(), t0.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user