Files
effectcraft/crates/engine/src/tests_codec_options.rs
T
bflatasticandClaude Opus 5.5 c1e13b1f66 Export: AV1 WebM refuses an explicit RGB + Alpha request (#166)
apply_output checked `channels: rgba` against the module before it
applied `webmCodec`, and choosing AV1 then quietly dropped the channels
to RGB. So `render --format webm --webm-codec av1 --channels rgba` (one
renderQueue.add) succeeded and wrote an opaque AV1 stream.

The codec options now apply before Channels, so the existing "has no
alpha channel" error (how an unsupported channels request is already
reported) refuses the combination; for WebM it names the codec: "AV1
WebM has no alpha channel (VP9 WebM has)". Choosing AV1 alone on an
RGBA VP9 item still switches it to RGB, as changing the format does.

Test: av1_webm_refuses_requested_alpha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00

146 lines
8.4 KiB
Rust

//! M13.4: HEVC / AV1 output modules and the WebM codec / Opus options over the command API.
use effectcraft_project::render_queue::{Channels, CodecProfile, OpusApplication, OutputFormat, RateControlMode, VideoCodecOptions, WebmVideoCodec};
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
#[test]
fn hevc_and_av1_output_module_options() {
let mut s = session();
let a = s
.execute(
"renderQueue.add",
json!({"format": "hevc", "profile": "main10", "level": "4.1", "rateControl": "quality", "quality": 85, "keyframeInterval": 30}),
)
.unwrap();
assert!(a["outputPath"].as_str().unwrap().ends_with(".mp4"), "{a}");
let om = &s.project.render_queue[0].output;
assert_eq!(om.format, OutputFormat::Hevc);
assert_eq!(om.codec, VideoCodecOptions { profile: CodecProfile::Main10, level: Some(41), rate_control: RateControlMode::Quality, quality: 85 });
assert_eq!(om.keyframe_interval, 30);
assert_eq!(a["output"]["codec"]["profile"], "Main10", "{a}");
let id = a["item"].clone();
s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "av1", "level": null, "rateControl": "bitrate", "bitrate": 3000})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((om.format, om.codec.level, om.codec.rate_control, om.bitrate_kbps), (OutputFormat::Av1, None, RateControlMode::Bitrate, 3000));
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "channels": "rgba"})).is_err(), "AV1 MP4 has no alpha");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "profile": "high"})).is_err());
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "level": "0.5"})).is_err());
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "rateControl": "cbr2"})).is_err());
// WebM: VP9 with alpha, then AV1 (alpha dropped) with voice-tuned Opus.
s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "webm", "channels": "rgba"})).unwrap();
assert_eq!(s.project.render_queue[0].output.channels, Channels::Rgba);
s.execute("renderQueue.setOutputModule", json!({"item": id, "webmCodec": "av1", "audioBitrate": 24, "opusApplication": "voice"})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((om.webm_codec, om.channels, om.opus_bitrate_kbps, om.opus_application), (WebmVideoCodec::Av1, Channels::Rgb, 24, OpusApplication::Voip));
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "webmCodec": "theora"})).is_err());
// The formats query lists the new formats and option values.
let f = s.execute("renderQueue.formats", json!({})).unwrap();
let ids: Vec<&str> = f["formats"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
assert!(ids.contains(&"Hevc") && ids.contains(&"Av1"), "{ids:?}");
assert_eq!(f["codecProfiles"], json!(["Main", "Main 10"]));
assert!(f["hevcLevels"].as_array().unwrap().contains(&json!("4.1")));
assert_eq!(f["webmCodecs"], json!(["vp9", "av1"]));
}
/// An explicit format wins over the output file's extension, which follows it (#154:
/// `effectcraft-cli render --format hevc --out x.mp4` wrote H.264). Without a format, the extension
/// still picks one.
#[test]
fn an_explicit_format_wins_over_the_output_extension() {
let mut s = session();
let cases = [
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
("hevc", "/out/x.mov", OutputFormat::Hevc, "/out/x.mp4"),
("av1", "/out/x.webm", OutputFormat::Av1, "/out/x.mp4"),
("prores", "/out/x.mp4", OutputFormat::ProRes, "/out/x.mov"),
];
for (k, (format, out, want, path)) in cases.into_iter().enumerate() {
s.execute("renderQueue.add", json!({"format": format, "output": out})).unwrap();
let om = &s.project.render_queue[k].output;
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
// A second output module of the item behaves the same.
s.execute("render.addOutputModule", json!({"index": k + 1, "format": format, "output": out})).unwrap();
let extra = &s.project.render_queue[k].extra_outputs[0];
assert_eq!((extra.format, extra.output.as_str()), (want, path), "addOutputModule {format} → {out}");
}
// No format: the extension picks it, as before. A format the extension agrees with keeps
// the name as given (the CLI passes the extension as the format).
s.execute("renderQueue.add", json!({"output": "/out/y.mov"})).unwrap();
assert_eq!(s.project.render_queue.last().unwrap().output.format, OutputFormat::ProRes);
s.execute("renderQueue.add", json!({"format": "png", "output": "/out/still.png"})).unwrap();
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.format, om.output.as_str()), (OutputFormat::PngSequence, "/out/still.png"));
}
/// Output To only changes the format when the format can't write the new extension: HEVC and AV1
/// write `.mp4` too, so they stay (#179). Other extensions still pick a file type.
#[test]
fn output_to_keeps_a_format_that_writes_the_extension() {
let mut s = session();
let cases = [
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
("h264", "/out/x.mov", OutputFormat::ProRes, "/out/x.mov"),
("prores", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
("h264", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
];
for (format, out, want, path) in cases {
let a = s.execute("renderQueue.add", json!({"format": format})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": out})).unwrap();
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
if OutputFormat::from_name(format) == Some(want) {
assert_eq!(r["outputModuleSummary"], a["outputModuleSummary"], "{format} → {out}: the module is unchanged");
}
}
// The module's own default name (HEVC resolves to `Main.mp4`) changes nothing either.
let a = s.execute("renderQueue.add", json!({"format": "hevc"})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": a["outputPath"]})).unwrap();
assert_eq!((&r["output"]["format"], &r["outputModuleSummary"]), (&json!("Hevc"), &a["outputModuleSummary"]), "{r}");
}
/// An explicit RGB + Alpha request with the AV1 WebM codec (which has no alpha) is refused, not
/// silently rendered opaque (#166). Without `channels`, AV1 WebM renders RGB as before.
#[test]
fn av1_webm_refuses_requested_alpha() {
let mut s = session();
let n = s.project.render_queue.len();
let e = s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
assert_eq!(s.project.render_queue.len(), n, "nothing queued");
let a = s.execute("renderQueue.add", json!({"format": "webm", "channels": "rgba"})).unwrap();
let e = s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Vp9, Channels::Rgba), "unchanged");
s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1"})).unwrap();
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Av1, Channels::Rgb));
}
/// A `comp` that names no composition says so, rather than "no active composition" (#155).
#[test]
fn an_unknown_comp_is_named_in_the_error() {
let mut s = session();
for (cmd, p) in [
("renderQueue.add", json!({"comp": "Nope", "output": "/out/x.mp4"})),
("layer.newSolid", json!({"comp": "Nope"})),
("comp.settings", json!({"comp": 99_999, "width": 10})),
] {
let e = s.execute(cmd, p.clone()).unwrap_err().to_string();
let name = p["comp"].to_string();
assert!(e.contains(&format!("no composition {name}")), "{cmd}: {e}");
}
}