use super::lower::LoweredJob;
use super::manifest::{ShapeStatus, DIALECT, MANIFEST_REVISION};
pub(crate) fn emit(job: &LoweredJob, command: &[String], status: &ShapeStatus) -> String {
let plain_input = job.input.format.is_none()
&& job.input.start_time_us.is_none()
&& job.input.recording_time_us.is_none()
&& job.input.stop_time_us.is_none();
let mut out = String::new();
header(
&mut out,
command,
status,
plain_input,
job.output.video_filter.is_some(),
);
for noop in &job.noops {
match &noop.value {
Some(value) => out.push_str(&format!(
"// {} {}: not applicable in-process (no-op)\n",
noop.flag,
comment_text(value)
)),
None => out.push_str(&format!(
"// {}: not applicable in-process (no-op)\n",
noop.flag
)),
}
}
if !job.noops.is_empty() {
out.push('\n');
}
out.push_str("fn main() -> Result<(), Box<dyn std::error::Error>> {\n");
out.push_str(" FfmpegContext::builder()\n");
if plain_input {
line(&mut out, 2, &format!(".input({})", lit(&job.input.url)));
} else {
line(&mut out, 2, ".input(");
line(
&mut out,
3,
&format!("Input::from({})", lit(&job.input.url)),
);
if let Some(format) = &job.input.format {
line(
&mut out,
4,
&format!(
".set_format({}) // -f {}",
lit(format),
comment_text(format)
),
);
}
if let Some(us) = job.input.start_time_us {
line(
&mut out,
4,
&format!(
".set_start_time_us({}) // -ss (input side, seconds -> microseconds)",
num(us)
),
);
}
if let Some(us) = job.input.recording_time_us {
line(
&mut out,
4,
&format!(".set_recording_time_us({}) // -t (input side)", num(us)),
);
}
if let Some(us) = job.input.stop_time_us {
line(
&mut out,
4,
&format!(".set_stop_time_us({}) // -to (input side)", num(us)),
);
}
line(&mut out, 2, ")");
}
line(&mut out, 2, ".output(");
line(
&mut out,
3,
&format!("Output::from({})", lit(&job.output.url)),
);
let o = &job.output;
if let Some(format) = &o.format {
line(
&mut out,
4,
&format!(
".set_format({}) // -f {}",
lit(format),
comment_text(format)
),
);
}
if let Some(us) = o.start_time_us {
line(
&mut out,
4,
&format!(
".set_start_time_us({}) // -ss (output side: decode, then discard)",
num(us)
),
);
}
if let Some(us) = o.recording_time_us {
line(
&mut out,
4,
&format!(".set_recording_time_us({}) // -t", num(us)),
);
}
if let Some(us) = o.stop_time_us {
line(
&mut out,
4,
&format!(".set_stop_time_us({}) // -to", num(us)),
);
}
if o.video_disable {
line(&mut out, 4, ".disable_video() // -vn");
}
if o.audio_disable {
line(&mut out, 4, ".disable_audio() // -an");
}
if let Some(codec) = &o.video_codec {
line(
&mut out,
4,
&format!(
".set_video_codec({}) // -c:v {}",
lit(codec),
comment_text(codec)
),
);
}
if let Some(codec) = &o.audio_codec {
line(
&mut out,
4,
&format!(
".set_audio_codec({}) // -c:a {}",
lit(codec),
comment_text(codec)
),
);
}
if let Some(bitrate) = &o.video_bitrate {
line(
&mut out,
4,
&format!(
".set_video_bitrate({}) // -b:v {}",
lit(bitrate),
comment_text(bitrate)
),
);
}
if let Some(bitrate) = &o.audio_bitrate {
line(
&mut out,
4,
&format!(
".set_audio_bitrate({}) // -b:a {}",
lit(bitrate),
comment_text(bitrate)
),
);
}
for (key, value) in &o.video_codec_opts {
line(
&mut out,
4,
&format!(
".set_video_codec_opt({}, {}) // -{} {}",
lit(key),
lit(value),
comment_text(key),
comment_text(value)
),
);
}
for (key, value) in &o.format_opts {
line(
&mut out,
4,
&format!(
".set_format_opt({}, {}) // -{} {}",
lit(key),
lit(value),
comment_text(key),
comment_text(value)
),
);
}
if let Some(pix_fmt) = &o.pix_fmt {
line(
&mut out,
4,
&format!(
".set_pix_fmt({}) // -pix_fmt {}",
lit(pix_fmt),
comment_text(pix_fmt)
),
);
}
if let Some(rate) = o.audio_sample_rate {
line(
&mut out,
4,
&format!(".set_audio_sample_rate({rate}) // -ar {rate}"),
);
}
if let Some(channels) = o.audio_channels {
line(
&mut out,
4,
&format!(".set_audio_channels({channels}) // -ac {channels}"),
);
}
if let Some(frames) = o.max_video_frames {
line(
&mut out,
4,
&format!(".set_max_video_frames({frames}) // -frames:v {frames} (image2 update mode is applied automatically)"),
);
}
if let Some(filter) = &o.video_filter {
line(
&mut out,
4,
&format!(
".set_video_filter({}) // -vf {}",
lit(filter),
comment_text(filter)
),
);
}
for (map, copy) in &o.stream_maps {
if *copy {
line(
&mut out,
4,
&format!(
".add_stream_map_with_copy({}) // -map {} + copy",
lit(map),
comment_text(map)
),
);
} else {
line(
&mut out,
4,
&format!(
".add_stream_map({}) // -map {}",
lit(map),
comment_text(map)
),
);
}
}
line(&mut out, 2, ")");
line(&mut out, 2, ".build()?");
line(&mut out, 2, ".start()?");
line(&mut out, 2, ".wait()?;");
out.push_str(" Ok(())\n}\n");
out
}
fn header(
out: &mut String,
command: &[String],
status: &ShapeStatus,
plain_input: bool,
vf_precondition: bool,
) {
out.push_str("// Generated from an ffmpeg command by the ez-ffmpeg CLI-compat emitter.\n");
out.push_str(&format!(
"// command: ffmpeg {}
",
comment_text(&requote(command))
));
out.push_str(&format!(
"// dialect: {DIALECT}; manifest: r{MANIFEST_REVISION}; crate: ez-ffmpeg {}; cargo features: none required\n",
env!("CARGO_PKG_VERSION")
));
match status {
ShapeStatus::Verified(id) => {
match super::manifest::shape(id) {
Some(shape) => out.push_str(&format!(
"// status: verified shape {id} ({}) — verified by the manifest-driven \
semantic golden suite (oracle: {:?}) against the ffmpeg CLI; canonical \
emission compile-pinned as examples/{}.rs\n",
shape.summary, shape.oracle, shape.emitted_example
)),
None => out.push_str(&format!(
"// status: verified shape {id} — backed by a semantic golden against the ffmpeg CLI\n"
)),
}
}
ShapeStatus::Unverified(id) => {
let entry = super::manifest::unverified_entry(id);
let summary = entry.map(|e| e.summary).unwrap_or("unverified");
out.push_str(&format!(
"// status: UNVERIFIED SCAFFOLDING — manifest entry {id} ({summary}).\n\
// This shape has no semantic golden. The code below compiles against the\n\
// ez-ffmpeg builder API, but its behavior has NOT been checked against the\n\
// ffmpeg CLI and must not be treated as a faithful translation. Review every\n\
// call before use; in-process execution (from_cli / from_cli_args) refuses\n\
// this shape.\n"
));
}
ShapeStatus::Unmatched => {
unreachable!("emit is never invoked for unmatched shapes (emit_from_tokens rejects)")
}
}
if vf_precondition {
out.push_str(
"// precondition: -vf requires the input to contain exactly ONE video stream;\n\
// in-process execution enforces this after probing (see from_cli_args), and\n\
// this generated code inherits the same assumption.\n",
);
}
out.push('\n');
if plain_input {
out.push_str("use ez_ffmpeg::{FfmpegContext, Output};\n\n");
} else {
out.push_str("use ez_ffmpeg::{FfmpegContext, Input, Output};\n\n");
}
}
fn line(out: &mut String, level: usize, text: &str) {
for _ in 0..level {
out.push_str(" ");
}
out.push_str(text);
out.push('\n');
}
fn lit(s: &str) -> String {
format!("{s:?}")
}
fn comment_text(s: &str) -> String {
s.chars()
.flat_map(|c| {
if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
c.escape_debug().collect::<Vec<_>>()
} else {
vec![c]
}
})
.collect()
}
fn num(us: i64) -> String {
let digits = us.to_string();
let mut grouped = String::new();
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
grouped.push('_');
}
grouped.push(ch);
}
grouped
}
fn requote(command: &[String]) -> String {
command
.iter()
.map(|token| {
let simple = !token.is_empty()
&& token.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+' | '%' | ',' | '@')
});
if simple {
token.clone()
} else {
format!("'{}'", token.replace('\'', r"'\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::super::{parse::parse, tokenize::tokenize};
use super::*;
use crate::core::cli::lower::lower;
use crate::core::cli::manifest::classify;
fn emit_cmd(cmd: &str) -> String {
let args = tokenize(cmd).unwrap();
let ir = parse(&args).unwrap_or_else(|e| panic!("{cmd} should parse: {e}"));
let status = classify(&ir);
emit(&lower(&ir), &args, &status)
}
#[test]
fn num_groups_digits() {
assert_eq!(num(0), "0");
assert_eq!(num(500), "500");
assert_eq!(num(10_000_000), "10_000_000");
assert_eq!(num(2_500_000), "2_500_000");
assert_eq!(num(100), "100");
assert_eq!(num(1000), "1_000");
}
#[test]
fn lit_escapes_quotes_and_keeps_unicode() {
assert_eq!(lit(r#"a"b"#), r#""a\"b""#);
assert_eq!(lit("视频.mp4"), "\"视频.mp4\"");
}
#[test]
fn comment_text_escapes_controls_and_unicode_line_separators() {
assert_eq!(comment_text("a\nb\0c"), r"a\nb\0c");
assert_eq!(comment_text("a\u{2028}b\u{2029}c"), r"a\u{2028}b\u{2029}c");
assert_eq!(comment_text("视频 ok"), "视频 ok");
}
#[test]
fn requote_quotes_tokens_with_spaces() {
let args = vec!["-i".to_string(), "my movie.mp4".to_string()];
assert_eq!(requote(&args), "-i 'my movie.mp4'");
}
#[test]
fn verified_emit_carries_dialect_and_shape() {
let code =
emit_cmd("ffmpeg -i in.mkv -c:v libx264 -crf 23 -preset fast -c:a aac -y out.mp4");
assert!(code.contains("// status: verified shape V1"));
assert!(code.contains("dialect: ffmpeg 7.1 command line"));
assert!(code.contains("manifest: r4"));
assert!(code.contains(".set_video_codec(\"libx264\") // -c:v libx264"));
assert!(code.contains(".set_video_codec_opt(\"crf\", \"23\") // -crf 23"));
assert!(code.contains(".set_video_codec_opt(\"preset\", \"fast\") // -preset fast"));
assert!(code.contains(".set_audio_codec(\"aac\") // -c:a aac"));
assert!(!code.contains("UNVERIFIED"));
}
#[test]
fn clip_emit_scopes_trims_correctly() {
let code =
emit_cmd("ffmpeg -ss 10 -i in.mp4 -t 20 -c:v libx264 -crf 23 -c:a aac -y clip.mp4");
assert!(code.contains("Input::from(\"in.mp4\")"));
assert!(code.contains(".set_start_time_us(10_000_000) // -ss (input side"));
assert!(code.contains(".set_recording_time_us(20_000_000) // -t"));
assert!(!code.contains("// -ss (output side"));
}
#[test]
fn thumbnail_emit_uses_generic_path() {
let code = emit_cmd("ffmpeg -ss 5 -i in.mp4 -an -c:v mjpeg -frames:v 1 -y thumb.jpg");
assert!(code.contains(".disable_audio() // -an"));
assert!(code.contains(".set_video_codec(\"mjpeg\")"));
assert!(code.contains(".set_max_video_frames(1)"));
assert!(code.contains("image2 update mode is applied automatically"));
}
#[test]
fn scale_emit_uses_per_output_filter() {
let code = emit_cmd(
"ffmpeg -i in.mp4 -vf scale=1280:-2 -c:v libx264 -crf 23 -preset fast -c:a aac -y scaled.mp4",
);
assert!(code.contains(".set_video_filter(\"scale=1280:-2\") // -vf scale=1280:-2"));
}
#[test]
fn hls_emit_maps_every_muxer_option() {
let code = emit_cmd(
"ffmpeg -i in.mp4 -c:v libx264 -crf 23 -c:a aac -f hls -hls_time 6 -hls_playlist_type vod -hls_list_size 0 -hls_segment_filename 'seg_%03d.ts' -y out.m3u8",
);
assert!(code.contains(".set_format(\"hls\") // -f hls"));
assert!(code.contains(".set_format_opt(\"hls_time\", \"6\")"));
assert!(code.contains(".set_format_opt(\"hls_playlist_type\", \"vod\")"));
assert!(code.contains(".set_format_opt(\"hls_list_size\", \"0\")"));
assert!(code.contains(".set_format_opt(\"hls_segment_filename\", \"seg_%03d.ts\")"));
}
#[test]
fn audio_extract_emit() {
let code = emit_cmd("ffmpeg -i in.mp4 -vn -c:a aac -b:a 192k -y out.m4a");
assert!(code.contains(".disable_video() // -vn"));
assert!(code.contains(".set_audio_bitrate(\"192k\") // -b:a 192k"));
}
#[test]
fn unverified_emit_carries_the_scaffolding_banner() {
let code = emit_cmd("ffmpeg -i in.mp4 -c:v mpeg4 -y out.avi");
assert!(code.contains("UNVERIFIED SCAFFOLDING"));
assert!(code.contains("manifest entry U16 (video-codec-only transcode)"));
assert!(code.contains("refuses"));
assert!(!code.to_lowercase().contains("equivalent"));
}
#[test]
fn emitted_code_quotes_paths_with_spaces_and_unicode() {
let code = emit_cmd("ffmpeg -i '我的 视频.mp4' -c:v mpeg4 -y '导出 v1.avi'");
assert!(code.contains(".input(\"我的 视频.mp4\")"));
assert!(code.contains("Output::from(\"导出 v1.avi\")"));
assert!(code.contains("// command: ffmpeg -i '我的 视频.mp4'"));
}
fn mask_crate_version(code: &str) -> String {
const MARKER: &str = "crate: ez-ffmpeg ";
assert_eq!(
code.matches(MARKER).count(),
1,
"expected exactly one crate-version stamp:\n{code}"
);
let (head, rest) = code.split_once(MARKER).unwrap();
let stamp_len = rest
.bytes()
.take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+'))
.count();
let (stamp, tail) = rest.split_at(stamp_len);
assert!(
stamp.as_bytes().first().is_some_and(u8::is_ascii_digit),
"crate-version stamp is not a version: {rest:?}"
);
format!("{head}{MARKER}<crate version>{tail}")
}
#[test]
fn mask_crate_version_masks_only_the_version_stamp() {
let header = "// dialect: d; manifest: r4; crate: ez-ffmpeg 0.15.0-rc.1+meta; \
cargo features: none required\n";
assert_eq!(
mask_crate_version(header),
"// dialect: d; manifest: r4; crate: ez-ffmpeg <crate version>; \
cargo features: none required\n"
);
}
#[test]
#[should_panic(expected = "not a version")]
fn mask_crate_version_rejects_a_non_version_stamp() {
mask_crate_version("crate: ez-ffmpeg vandalized; cargo features: none required");
}
#[test]
fn every_verified_shape_emission_is_pinned_and_compiled() {
use crate::core::cli::manifest::VERIFIED_SHAPES;
for shape in VERIFIED_SHAPES {
let code = crate::core::cli::emit_rust_code_from_args(shape.canonical_argv)
.unwrap_or_else(|e| panic!("emit of {} canonical argv failed: {e}", shape.id));
let pinned = match shape.emitted_example {
"cli_emitted_transcode" => {
include_str!("../../../examples/cli_emitted_transcode.rs")
}
"cli_emitted_clip" => include_str!("../../../examples/cli_emitted_clip.rs"),
"cli_emitted_audio_extract" => {
include_str!("../../../examples/cli_emitted_audio_extract.rs")
}
"cli_emitted_thumbnail" => {
include_str!("../../../examples/cli_emitted_thumbnail.rs")
}
"cli_emitted_scale" => include_str!("../../../examples/cli_emitted_scale.rs"),
"cli_emitted_hls" => include_str!("../../../examples/cli_emitted_hls.rs"),
other => panic!("shape {} names an unpinned example {other}", shape.id),
};
let pinned = pinned.replace("\r\n", "\n");
assert_eq!(
mask_crate_version(&code),
mask_crate_version(&pinned),
"examples/{}.rs drifted from the emitter; regenerate it",
shape.emitted_example
);
}
}
#[test]
fn emitted_output_calls_follow_the_runtime_apply_order() {
let code = emit_cmd(
"ffmpeg -i in.mp4 -ss 3 -t 4 -c:v libx264 -crf 23 -preset fast -c:a aac -y out.mp4",
);
let runtime_order = [
".set_start_time_us(3_000_000)",
".set_recording_time_us(4_000_000)",
".set_video_codec(\"libx264\")",
".set_audio_codec(\"aac\")",
".set_video_codec_opt(\"crf\", \"23\")",
".set_video_codec_opt(\"preset\", \"fast\")",
];
let mut previous: Option<(usize, &str)> = None;
for call in runtime_order {
let at = code
.find(call)
.unwrap_or_else(|| panic!("emitted code lost {call}:\n{code}"));
if let Some((prev_at, prev_call)) = previous {
assert!(
at > prev_at,
"emitted {call} before {prev_call}, diverging from the runtime apply \
order:\n{code}"
);
}
previous = Some((at, call));
}
}
#[test]
fn faststart_remux_emit_is_unverified_with_explicit_copies() {
let code =
emit_cmd("ffmpeg -i in.mp4 -c:v copy -c:a copy -movflags +faststart -y faststart.mp4");
assert!(code.contains("UNVERIFIED SCAFFOLDING"));
assert!(code.contains(".set_video_codec(\"copy\") // -c:v copy"));
assert!(code.contains(".set_audio_codec(\"copy\") // -c:a copy"));
assert!(code.contains(".set_format_opt(\"movflags\", \"+faststart\")"));
}
}