mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
* Tests: one integration-test binary per crate Each crate with several tests/*.rs files keeps them as modules of tests/it/, so cargo links one test executable per crate instead of one per file. Files that change process-wide state or time a workload stay binaries of their own. * Profiles: line tables for workspace crates, no debug info for dependencies * Windows ARM64: nightly and on release pushes instead of every push to main * xtask ci: clippy and tests in the dev profile The release profile's thin LTO and single codegen unit made linking the test binaries take most of the gate. The codec and audio crates stay at opt-level 3 in the dev profile, so their tests run at release speed. * xtask ci: nextest, doctests, no examples, one wasm check Tests run with cargo nextest when it is installed (cargo test otherwise) and build with --lib --bins --tests, so examples aren't linked; doctests run separately. The wasm check keeps going past the first failing crate. VP8 fixture generation locks across processes, since nextest runs each test in its own. * VP9 synthetic fixtures: write aside and rename A test in another process could read a fixture ffmpeg was still writing. * Fuzz tests: a third of each sweep by default, all of it with FILMCRAFT_FUZZ_ITERS Every seed, mutation kind and thread count still runs. Header bytes each get one overwrite value and two bit flips in rotation, so every value and bit index still occurs across the region. FILMCRAFT_FUZZ_ITERS=N runs the whole sweep plus N random rounds per seed, as before. * Platform fallback tests: one failure point per hybrid path The failure-anywhere test injected a failure at four points around every restart point; the hybrid's paths differ only in the first GOP, around the second and last restart points, at the end and never. The replay test needs the first two GOPs. * Export tests: shorter ranges with the same checks The bitrate test measures 0.5–1.5 s, where each preset reaches its own target (on 0–3 s the simple demo left two presets at the same rate). The every-preset test exports three frames per preset. * Audio tests: effects in parallel, a table-driven FFT for the Opus quality metric The extreme-settings test keeps both settings and the 40 s tail for every effect, one thread each. The Opus quality metric's FFT builds its twiddles once and allocates nothing per transform (same algorithm and summation order). * CI: dev-profile gate with nextest xtask ci no longer builds with --release, so the release-profile overrides go; nextest is installed for the test step. * CI: apt packages through the cached install-apt-packages action * Review fixes: fuzz tests with a panic hook in their own binaries, loud fixture lock The fuzz tests of av1, h264, hevc, mpeg2v, vp8 and vp9 count panics through a process-wide hook, so a panic in another test of the same process would fail them; they are binaries of their own again. The VP8 fixture lock fails the test instead of skipping it silently, and the full-sweep doc comments say how many truncation points the full run samples. * Fuzz tests: rotate the default subset per run (FILMCRAFT_FUZZ_SEED replays one) * platform tests: decode-switch tests in their own binaries, one NVENC lock
489 lines
24 KiB
Rust
489 lines
24 KiB
Rust
//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent
|
||
//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`,
|
||
//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot:
|
||
//!
|
||
//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames);
|
||
//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3).
|
||
//!
|
||
//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross
|
||
//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes)
|
||
//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which
|
||
//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels.
|
||
//!
|
||
//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never
|
||
//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure).
|
||
//! ffmpeg and ffprobe are external test oracles only.
|
||
|
||
use std::io::{BufRead, BufReader, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Fixtures
|
||
|
||
/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped
|
||
/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The
|
||
/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0
|
||
/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers.
|
||
fn phone_vfr_mp4(ffmpeg: &Path) -> Option<PathBuf> {
|
||
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4");
|
||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||
Command::new(ffmpeg)
|
||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"])
|
||
.args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"])
|
||
.args([
|
||
"-filter_complex",
|
||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\
|
||
select='not(eq(mod(n,7),3))'[v];\
|
||
[1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]",
|
||
])
|
||
.args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"])
|
||
.args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"])
|
||
.arg(tmp)
|
||
.status()
|
||
.is_ok_and(|s| s.success())
|
||
})
|
||
}
|
||
|
||
/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe,
|
||
/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3.
|
||
fn camera_long_gop_mkv(ffmpeg: &Path) -> Option<PathBuf> {
|
||
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv");
|
||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||
Command::new(ffmpeg)
|
||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"])
|
||
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"])
|
||
.args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"])
|
||
.args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"])
|
||
.args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"])
|
||
.args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"])
|
||
.arg(tmp)
|
||
.status()
|
||
.is_ok_and(|s| s.success())
|
||
})
|
||
}
|
||
|
||
/// A scratch directory under the workspace `target/`, removed when dropped.
|
||
struct Scratch(PathBuf);
|
||
|
||
impl Scratch {
|
||
fn new(name: &str) -> Self {
|
||
let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
Scratch(d)
|
||
}
|
||
fn path(&self, name: &str) -> String {
|
||
self.0.join(name).to_string_lossy().into_owned()
|
||
}
|
||
}
|
||
|
||
impl Drop for Scratch {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio)
|
||
|
||
struct Mcp {
|
||
child: Child,
|
||
stdin: ChildStdin,
|
||
stdout: BufReader<ChildStdout>,
|
||
next_id: u64,
|
||
}
|
||
|
||
impl Mcp {
|
||
/// Start the headless server (an empty project) and initialise the session. `data_dir` keeps
|
||
/// user presets away from the real data directory.
|
||
fn start(data_dir: &str) -> Self {
|
||
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
|
||
.args(["--data-dir", data_dir, "mcp"])
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null())
|
||
.spawn()
|
||
.expect("spawn filmcraft-cli mcp");
|
||
let stdin = child.stdin.take().unwrap();
|
||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||
let mut m = Mcp { child, stdin, stdout, next_id: 1 };
|
||
let init =
|
||
m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}}));
|
||
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}");
|
||
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
||
m
|
||
}
|
||
|
||
fn send(&mut self, msg: &Value) {
|
||
writeln!(self.stdin, "{msg}").unwrap();
|
||
self.stdin.flush().unwrap();
|
||
}
|
||
|
||
/// Send a request and return its response (notifications in between are skipped).
|
||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||
let id = self.next_id;
|
||
self.next_id += 1;
|
||
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||
loop {
|
||
let mut line = String::new();
|
||
assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending");
|
||
let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}"));
|
||
if v["id"] == id {
|
||
return v;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error.
|
||
fn tool(&mut self, name: &str, arguments: Value) -> Value {
|
||
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
|
||
let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}");
|
||
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
||
}
|
||
|
||
/// `command_run`: one engine command by id.
|
||
fn run(&mut self, id: &str, params: Value) -> Value {
|
||
self.tool("command_run", json!({"id": id, "params": params}))
|
||
}
|
||
}
|
||
|
||
impl Drop for Mcp {
|
||
fn drop(&mut self) {
|
||
let _ = self.child.kill();
|
||
let _ = self.child.wait();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Oracles
|
||
|
||
fn probe(ffprobe: &Path, path: &str) -> Value {
|
||
let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap();
|
||
assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
|
||
serde_json::from_slice(&out.stdout).unwrap()
|
||
}
|
||
|
||
fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> {
|
||
j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect()
|
||
}
|
||
|
||
fn num(v: &Value) -> f64 {
|
||
v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN)
|
||
}
|
||
|
||
/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout.
|
||
fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec<u8> {
|
||
let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap();
|
||
assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr));
|
||
out.stdout
|
||
}
|
||
|
||
/// Size of the frames [`frames`] returns.
|
||
const FW: usize = 80;
|
||
const FH: usize = 60;
|
||
|
||
/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes
|
||
/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it).
|
||
fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec<Vec<u8>> {
|
||
let chain = match vf {
|
||
Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"),
|
||
None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"),
|
||
};
|
||
let bpp = if pix_fmt == "gray" { 1 } else { 3 };
|
||
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]);
|
||
raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect()
|
||
}
|
||
|
||
/// Mean absolute difference of two equally sized frames.
|
||
fn diff(a: &[u8], b: &[u8]) -> f64 {
|
||
a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len().max(1) as f64
|
||
}
|
||
|
||
/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close.
|
||
fn assert_shows(got: &[u8], src: &[Vec<u8>], want: usize, what: &str) {
|
||
let d: Vec<f64> = src.iter().map(|s| diff(got, s)).collect();
|
||
let best = d.iter().copied().fold(f64::INFINITY, f64::min);
|
||
let at = d.iter().position(|x| *x == best).unwrap_or(0);
|
||
assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]);
|
||
}
|
||
|
||
/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz.
|
||
fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec<f32> {
|
||
let map = format!("0:a:{stream}");
|
||
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]);
|
||
raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||
}
|
||
|
||
/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart.
|
||
fn onsets(x: &[f32]) -> Vec<f64> {
|
||
let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||
let mut out = Vec::new();
|
||
let mut last = f64::NEG_INFINITY;
|
||
for (i, block) in x.chunks(48).enumerate() {
|
||
let t = i as f64 * 48.0 / 48_000.0;
|
||
if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 {
|
||
out.push(t);
|
||
}
|
||
if block.iter().any(|v| v.abs() > peak / 4.0) {
|
||
last = t;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn rms(x: &[f32], from: f64, to: f64) -> f64 {
|
||
let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default();
|
||
(s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::<f64>() / s.len().max(1) as f64).sqrt()
|
||
}
|
||
|
||
/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel).
|
||
fn tone(x: &[f32], at: f64, freq: f64) -> f64 {
|
||
let start = (at * 48_000.0) as usize;
|
||
let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default();
|
||
let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos();
|
||
let (mut s1, mut s2) = (0.0, 0.0);
|
||
for v in s {
|
||
let s0 = f64::from(*v) + c * s1 - s2;
|
||
s2 = s1;
|
||
s1 = s0;
|
||
}
|
||
(s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64
|
||
}
|
||
|
||
fn db(ratio: f64) -> f64 {
|
||
20.0 * ratio.log10()
|
||
}
|
||
|
||
fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec<Value> {
|
||
seq[kind][track]["items"].as_array().unwrap()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Workflows
|
||
|
||
/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the
|
||
/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and
|
||
/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame
|
||
/// holds the one before), the cut lands on the right frames, picture and sound stay in sync
|
||
/// across the cut, the title is in the picture only while it is on the timeline.
|
||
#[test]
|
||
fn phone_vfr_clip_extract_gain_title_to_h264_mp4() {
|
||
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
|
||
let Some(src) = phone_vfr_mp4(&ffmpeg) else {
|
||
return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)");
|
||
};
|
||
let src = src.to_string_lossy().into_owned();
|
||
let dir = Scratch::new("phone");
|
||
let mut m = Mcp::start(&dir.path("data"));
|
||
|
||
let imported = m.tool("media_import", json!({"paths": [src]}));
|
||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||
let item = imported["items"][0].as_u64().unwrap();
|
||
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
|
||
assert!(made["sequence"].is_u64(), "{made}");
|
||
let seq = m.tool("sequence_inspect", json!({}));
|
||
assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]);
|
||
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate");
|
||
assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps");
|
||
|
||
// Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain.
|
||
m.run("markers.markIn", json!({"seconds": 1.5}));
|
||
m.run("markers.markOut", json!({"seconds": 2.5}));
|
||
m.run("sequence.extract", json!({}));
|
||
m.run("edit.selectAll", json!({}));
|
||
let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0}));
|
||
assert_eq!(gain["clips"], 2, "both audio pieces: {gain}");
|
||
m.run("edit.deselectAll", json!({}));
|
||
m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5}));
|
||
let seq = m.tool("sequence_inspect", json!({}));
|
||
assert_eq!(seq["durationFrames"], 89, "{seq}");
|
||
assert_eq!(clips_on(&seq, "video", 0).len(), 2);
|
||
assert_eq!(clips_on(&seq, "audio", 0).len(), 2);
|
||
|
||
let out = dir.path("phone_edit.mp4");
|
||
let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true}));
|
||
assert_eq!(r["result"]["frames"], 89, "{r}");
|
||
|
||
// container and streams
|
||
let j = probe(&ffprobe, &out);
|
||
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
|
||
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
|
||
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240)));
|
||
assert_eq!(v[0]["r_frame_rate"], "30/1");
|
||
assert_eq!(v[0]["nb_read_frames"], "89");
|
||
assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)));
|
||
let duration = num(&j["format"]["duration"]);
|
||
assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}");
|
||
assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]);
|
||
|
||
// picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source
|
||
// as an editor plays it (CFR 30, a dropped frame holding the previous one)
|
||
let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray");
|
||
let got = frames(&ffmpeg, &out, None, "gray");
|
||
assert!(src_frames.len() >= 120, "{} source frames", src_frames.len());
|
||
assert_eq!(got.len(), 89);
|
||
for k in [20, 32, 40, 44, 45, 50, 70, 88] {
|
||
let want = if k < 45 { k } else { k + 31 };
|
||
assert_shows(&got[k], &src_frames, want, &format!("export frame {k}"));
|
||
}
|
||
// the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31
|
||
let mean = |f: &Vec<u8>| f.iter().map(|v| f64::from(*v)).sum::<f64>() / f.len() as f64;
|
||
let flashes: Vec<usize> = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect();
|
||
assert_eq!(flashes, [30, 31, 59], "white frames");
|
||
|
||
// the title: over frames 0..15 only (its pixels differ from the source there, nowhere after)
|
||
let blocks_changed = |k: usize| {
|
||
let mut n = 0;
|
||
for by in (0..FH).step_by(10) {
|
||
for bx in (0..FW).step_by(10) {
|
||
let mut d = 0.0;
|
||
for y in by..by + 10 {
|
||
for x in bx..bx + 10 {
|
||
d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x]));
|
||
}
|
||
}
|
||
n += usize::from(d / 100.0 > 15.0);
|
||
}
|
||
}
|
||
n
|
||
};
|
||
for k in [2, 8, 14] {
|
||
assert!(blocks_changed(k) >= 2, "title missing on frame {k}");
|
||
}
|
||
for k in [16, 25] {
|
||
assert_eq!(blocks_changed(k), 0, "title still on frame {k}");
|
||
}
|
||
|
||
// sound: beeps where the flashes are (A/V sync across the extract), 6 dB down
|
||
let src_pcm = pcm(&ffmpeg, &src, 0);
|
||
let got_pcm = pcm(&ffmpeg, &out, 0);
|
||
let beeps = onsets(&got_pcm);
|
||
assert_eq!(beeps.len(), 2, "beeps at {beeps:?}");
|
||
for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) {
|
||
assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s");
|
||
}
|
||
let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09));
|
||
assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB");
|
||
let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09));
|
||
assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB");
|
||
}
|
||
|
||
/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make
|
||
/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at
|
||
/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross
|
||
/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes.
|
||
/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio
|
||
/// tracks are in the mix at their own level and stop where the clip does.
|
||
#[test]
|
||
fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() {
|
||
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
|
||
let Some(src) = camera_long_gop_mkv(&ffmpeg) else {
|
||
return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)");
|
||
};
|
||
let src = src.to_string_lossy().into_owned();
|
||
let dir = Scratch::new("camera");
|
||
let data = dir.path("data");
|
||
let project = dir.path("camera.fcproj");
|
||
{
|
||
let mut m = Mcp::start(&data);
|
||
let imported = m.tool("media_import", json!({"paths": [src]}));
|
||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||
let item = imported["items"][0].as_u64().unwrap();
|
||
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
|
||
let clips: Vec<u64> = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||
assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}");
|
||
let seq = m.tool("sequence_inspect", json!({}));
|
||
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1}));
|
||
assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks");
|
||
let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap();
|
||
|
||
// 65 frames off the head and 15 off the tail, the linked audio following
|
||
m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65}));
|
||
m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15}));
|
||
let seq = m.tool("sequence_inspect", json!({}));
|
||
let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap();
|
||
for t in 0..2 {
|
||
let a = &clips_on(&seq, "audio", t)[0];
|
||
assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1);
|
||
}
|
||
|
||
let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap();
|
||
let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false}));
|
||
let matte_clip = placed["clips"][0].as_u64().unwrap();
|
||
m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"}));
|
||
let seq = m.tool("sequence_inspect", json!({}));
|
||
let tr = &seq["video"][0]["transitions"][0];
|
||
assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}");
|
||
m.run("file.saveAs", json!({"path": project}));
|
||
}
|
||
assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit");
|
||
|
||
// a fresh process opens the saved project and renders it
|
||
let out = dir.path("camera_edit.mov");
|
||
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap();
|
||
assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr));
|
||
let r: Value = serde_json::from_slice(&o.stdout).unwrap();
|
||
let total = 71 + 50;
|
||
assert_eq!(r["result"]["frames"], total, "{r}");
|
||
|
||
let j = probe(&ffprobe, &out);
|
||
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
|
||
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
|
||
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240)));
|
||
assert_eq!(v[0]["r_frame_rate"], "25/1");
|
||
assert_eq!(v[0]["nb_read_frames"], total.to_string());
|
||
assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2)));
|
||
let duration = num(&j["format"]["duration"]);
|
||
assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}");
|
||
|
||
// picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it
|
||
let src_frames = frames(&ffmpeg, &src, None, "rgb24");
|
||
let got = frames(&ffmpeg, &out, None, "rgb24");
|
||
assert_eq!((src_frames.len(), got.len()), (150, total as usize));
|
||
for k in [0, 1, 2, 30, 64] {
|
||
assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}"));
|
||
}
|
||
let red = &got[100];
|
||
assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red");
|
||
// the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut
|
||
let mix = |k: usize| {
|
||
let (cam, e) = (&src_frames[65 + k], &got[k]);
|
||
let (mut num, mut den) = (0.0, 0.0);
|
||
for ((c, r), e) in cam.iter().zip(red).zip(e) {
|
||
let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e));
|
||
num += (e - c) * (r - c);
|
||
den += (r - c) * (r - c);
|
||
}
|
||
num / den
|
||
};
|
||
let alphas: Vec<f64> = (64..=78).map(mix).collect();
|
||
assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}");
|
||
assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}");
|
||
assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}");
|
||
|
||
// sound: both tracks in the mix at their own level while the clip plays, silence on the matte
|
||
let got_pcm = pcm(&ffmpeg, &out, 0);
|
||
let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1));
|
||
for at in [0.2, 1.5, 2.6] {
|
||
let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0));
|
||
let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0));
|
||
assert!(
|
||
db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0,
|
||
"at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})"
|
||
);
|
||
}
|
||
assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte");
|
||
}
|
||
|
||
/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through
|
||
/// `filmcraft-cli --project … inspect sequence`.
|
||
fn end_frame_of(project: &str) -> Option<u64> {
|
||
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?;
|
||
let v: Value = serde_json::from_slice(&o.stdout).ok()?;
|
||
v["video"][0]["items"][0]["endFrame"].as_u64()
|
||
}
|