223 lines
10 KiB
Rust
223 lines
10 KiB
Rust
//! 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}");
|
||
}
|
||
}
|