ez_ffmpeg/core/cli/mod.rs
1//! CLI-compat layer (`cli` feature): run or translate a strict, documented
2//! subset of ffmpeg command lines in-process.
3//!
4//! Two capabilities share one typed intermediate representation:
5//!
6//! - **Run**: [`from_cli_args`] / [`from_cli`] parse a command and build a
7//! ready-to-start [`FfmpegContext`] on the crate's native pipeline.
8//! - **Translate**: [`emit_rust_code_from_args`] / [`emit_rust_code`] turn
9//! the same command into a complete, compile-ready builder program to copy
10//! into your codebase.
11//!
12//! ```no_run
13//! use ez_ffmpeg::cli::from_cli_args;
14//!
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! from_cli_args(&["-i", "in.mkv", "-c:v", "libx264", "-crf", "23",
17//! "-preset", "fast", "-c:a", "aac", "-y", "out.mp4"])?
18//! .start()?
19//! .wait()?;
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! The layer is optional; enable it in `Cargo.toml`:
25//!
26//! ```toml
27//! [dependencies.ez-ffmpeg]
28//! version = "*"
29//! features = ["cli"]
30//! ```
31//!
32//! # The contract: classify everything, approximate nothing
33//!
34//! **Broad CLI compatibility is a non-goal.** The ffmpeg CLI is ~14k lines
35//! of option machinery whose semantics move with every release; chasing it
36//! wholesale produces the "runs, but subtly different" failures that destroy
37//! trust. This layer does the opposite:
38//!
39//! - every argv token must classify against the versioned compatibility
40//! manifest, or the ENTIRE command is rejected with a token-anchored,
41//! typed diagnostic ([`CliError`]) — nothing is dropped or guessed;
42//! - commands whose exact shape is backed by a semantic golden test (stream
43//! identity, codecs, dimensions, durations, playlist topology compared
44//! against the real ffmpeg CLI) are **verified**: they may execute;
45//! - commands that parse and match an ENUMERATED unverified manifest entry
46//! are **emit-only**: the code generators label their output "unverified
47//! scaffolding" and [`from_cli_args`] refuses to run them;
48//! - commands that parse but match neither manifest table are **rejected
49//! outright** ([`CliError::UnmatchedShape`]) — run and emit alike; no
50//! silent scaffolding class exists;
51//! - execution additionally requires a verified runtime profile of the
52//! linked FFmpeg (currently 7.1 only; 8.1 joins once its version-matched
53//! golden lane passes) — anything else fails with the typed
54//! [`CliError::UnverifiedRuntimeProfile`] before any I/O.
55//!
56//! CLI-initiated pipelines also run with strict AVOption handling: an option
57//! no component consumed fails the run (fftools `check_avoptions` parity)
58//! instead of the default builder path's warning.
59//!
60//! # The manifest (generated; revision-pinned by an exact-equality test)
61//!
62//! <!-- manifest:begin -->
63//! Manifest revision 4; dialect: ffmpeg 7.1 command line.
64//!
65//! | option | scope | selector | repeat | notes | maps to |
66//! |---|---|---|---|---|---|
67//! | `-y` | global | run | repeatable | flag | mandatory overwrite gate |
68//! | `-hide_banner` | global | no-op | repeatable | accepted, no in-process effect | none (documented no-op) |
69//! | `-nostdin` | global | no-op | repeatable | accepted, no in-process effect | none (documented no-op) |
70//! | `-stats` | global | no-op | repeatable | accepted, no in-process effect | none (documented no-op) |
71//! | `-nostats` | global | no-op | repeatable | accepted, no in-process effect | none (documented no-op) |
72//! | `-loglevel` | global | no-op | repeatable | no in-process effect; value: `[repeat+][level+]LEVEL` | none (documented no-op) |
73//! | `-v` | global | no-op | repeatable | no in-process effect; value: `[repeat+][level+]LEVEL` | none (documented no-op) |
74//! | `-ss` | input or output (position-scoped) | container | once | decimal seconds only (`10`, `2.5`) | Input::set_start_time_us / Output::set_start_time_us |
75//! | `-t` | input or output (position-scoped) | container | once | decimal seconds only (`10`, `2.5`) | Input::set_recording_time_us / Output::set_recording_time_us |
76//! | `-to` | input or output (position-scoped) | container | once | decimal seconds only (`10`, `2.5`) | Input::set_stop_time_us / Output::set_stop_time_us |
77//! | `-f` | input or output (position-scoped) | container | once | container/demuxer name (`[a-z0-9_]+`) | Input::set_format / Output::set_format |
78//! | `-vn` | output | video | repeatable | flag | Output::disable_video |
79//! | `-an` | output | audio | repeatable | flag | Output::disable_audio |
80//! | `-c:v` | output | video | once | codec name or `copy` (`[A-Za-z0-9_-]+`) | Output::set_video_codec |
81//! | `-c:a` | output | audio | once | codec name or `copy` (`[A-Za-z0-9_-]+`) | Output::set_audio_codec |
82//! | `-b:v` | output | video | once | `NNN` with optional `k`/`K`/`m`/`M` suffix | Output::set_video_bitrate |
83//! | `-b:a` | output | audio | once | `NNN` with optional `k`/`K`/`m`/`M` suffix | Output::set_audio_bitrate |
84//! | `-crf` | output | video | once | integer 0..=51 | Output::set_video_codec_opt("crf", …), libx264 only |
85//! | `-preset` | output | video | once | x264 preset name | Output::set_video_codec_opt("preset", …), libx264 only |
86//! | `-pix_fmt` | output | video | once | pixel format name (`[a-z0-9_]+`) | Output::set_pix_fmt |
87//! | `-ar` | output | audio | once | positive integer | Output::set_audio_sample_rate |
88//! | `-ac` | output | audio | once | positive integer | Output::set_audio_channels |
89//! | `-frames:v` | output | video | once | exactly `1` | Output::set_max_video_frames(1) |
90//! | `-vf` | output | video | once | single `scale=…` chain only | Output::set_video_filter |
91//! | `-map` | output | stream map | accumulates | basic index maps only (`0`, `0:v`, `0:a:1`, `0:1`, …) | Output::add_stream_map / add_stream_map_with_copy |
92//! | `-movflags` | output | container | once | exactly `+faststart` | Output::set_format_opt("movflags", "+faststart") |
93//! | `-hls_time` | output | container | once | decimal seconds > 0 | Output::set_format_opt("hls_time", …) |
94//! | `-hls_playlist_type` | output | container | once | exactly `vod` | Output::set_format_opt("hls_playlist_type", "vod") |
95//! | `-hls_list_size` | output | container | once | exactly `0` | Output::set_format_opt("hls_list_size", "0") |
96//! | `-hls_segment_filename` | output | container | once | non-empty path; `-`-leading values rejected | Output::set_format_opt("hls_segment_filename", …) |
97//!
98//! Command layout is fixed: exactly one `-i` input and exactly one output
99//! path, in the canonical `[global/input options] -i INPUT [output options]
100//! OUTPUT [global options]` order — after the output path only GLOBAL options
101//! are accepted (e.g. a trailing `-y`). The `-` stdin/stdout pseudo-paths are
102//! excluded — pipe I/O is process wiring, not part of the in-process subset.
103//!
104//! Verified shapes (may execute; each is backed by a semantic golden and a
105//! compile-pinned emitted example):
106//!
107//! | id | shape | container |
108//! |---|---|---|
109//! | V1 | H.264/AAC transcode (crf + preset) | `.mp4` |
110//! | V2 | re-encoded clip (input -ss, output -t) | `.mp4` |
111//! | V3 | audio extract (-vn, AAC) | `.m4a` |
112//! | V4 | single-frame thumbnail (input -ss, -an, mjpeg) | `.jpg` |
113//! | V5 | scaled H.264/AAC transcode (-vf scale) | `.mp4` |
114//! | V6 | single-rendition VOD HLS | `.m3u8` |
115//!
116//! Unverified entries (emit-only: code generation with a scaffolding banner,
117//! execution refused):
118//!
119//! | id | shape |
120//! |---|---|
121//! | U1 | input-side trim (-ss + -t before -i) |
122//! | U2 | faststart remux (explicit per-media copy) |
123//! | U3 | scale with audio copy |
124//! | U4 | thumbnail recipe shape (-vf + -frames:v) |
125//! | U5 | audio extract via stream copy |
126//! | U6 | PCM / resampled audio extract |
127//! | U7 | partial HLS option set |
128//! | U8 | mapped transcode (basic index maps) |
129//! | U9 | mapped audio copy |
130//! | U10 | cross-scope trim pair (input -t, output -to) |
131//! | U11 | output-side seek transcode |
132//! | U12 | bitrate-driven transcode |
133//! | U13 | pixel-format transcode |
134//! | U14 | explicit input format (e.g. lavfi) |
135//! | U15 | container-defaults remux |
136//! | U16 | video-codec-only transcode |
137//! | U17 | transcode without preset |
138//! | U18 | transcode with audio bitrate |
139//! | U19 | audio+video codec selection only |
140//! | U20 | transcode shape with unpinned values/container |
141//! | U21 | clip shape with unpinned values/container |
142//! | U22 | audio-extract shape with unpinned values/container |
143//! | U23 | thumbnail shape with unpinned values/container |
144//! | U24 | scaled-transcode shape with unpinned values/container |
145//! | U25 | VOD HLS shape with unpinned values/container |
146//!
147//! Everything else — including parseable option sets outside both tables — is
148//! rejected with a typed diagnostic.
149//! <!-- manifest:end -->
150//!
151//! # String form
152//!
153//! [`from_cli`] / [`emit_rust_code`] accept one string and apply POSIX word
154//! splitting ONLY: single/double quotes, backslash escapes,
155//! backslash-newline continuation, plus caret-newline continuation as an
156//! explicitly named cmd.exe compatibility extension. No variables, globs,
157//! tilde, pipes, redirects, comments or command lists — those tokens are
158//! rejected, never emulated (unquoted `*`, `?` and `[` included: quote or
159//! escape a literal). Windows `cmd.exe` quoting is NOT reproduced:
160//! commands relying on `CommandLineToArgvW` backslash rules must use the
161//! argv form.
162
163mod emit;
164mod error;
165mod ir;
166mod lower;
167mod manifest;
168mod parse;
169mod table;
170mod tokenize;
171
172#[cfg(all(test, not(docsrs)))]
173mod corpus_tests;
174#[cfg(all(test, not(docsrs)))]
175mod golden_tests;
176#[cfg(all(test, not(docsrs)))]
177mod strict_tests;
178
179pub use error::{CliError, CliScope};
180
181use crate::core::context::ffmpeg_context::FfmpegContext;
182use manifest::{ShapeStatus, VERIFIED_PROFILES};
183
184/// Builds a ready-to-start [`FfmpegContext`] from ffmpeg-style argv tokens.
185///
186/// This is the primary form: argv has zero quoting ambiguity (the same shape
187/// `ffmpeg.wasm` chose). A leading `ffmpeg` / `ffmpeg.exe` token is
188/// tolerated and stripped.
189///
190/// The command must classify completely against the compatibility manifest
191/// AND match a verified (golden-backed) shape, and the linked FFmpeg must be
192/// a verified runtime profile — otherwise a typed [`CliError`] is returned
193/// before any I/O. See the [module docs](self) for the exact surface.
194pub fn from_cli_args<S: AsRef<str>>(args: &[S]) -> Result<FfmpegContext, CliError> {
195 let args: Vec<String> = args.iter().map(|s| s.as_ref().to_string()).collect();
196 let args = tokenize::strip_program_token(args);
197 run_from_tokens(&args)
198}
199
200/// Single-string convenience wrapper over [`from_cli_args`].
201///
202/// Applies the documented POSIX word-splitting contract (see the
203/// [module docs](self)); everything after tokenization is identical to the
204/// argv form.
205pub fn from_cli(command: &str) -> Result<FfmpegContext, CliError> {
206 let args = tokenize::tokenize(command)?;
207 run_from_tokens(&args)
208}
209
210/// Translates ffmpeg-style argv tokens into a complete Rust program using
211/// the ez-ffmpeg builder API.
212///
213/// Emission works for verified shapes and for the manifest's enumerated
214/// unverified entries — including shapes that are
215/// not verified for execution, whose output is prominently labeled
216/// "unverified scaffolding".
217///
218/// The generated program and [`from_cli_args`] consume the same lowered
219/// plan — same builder calls, same values, same order — but they are not
220/// policy-identical. The runtime path layers three checks on top that the
221/// emitted program deliberately does not carry:
222///
223/// - **runtime-profile gate**: [`from_cli_args`] refuses to execute on a
224/// non-verified linked FFmpeg ([`CliError::UnverifiedRuntimeProfile`]);
225/// the emitted program runs against whatever FFmpeg it is built with;
226/// - **strict AVOption handling**: an in-process run fails on an option no
227/// component consumed (fftools `check_avoptions` parity, set through a
228/// crate-private flag); the emitted builder program only logs the
229/// default warning;
230/// - **unique-video-source prerequisite**: an in-process `-vf` run fails
231/// unless the opened input has exactly one video stream
232/// ([`CliError::AmbiguousFilterSource`]); the emitted program uses the
233/// builder default, which score-selects a stream like the ffmpeg CLI.
234pub fn emit_rust_code_from_args<S: AsRef<str>>(args: &[S]) -> Result<String, CliError> {
235 let args: Vec<String> = args.iter().map(|s| s.as_ref().to_string()).collect();
236 let args = tokenize::strip_program_token(args);
237 emit_from_tokens(&args)
238}
239
240/// Single-string convenience wrapper over [`emit_rust_code_from_args`],
241/// using the same POSIX word-splitting contract as [`from_cli`].
242pub fn emit_rust_code(command: &str) -> Result<String, CliError> {
243 let args = tokenize::tokenize(command)?;
244 emit_from_tokens(&args)
245}
246
247fn run_from_tokens(args: &[String]) -> Result<FfmpegContext, CliError> {
248 let ir = parse::parse(args)?;
249 match manifest::classify(&ir) {
250 ShapeStatus::Verified(_) => {}
251 ShapeStatus::Unverified(_) => {
252 return Err(CliError::NotVerified {
253 parsed_options: ir.fingerprint().iter().map(|s| s.to_string()).collect(),
254 });
255 }
256 ShapeStatus::Unmatched => {
257 return Err(CliError::UnmatchedShape {
258 parsed_options: ir.fingerprint().iter().map(|s| s.to_string()).collect(),
259 });
260 }
261 }
262 check_runtime_profile()?;
263 // The hard simple-filter prerequisite (exactly one video stream under a
264 // -vf command) is enforced INSIDE context binding, on the demuxer
265 // instances the pipeline executes with — one opening, no TOCTOU window,
266 // no second remote fetch. The lowering arms it; here the typed core
267 // error is translated to the public diagnostic.
268 lower::lower(&ir).into_context().map_err(|err| match err {
269 crate::error::Error::AmbiguousVideoSource { video_streams } => {
270 CliError::AmbiguousFilterSource { video_streams }
271 }
272 other => CliError::Build(other),
273 })
274}
275
276fn emit_from_tokens(args: &[String]) -> Result<String, CliError> {
277 let ir = parse::parse(args)?;
278 let status = manifest::classify(&ir);
279 if status == ShapeStatus::Unmatched {
280 // No silent scaffolding class: unenumerated shapes get nothing.
281 return Err(CliError::UnmatchedShape {
282 parsed_options: ir.fingerprint().iter().map(|s| s.to_string()).collect(),
283 });
284 }
285 Ok(emit::emit(&lower::lower(&ir), args, &status))
286}
287
288/// Major.minor of the linked libavcodec and libavformat (in that order),
289/// extracted from FFmpeg's packed version words.
290fn linked_version_pairs() -> ((u32, u32), (u32, u32)) {
291 let pair = |v: u32| (v >> 16, (v >> 8) & 0xff);
292 (
293 pair(unsafe { ffmpeg_sys_next::avcodec_version() }),
294 pair(unsafe { ffmpeg_sys_next::avformat_version() }),
295 )
296}
297
298/// Whether the LINKED libavcodec/libavformat pair matches a verified runtime
299/// profile. Tests use this to stay honest on non-verified lanes: on a linked
300/// 8.x build the correct expectation is the typed `UnverifiedRuntimeProfile`
301/// failure, not runtime success.
302pub(crate) fn linked_profile_verified() -> bool {
303 let (avcodec, avformat) = linked_version_pairs();
304 VERIFIED_PROFILES
305 .iter()
306 .any(|profile| avcodec == profile.avcodec && avformat == profile.avformat)
307}
308
309/// Runtime-profile gate: in-process execution is allowed only on linked
310/// FFmpeg builds whose libavcodec/libavformat major.minor pairs match a
311/// verified profile. Purely a version check — it runs before any I/O.
312fn check_runtime_profile() -> Result<(), CliError> {
313 if linked_profile_verified() {
314 return Ok(());
315 }
316 let ((ac_major, ac_minor), (af_major, af_minor)) = linked_version_pairs();
317 Err(CliError::UnverifiedRuntimeProfile {
318 linked_avcodec: format!("{ac_major}.{ac_minor}"),
319 linked_avformat: format!("{af_major}.{af_minor}"),
320 verified: VERIFIED_PROFILES
321 .iter()
322 .map(|profile| {
323 format!(
324 "{} (avcodec {}.{}, avformat {}.{})",
325 profile.name,
326 profile.avcodec.0,
327 profile.avcodec.1,
328 profile.avformat.0,
329 profile.avformat.1
330 )
331 })
332 .collect::<Vec<_>>()
333 .join(", "),
334 })
335}
336
337#[cfg(test)]
338mod facade_tests {
339 use super::*;
340
341 #[test]
342 fn runtime_profile_gate_matches_the_linked_build() {
343 // Profile-aware: on a verified line (7.1) the gate passes; on any
344 // other linked line the typed failure IS the correct behavior, and
345 // asserting it keeps non-verified CI lanes green and honest.
346 match check_runtime_profile() {
347 Ok(()) => assert!(
348 linked_profile_verified(),
349 "gate passed on a non-verified linked profile"
350 ),
351 Err(CliError::UnverifiedRuntimeProfile { .. }) => assert!(
352 !linked_profile_verified(),
353 "gate rejected a verified linked profile"
354 ),
355 Err(other) => panic!("unexpected gate error: {other}"),
356 }
357 }
358
359 /// Fail-closed pin with LITERAL version pairs. `VERIFIED_PROFILES` is
360 /// deliberately not consulted: the test above derives its expectation
361 /// from the same table the gate reads, so a table edit that silently
362 /// widened the gate (say, adding an unproven 8.x row) would satisfy
363 /// both sides. FFmpeg 7.1 is the only verified line — libavcodec 61.19
364 /// / libavformat 61.7 — and those numbers are hardcoded here: on any
365 /// other linked pair, `from_cli_args` on a verified-shape command must
366 /// return the typed profile refusal before any I/O.
367 #[test]
368 fn non_71_linked_pair_fails_closed_by_literal_version() {
369 let pair = |v: u32| (v >> 16, (v >> 8) & 0xff);
370 let linked_is_71 = pair(unsafe { ffmpeg_sys_next::avcodec_version() }) == (61, 19)
371 && pair(unsafe { ffmpeg_sys_next::avformat_version() }) == (61, 7);
372 let args = [
373 "-i",
374 "no_such_fixture.mkv",
375 "-c:v",
376 "libx264",
377 "-crf",
378 "23",
379 "-preset",
380 "fast",
381 "-c:a",
382 "aac",
383 "-y",
384 "out.mp4",
385 ];
386 match from_cli_args(&args) {
387 Err(CliError::UnverifiedRuntimeProfile { .. }) => assert!(
388 !linked_is_71,
389 "profile refusal on the literal 7.1 pair (61.19/61.7)"
390 ),
391 Ok(_) => assert!(
392 linked_is_71,
393 "a non-7.1 linked pair must fail closed with UnverifiedRuntimeProfile"
394 ),
395 Err(other) => assert!(
396 linked_is_71,
397 "a non-7.1 linked pair must fail closed with UnverifiedRuntimeProfile, got: {other}"
398 ),
399 }
400 }
401
402 #[test]
403 fn unverified_shape_is_refused_at_run_but_emitted() {
404 let args = ["-i", "in.mp4", "-c:v", "mpeg4", "-y", "out.avi"];
405 let err = from_cli_args(&args).map(|_| ()).unwrap_err();
406 assert!(
407 matches!(&err, CliError::NotVerified { parsed_options }
408 if parsed_options.iter().any(|o| o == "out:-c:v")),
409 "unexpected error: {err}"
410 );
411 let code = emit_rust_code_from_args(&args).unwrap();
412 assert!(code.contains("UNVERIFIED SCAFFOLDING"));
413 }
414
415 #[test]
416 fn argv_form_tolerates_a_program_token() {
417 let err = from_cli_args(&["ffmpeg", "-i", "in.mp4", "-c:v", "mpeg4", "-y", "o.avi"])
418 .map(|_| ())
419 .unwrap_err();
420 // Stripped "ffmpeg" leaves a parseable (unverified) command, proving
421 // the token was not mistaken for an output path.
422 assert!(
423 matches!(err, CliError::NotVerified { .. }),
424 "unexpected error: {err}"
425 );
426 }
427
428 #[test]
429 fn run_and_emit_reject_identically() {
430 let args = ["-i", "in.mp4", "-fps_mode", "cfr", "-y", "out.mp4"];
431 let run_err = from_cli_args(&args).map(|_| ()).unwrap_err();
432 let emit_err = emit_rust_code_from_args(&args).unwrap_err();
433 assert_eq!(run_err.to_string(), emit_err.to_string());
434 }
435
436 #[test]
437 fn loglevel_grammar_is_wired_to_the_facade() {
438 // End-to-end pin of the -loglevel value-grammar wiring: the
439 // flag-prefix grammar must be reachable through from_cli itself,
440 // anchored at the VALUE token — disconnecting -loglevel from its
441 // rule would fail here even if the rule's own unit tests stayed
442 // green.
443 let err = from_cli(
444 "ffmpeg -hide_banner -loglevel banana+error -i in.mkv -c:v libx264 -crf 23 -preset fast -c:a aac -y out.mp4",
445 )
446 .map(|_| ())
447 .unwrap_err();
448 match &err {
449 CliError::UnsupportedValue {
450 option,
451 value,
452 index,
453 ..
454 } => {
455 assert_eq!(option, "-loglevel");
456 assert_eq!(value, "banana+error");
457 // tokens: 0=-hide_banner 1=-loglevel 2=banana+error …
458 assert_eq!(*index, 2, "the VALUE token is the anchor");
459 }
460 other => panic!("expected UnsupportedValue for banana+error, got {other}"),
461 }
462 }
463
464 #[test]
465 fn every_rejection_carries_the_cta() {
466 for args in [
467 &["-i", "in.mp4", "-fps_mode", "cfr", "-y", "out.mp4"][..],
468 &["-i", "in.mp4", "out.mp4"][..],
469 &["-i", "in.mp4", "-c:v", "mpeg4", "-y", "out.avi"][..],
470 ] {
471 let err = from_cli_args(args).map(|_| ()).unwrap_err();
472 assert!(
473 err.to_string().contains("open an issue"),
474 "rejection lost its CTA: {err}"
475 );
476 }
477 }
478}