ffmpeg and ffprobe
ffmpeg and ffprobe through argument lists: a file probed to a map, thumbnails, trims, transcodes and HLS segments, a test video made from lavfi, progress read from -progress with a background job followed by percent, and everything checked first: paths forced to `file:`, so URLs and concat: stay plain names, and codecs, times and sizes refused unless they are what they should be.
Version 1.0.0 · 11 functions · 2 crates
Functions
What use ffmpeg_cli.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use ffmpeg_probe, ffmpeg_thumbnail from ffmpeg_cli (Quillo 0.6).
ffmpeg_probe(path: str) → anyor an errorReads a media file with ffprobe: { format, format_name, duration, size, bit_rate, tags, video: { codec, profile, width, height, pixel_format, fps, frames, rotation, bit_rate }, audio: { codec, sample_rate, channels, layout, bit_rate }, streams, subtitles, chapters }. A file that is not media is an error saying so.
ffmpeg_thumbnail(input: str, output: str, options: any) → anyor an errorOne frame as an image: at (a time), width or height (the other keeps the aspect ratio), overwrite. The extension picks JPEG, PNG or WebP.
ffmpeg_trim(input: str, output: str, options: any) → anyor an errorCuts a piece: start, and end or duration. Streams are copied unless copy: false, which re-encodes, cutting exactly.
ffmpeg_transcode(input: str, output: str, options: any) → anyor an errorTranscodes: video_codec, audio_codec, crf, preset, video_bitrate, audio_bitrate, width, height, fps, pixel_format, no_audio, no_video, faststart, overwrite, timeout_ms.
ffmpeg_transcode_args(input: str, output: str, options: any) → listor an errorThe arguments a transcode would run with, as a list: for logging, or a test. Nothing runs.
ffmpeg_hls(input: str, folder: str, options: any) → anyor an errorCuts a video into HLS: index.m3u8 and seg_00000.ts files in folder, each about segment_seconds (6) long, with the options of a transcode (streams copied unless a codec is given). Answers { playlist, segments: [{ file, duration }], target_duration, bytes }, read from the playlist.
ffmpeg_generate(output: str, options: any) → anyor an errorA test video from ffmpeg's own lavfi source, colour bars and a tone: seconds (2), width (320), height (240), fps (25), audio (true). For tests and demos: no sample to ship.
ffmpeg_start(name: str, input: str, output: str, options: any) → boolor an errorStarts a transcode in the background under name and answers at once; ffmpeg_progress follows it, with percent worked out from the input's length.
ffmpeg_progress(name: str) → anyor an errorWhere a background job is: { running, percent, frame, fps, speed, out_ms, size, finished }; once it has ended, its result, or error. Its name is free after that.
ffmpeg_cancel(name: str) → boolor an errorStops a background job and removes its partial output; true when there was one.
ffmpeg_version() → anyor an errorWhich ffmpeg this is: its version line, and what it can encode among the usual codecs.
Get it
$ quillo install ffmpeg_cli # ffmpeg_cli.qwer into this folder$ quillo install ffmpeg_cli --example # and its example, ffmpeg_cli.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/ffmpeg_cli.qwer$ curl -O https://quillolang.org/plugins/files/ffmpeg_cli.qweThen, in your program, all of it or only what you call:
use ffmpeg_cli.qweruse ffmpeg_probe, ffmpeg_thumbnail from ffmpeg_cli // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
libc = "0.2"serde_json = { version = "1", features = ["preserve_order"] }Example
// A small video pipeline around the ffmpeg command: a file probed, a poster frame, a clip, a// smaller copy, HLS for a player, and a long job followed in the background. The sample is made by// ffmpeg's own test source, so nothing ships with this example.//// FFMPEG_OUT=/tmp/video-work quillo run ffmpeg_cli.qwe then open http://localhost:8080/probe//// ffmpeg and ffprobe must be installed: FFMPEG_BIN and FFPROBE_BIN name them if not on PATH.use "ffmpeg_cli.qwer"let OUT = env("FFMPEG_OUT", "/tmp/quillo-ffmpeg-demo")let SAMPLE = `${OUT}/sample.mp4`let DONE = { overwrite: true, timeout_ms: 60000 }ffmpeg_generate(SAMPLE, { seconds: 4, width: 320, height: 240, fps: 25, overwrite: true })route GET /probe { let p = ffmpeg_probe(SAMPLE)? { format: p.format, duration: p.duration, video: [p.video.codec, p.video.width, p.video.height, p.video.pixel_format, p.video.fps], audio: [p.audio.codec, p.audio.sample_rate, p.audio.channels], streams: p.streams | map((s) => s.type) }}route GET /thumbnail { let t = ffmpeg_thumbnail(SAMPLE, `${OUT}/poster.jpg`, { ...DONE, at: 1.5, width: 160 })? let p = ffmpeg_probe(`${OUT}/poster.jpg`)? { made: t.bytes > 500, size: [p.video.width, p.video.height], codec: p.video.codec }}route GET /trim { let c = ffmpeg_trim(SAMPLE, `${OUT}/clip.mp4`, { ...DONE, start: 1, duration: 2 })? let e = ffmpeg_trim(SAMPLE, `${OUT}/exact.mp4`, { ...DONE, start: "00:00:00.5", end: "00:00:01.5", copy: false })? { clip: round(ffmpeg_probe(c.output)?.duration), exact: round(ffmpeg_probe(e.output)?.duration * 10), same_codec: ffmpeg_probe(c.output)?.video.codec }}route GET /transcode { let r = ffmpeg_transcode(SAMPLE, `${OUT}/small.mp4`, { ...DONE, width: 160, crf: 30, preset: "ultrafast", audio_bitrate: "64k", faststart: true, video_codec: "libx264", audio_codec: "aac" })? let silent = ffmpeg_transcode(SAMPLE, `${OUT}/silent.mp4`, { ...DONE, no_audio: true, video_codec: "libx264", preset: "ultrafast" })? let sound = ffmpeg_transcode(SAMPLE, `${OUT}/sound.m4a`, { ...DONE, no_video: true })? let p = ffmpeg_probe(r.output)? { size: [p.video.width, p.video.height], frames: r.frames, progress_read: r.out_ms > 3000, silent: ffmpeg_probe(silent.output)?.audio, sound: ffmpeg_probe(sound.output)?.video, sound_audio: ffmpeg_probe(sound.output)?.audio.codec }}route GET /hls { let h = ffmpeg_hls(SAMPLE, `${OUT}/hls`, { ...DONE, segment_seconds: 1 })? { count_ok: len(h.segments) >= 3, first: h.segments[0].file, ended: h.ended, target: h.target_duration, total: round(reduce(h.segments, 0, (a, s) => a + s.duration)), bytes_ok: h.bytes > 1000 }}route GET /background { ffmpeg_start("job", SAMPLE, `${OUT}/job.mp4`, { ...DONE, video_codec: "libx264", preset: "ultrafast" })? let twice = ffmpeg_start("job", SAMPLE, `${OUT}/x.mp4`, DONE).error let tries = 0 let last = ffmpeg_progress("job")? while last.running && tries < 500 { sleep(20) last = ffmpeg_progress("job")? tries = tries + 1 } ffmpeg_start("stop-me", SAMPLE, `${OUT}/stopped.mp4`, { ...DONE, video_codec: "libx264" })? { running: last.running, percent: last.percent, finished: last.finished, made: last.result.bytes > 1000, twice: twice, gone: ffmpeg_progress("job").error, cancelled: ffmpeg_cancel("stop-me")?, no_such: ffmpeg_cancel("stop-me")? }}// What reaches the command line: a hostile name stays a path, and a bad value never gets there.route GET /safety = { args: ffmpeg_transcode_args(SAMPLE, `${OUT}/a.mp4`, { ...DONE, video_codec: "libx264", crf: 23, width: 640, audio_bitrate: "128k" })? | filter((a) => !contains(a, "demo") && !contains(a, OUT)), url: ffmpeg_probe("http://example.com/a.mp4").error, concat: ffmpeg_probe("concat:/etc/passwd|/etc/hosts").error, option: ffmpeg_probe("-version").error, codec: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, video_codec: "x; rm -rf /" }).error, preset: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, preset: "-y" }).error, time: ffmpeg_thumbnail(SAMPLE, `${OUT}/c.jpg`, { ...DONE, at: "1;2" }).error, crf: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, crf: 99 }).error, size: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, width: 1 }).error, typo: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, vidoe_codec: "h264" }).error, both: ffmpeg_trim(SAMPLE, `${OUT}/b.mp4`, { ...DONE, end: 2, duration: 1 }).error, backwards: ffmpeg_trim(SAMPLE, `${OUT}/b.mp4`, { ...DONE, start: 3, end: 2 }).error, extension: ffmpeg_transcode(SAMPLE, `${OUT}/noext`, DONE).error,}route GET /failures = { not_media: ffmpeg_probe("ffmpeg_cli.qwe").error, missing: ffmpeg_probe("nothing-here.mp4").error, exists: ffmpeg_transcode(SAMPLE, SAMPLE, {}).error != null, bad_encoder: ffmpeg_transcode(SAMPLE, `${OUT}/b.mp4`, { ...DONE, video_codec: "no_such_codec" }).error, version_known: ffmpeg_version()?.version != null,}$ quillo run ffmpeg_cli.qwe$ curl 'localhost:8080/probe'$ curl 'localhost:8080/thumbnail'$ curl 'localhost:8080/trim'$ curl 'localhost:8080/transcode'$ curl 'localhost:8080/hls'$ curl 'localhost:8080/background'$ curl 'localhost:8080/safety'$ curl 'localhost:8080/failures'Source
//! ffmpeg and ffprobe driven through argument lists, never a shell: a file probed to a map, thumbnails, trims, transcodes and HLS segments, values checked before they reach the command line, paths kept from being read as URLs or options, and progress read from `-progress`, in the call or for a job in the background.//! version: 1.0.0//!//! [dependencies]//! libc = "0.2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::HashMap;use std::io::{BufRead, BufReader, Read};use std::os::unix::process::CommandExt;use std::path::Path;use std::process::{Child, Command, Stdio};use std::sync::{Arc, Mutex, OnceLock};use std::time::{Duration, Instant};const DEFAULT_TIMEOUT: u64 = 600_000;const OPTIONS: [&str; 21] = [ "overwrite", "timeout_ms", "at", "start", "end", "duration", "copy", "width", "height", "fps", "video_codec", "audio_codec", "crf", "preset", "video_bitrate", "audio_bitrate", "no_audio", "no_video", "pixel_format", "faststart", "segment_seconds",];fn ffmpeg() -> String { std::env::var("FFMPEG_BIN").ok().filter(|s| !s.is_empty()).unwrap_or("ffmpeg".to_string())}fn ffprobe() -> String { std::env::var("FFPROBE_BIN").ok().filter(|s| !s.is_empty()).unwrap_or("ffprobe".to_string())}fn check_options(o: &Value) -> Result<(), String> { for k in o.as_object().into_iter().flat_map(|m| m.keys()) { if !OPTIONS.contains(&k.as_str()) { return Err(format!("no option `{}`; there are {}", k, OPTIONS.join(", "))); } } Ok(())}// `file:` makes ffmpeg read this as a path, whatever it looks like: `concat:a|b`, `http://x`,// `-y`, `subfile,,start,...`. ffmpeg's own protocols never see a caller's text.fn input_arg(path: &str) -> Result<String, String> { if path.is_empty() || path.contains('\0') { return Err("no input named".into()); } if !Path::new(path).is_file() { return Err(format!("{}: no such file", path)); } Ok(format!("file:{}", path))}fn output_arg(path: &str, o: &Value) -> Result<String, String> { if path.is_empty() || path.contains('\0') { return Err("no output named".into()); } if Path::new(path).extension().is_none() { return Err(format!("{}: an output needs an extension, which picks the format", path)); } if Path::new(path).exists() && !flag(o, "overwrite") { return Err(format!("{} already exists: give `overwrite: true`", path)); } if let Some(d) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| format!("{}: {}", d.display(), e))?; } Ok(format!("file:{}", path))}fn flag(o: &Value, k: &str) -> bool { o.get(k).and_then(|v| v.as_bool()).unwrap_or(false)}fn word(o: &Value, k: &str) -> Result<Option<String>, String> { match o.get(k).and_then(|v| v.as_str()) { None => Ok(None), Some(w) if !w.is_empty() && !w.starts_with('-') && w.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') => Ok(Some(w.to_string())), Some(w) => Err(format!("`{}` is not a {}", w, k.replace('_', " "))), }}// "1.5", 90, "00:01:30", "00:01:30.250": as ffmpeg takes a time.fn time(o: &Value, k: &str) -> Result<Option<String>, String> { let t = match o.get(k) { None | Some(Value::Null) => return Ok(None), Some(Value::Number(n)) => n.as_f64().filter(|x| *x >= 0.0).map(|x| x.to_string()).ok_or(format!("`{}` cannot be negative", k))?, Some(Value::String(s)) => s.clone(), Some(x) => return Err(format!("{} is not a time", x)), }; if t.is_empty() || !t.bytes().all(|c| c.is_ascii_digit() || c == b':' || c == b'.') { return Err(format!("`{}` is not a time: seconds, or HH:MM:SS.mmm", t)); } Ok(Some(t))}fn seconds_of(t: &str) -> f64 { t.split(':').fold(0.0, |a, x| a * 60.0 + x.parse::<f64>().unwrap_or(0.0))}fn even_size(o: &Value, k: &str) -> Result<Option<u64>, String> { match o.get(k) { None | Some(Value::Null) => Ok(None), Some(v) => v.as_u64().filter(|n| (2..=16384).contains(n)).map(Some).ok_or(format!("{} {} is not a size", k, v)), }}fn scale_filter(o: &Value) -> Result<Option<String>, String> { let (w, h) = (even_size(o, "width")?, even_size(o, "height")?); // -2 keeps the aspect ratio with an even size, which H.264 needs. Ok(match (w, h) { (None, None) => None, (Some(a), None) => Some(format!("scale={}:-2", a)), (None, Some(b)) => Some(format!("scale=-2:{}", b)), (Some(a), Some(b)) => Some(format!("scale={}:{}", a, b)), })}fn base(o: &Value) -> Vec<String> { let mut a: Vec<String> = ["-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", "-progress", "pipe:1"].iter().map(|s| s.to_string()).collect(); a.push(if flag(o, "overwrite") { "-y".into() } else { "-n".into() }); a}fn encoder_args(a: &mut Vec<String>, o: &Value) -> Result<(), String> { if flag(o, "no_video") { a.push("-vn".into()); } else { if let Some(c) = word(o, "video_codec")? { a.extend(["-c:v".into(), c]); } if let Some(n) = o.get("crf") { let c = n.as_u64().filter(|x| *x <= 63).ok_or(format!("crf {} is not 0 to 63", n))?; a.extend(["-crf".into(), c.to_string()]); } if let Some(p) = word(o, "preset")? { a.extend(["-preset".into(), p]); } if let Some(b) = word(o, "video_bitrate")? { a.extend(["-b:v".into(), b]); } if let Some(f) = scale_filter(o)? { a.extend(["-vf".into(), f]); } if let Some(n) = o.get("fps") { let f = n.as_f64().filter(|x| *x > 0.0 && *x <= 480.0).ok_or(format!("fps {} is not a frame rate", n))?; a.extend(["-r".into(), f.to_string()]); } if let Some(p) = word(o, "pixel_format")? { a.extend(["-pix_fmt".into(), p]); } } if flag(o, "no_audio") { a.push("-an".into()); } else { if let Some(c) = word(o, "audio_codec")? { a.extend(["-c:a".into(), c]); } if let Some(b) = word(o, "audio_bitrate")? { a.extend(["-b:a".into(), b]); } } if flag(o, "faststart") { a.extend(["-movflags".into(), "+faststart".into()]); } Ok(())}fn tail_of(e: &str) -> String { // ffmpeg starts a line with `[vost#0:0 @ 0x7f...]`, an address that means nothing to a reader. let bare = |l: &'_ str| -> String { if l.starts_with('[') { l.split_once("] ").map_or(l, |x| x.1).to_string() } else { l.to_string() } }; let lines: Vec<String> = e.lines().map(|l| bare(l.trim())).filter(|l| !l.is_empty()).collect(); // The first line says what went wrong; the last what came of it. match lines.as_slice() { [] => String::new(), [one] => one.clone(), [first, .., last] => format!("{}; {}", first, last), }}#[derive(Default)]struct Progress { frame: u64, fps: f64, out_ms: u64, speed: f64, size: u64, finished: bool,}fn progress_line(p: &mut Progress, l: &str) { let Some((k, v)) = l.split_once('=') else { return }; let v = v.trim(); match k { "frame" => p.frame = v.parse().unwrap_or(p.frame), "fps" => p.fps = v.parse().unwrap_or(p.fps), "out_time_us" | "out_time_ms" => { // ffmpeg writes microseconds under both names. if let Ok(n) = v.parse::<u64>() { p.out_ms = n / 1000; } } "total_size" => p.size = v.parse().unwrap_or(p.size), "speed" => p.speed = v.trim_end_matches('x').trim().parse().unwrap_or(p.speed), "progress" => p.finished = v == "end", _ => {} }}fn progress_map(p: &Progress, duration_ms: Option<u64>) -> Value { let percent = duration_ms.filter(|d| *d > 0).map(|d| ((p.out_ms as f64 / d as f64 * 1000.0).round() / 10.0).min(100.0)); json!({ "frame": p.frame, "fps": p.fps, "out_ms": p.out_ms, "speed": p.speed, "size": p.size, "percent": if p.finished { json!(100.0) } else { json!(percent) }, "finished": p.finished })}struct Job { child: Mutex<Child>, pid: i32, progress: Arc<Mutex<Progress>>, errors: Arc<Mutex<String>>, readers: Mutex<Vec<std::thread::JoinHandle<()>>>, duration_ms: Option<u64>, started: Instant, output: String,}fn spawn(args: &[String], duration_ms: Option<u64>, output: &str) -> Result<Job, String> { let mut c = Command::new(ffmpeg()); c.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).process_group(0); let mut child = c.spawn().map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => "ffmpeg is not installed, or not on PATH (FFMPEG_BIN names it)".to_string(), _ => format!("cannot run ffmpeg: {}", e), })?; let progress = Arc::new(Mutex::new(Progress::default())); let errors = Arc::new(Mutex::new(String::new())); let (so, mut se) = (child.stdout.take().ok_or("no stdout")?, child.stderr.take().ok_or("no stderr")?); let (p2, e2) = (progress.clone(), errors.clone()); let a = std::thread::spawn(move || { for l in BufReader::new(so).lines().map_while(Result::ok) { progress_line(&mut p2.lock().unwrap(), &l); } }); let b = std::thread::spawn(move || { let mut t = [0u8; 4096]; while let Ok(n) = se.read(&mut t) { if n == 0 { break; } let mut g = e2.lock().unwrap(); if g.len() < 1 << 16 { g.push_str(&String::from_utf8_lossy(&t[..n])); } } }); let pid = child.id() as i32; Ok(Job { child: Mutex::new(child), pid, progress, errors, readers: Mutex::new(vec![a, b]), duration_ms, started: Instant::now(), output: output.to_string() })}fn kill_group(pid: i32) { // SAFETY: kill(2) with a negative pid signals a group; nothing is touched in memory. unsafe { libc::kill(-pid, libc::SIGKILL); }}fn finish(j: &Job, code: Option<i32>) -> Result<Value, String> { for r in j.readers.lock().map_err(|_| "lock")?.drain(..) { let _ = r.join(); } let err = j.errors.lock().map_err(|_| "lock")?.clone(); if code != Some(0) { let _ = std::fs::remove_file(&j.output); let t = tail_of(&err); return Err(if t.is_empty() { format!("ffmpeg stopped with status {:?}", code) } else { format!("ffmpeg: {}", t) }); } let p = j.progress.lock().map_err(|_| "lock")?; let bytes = std::fs::metadata(&j.output).map(|m| m.len()).unwrap_or(0); Ok(json!({ "output": j.output, "bytes": bytes, "seconds": j.started.elapsed().as_secs_f64(), "frames": p.frame, "speed": p.speed, "out_ms": p.out_ms }))}fn run(args: &[String], output: &str, o: &Value) -> Result<Value, String> { let j = spawn(args, None, output)?; let limit = Duration::from_millis(o.get("timeout_ms").and_then(|v| v.as_u64()).unwrap_or(DEFAULT_TIMEOUT)); let end = Instant::now() + limit; let code = loop { if let Some(s) = j.child.lock().map_err(|_| "lock")?.try_wait().map_err(|e| e.to_string())? { break s.code(); } if Instant::now() >= end { kill_group(j.pid); let _ = j.child.lock().map_err(|_| "lock")?.wait(); let _ = std::fs::remove_file(output); return Err(format!("ffmpeg took longer than {} ms and was stopped", limit.as_millis())); } std::thread::sleep(Duration::from_millis(10)); }; finish(&j, code)}fn first_of<'a>(streams: &'a [Value], kind: &str) -> Option<&'a Value> { streams.iter().find(|s| s["codec_type"] == kind)}fn num(v: &Value) -> Value { match v.as_str().and_then(|s| s.parse::<f64>().ok()) { Some(x) if x.fract() == 0.0 => json!(x as i64), Some(x) => json!(x), None => v.clone(), }}fn rate(v: &Value) -> Value { let t = v.as_str().unwrap_or("0/0"); match t.split_once('/').and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?))) { Some((a, b)) if b != 0.0 => json!((a / b * 1000.0).round() / 1000.0), _ => Value::Null, }}/// Reads a media file with ffprobe: `{ format, format_name, duration, size, bit_rate, tags, video: { codec, profile, width, height, pixel_format, fps, frames, rotation, bit_rate }, audio: { codec, sample_rate, channels, layout, bit_rate }, streams, subtitles, chapters }`. A file that is not media is an error saying so.pub fn ffmpeg_probe(path: &str) -> Result<Value, String> { let input = input_arg(path)?; let out = Command::new(ffprobe()).args(["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", &input]).stdin(Stdio::null()).output().map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => "ffprobe is not installed, or not on PATH (FFPROBE_BIN names it)".to_string(), _ => format!("cannot run ffprobe: {}", e), })?; if !out.status.success() { return Err(format!("{} cannot be read as media: {}", path, tail_of(&String::from_utf8_lossy(&out.stderr)))); } let doc: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe's answer: {}", e))?; let streams = doc["streams"].as_array().cloned().unwrap_or_default(); let f = &doc["format"]; let mut m = json!({ "format": f["format_name"], "format_name": f["format_long_name"], "duration": num(&f["duration"]), "size": num(&f["size"]), "bit_rate": num(&f["bit_rate"]), "tags": f.get("tags").cloned().unwrap_or(json!({})), }); if let Some(v) = first_of(&streams, "video") { let mut rotation = Value::Null; for d in v["side_data_list"].as_array().into_iter().flatten() { if let Some(r) = d.get("rotation") { rotation = r.clone(); } } m["video"] = json!({ "codec": v["codec_name"], "profile": v["profile"], "width": v["width"], "height": v["height"], "pixel_format": v["pix_fmt"], "fps": rate(&v["avg_frame_rate"]), "frames": num(&v["nb_frames"]), "rotation": rotation, "bit_rate": num(&v["bit_rate"]), }); } if let Some(a) = first_of(&streams, "audio") { m["audio"] = json!({ "codec": a["codec_name"], "sample_rate": num(&a["sample_rate"]), "channels": a["channels"], "layout": a["channel_layout"], "bit_rate": num(&a["bit_rate"]) }); } m["streams"] = json!(streams.iter().map(|s| json!({ "index": s["index"], "type": s["codec_type"], "codec": s["codec_name"], "language": s["tags"]["language"] })).collect::<Vec<_>>()); m["subtitles"] = json!(streams.iter().filter(|s| s["codec_type"] == "subtitle").count()); m["chapters"] = json!(doc["chapters"].as_array().map_or(0, |c| c.len())); Ok(m)}fn thumbnail_args(input: &str, output: &str, o: &Value) -> Result<Vec<String>, String> { check_options(o)?; let mut a = base(o); let at = time(o, "at")?.unwrap_or("0".to_string()); // Before -i, so the seek is by keyframe and fast. a.extend(["-ss".into(), at, "-i".into(), input_arg(input)?, "-frames:v".into(), "1".into(), "-update".into(), "1".into()]); if let Some(f) = scale_filter(o)? { a.extend(["-vf".into(), f]); } a.push(output_arg(output, o)?); Ok(a)}/// One frame as an image: `at` (a time), `width` or `height` (the other keeps the aspect ratio), `overwrite`. The extension picks JPEG, PNG or WebP.pub fn ffmpeg_thumbnail(input: &str, output: &str, options: serde_json::Value) -> Result<Value, String> { run(&thumbnail_args(input, output, &options)?, output, &options)}fn trim_args(input: &str, output: &str, o: &Value) -> Result<Vec<String>, String> { check_options(o)?; let (start, end, length) = (time(o, "start")?, time(o, "end")?, time(o, "duration")?); if end.is_some() && length.is_some() { return Err("give `end` or `duration`, not both".into()); } if let (Some(s), Some(e)) = (&start, &end) { if seconds_of(e) <= seconds_of(s) { return Err(format!("`end` ({}) is not after `start` ({})", e, s)); } } let mut a = base(o); if let Some(s) = &start { a.extend(["-ss".into(), s.clone()]); } a.extend(["-i".into(), input_arg(input)?]); let span = match (&end, &length) { (Some(e), _) => Some((seconds_of(e) - start.as_deref().map_or(0.0, seconds_of)).to_string()), (_, Some(l)) => Some(l.clone()), _ => None, }; if let Some(t) = span { a.extend(["-t".into(), t]); } // By default streams are copied: fast, and cut at keyframes. `copy: false` re-encodes to the frame. if o.get("copy").and_then(|v| v.as_bool()).unwrap_or(true) { a.extend(["-c".into(), "copy".into(), "-avoid_negative_ts".into(), "make_zero".into()]); } else { encoder_args(&mut a, o)?; } a.push(output_arg(output, o)?); Ok(a)}/// Cuts a piece: `start`, and `end` or `duration`. Streams are copied unless `copy: false`, which re-encodes, cutting exactly.pub fn ffmpeg_trim(input: &str, output: &str, options: serde_json::Value) -> Result<Value, String> { run(&trim_args(input, output, &options)?, output, &options)}fn transcode_args(input: &str, output: &str, o: &Value) -> Result<Vec<String>, String> { check_options(o)?; let mut a = base(o); a.extend(["-i".into(), input_arg(input)?]); encoder_args(&mut a, o)?; a.push(output_arg(output, o)?); Ok(a)}/// Transcodes: `video_codec`, `audio_codec`, `crf`, `preset`, `video_bitrate`, `audio_bitrate`, `width`, `height`, `fps`, `pixel_format`, `no_audio`, `no_video`, `faststart`, `overwrite`, `timeout_ms`.pub fn ffmpeg_transcode(input: &str, output: &str, options: serde_json::Value) -> Result<Value, String> { run(&transcode_args(input, output, &options)?, output, &options)}/// The arguments a transcode would run with, as a list: for logging, or a test. Nothing runs.pub fn ffmpeg_transcode_args(input: &str, output: &str, options: serde_json::Value) -> Result<Vec<String>, String> { transcode_args(input, output, &options)}/// Cuts a video into HLS: `index.m3u8` and `seg_00000.ts` files in `folder`, each about `segment_seconds` (6) long, with the options of a transcode (streams copied unless a codec is given). Answers `{ playlist, segments: [{ file, duration }], target_duration, bytes }`, read from the playlist.pub fn ffmpeg_hls(input: &str, folder: &str, options: serde_json::Value) -> Result<Value, String> { check_options(&options)?; let secs = options.get("segment_seconds").and_then(|v| v.as_f64()).filter(|x| *x > 0.0 && *x <= 3600.0).unwrap_or(6.0); std::fs::create_dir_all(folder).map_err(|e| format!("{}: {}", folder, e))?; let playlist = format!("{}/index.m3u8", folder.trim_end_matches('/')); if Path::new(&playlist).exists() && !flag(&options, "overwrite") { return Err(format!("{} already exists: give `overwrite: true`", playlist)); } let mut a = base(&options); a.extend(["-i".into(), input_arg(input)?]); let mut o = options.clone(); let chose = options.get("video_codec").is_some() || options.get("width").is_some() || options.get("height").is_some(); if !chose { a.extend(["-c".into(), "copy".into()]); if let Some(m) = o.as_object_mut() { m.remove("audio_codec"); } } else { encoder_args(&mut a, &o)?; } a.extend(["-f".into(), "hls".into(), "-hls_time".into(), secs.to_string(), "-hls_playlist_type".into(), "vod".into(), "-hls_segment_type".into(), "mpegts".into(), "-hls_flags".into(), "independent_segments".into()]); a.extend(["-hls_segment_filename".into(), format!("{}/seg_%05d.ts", folder.trim_end_matches('/')), playlist.clone()]); run(&a, &playlist, &options)?; let text = std::fs::read_to_string(&playlist).map_err(|e| format!("{}: {}", playlist, e))?; let mut segments = vec![]; let mut target = Value::Null; let mut last = 0.0; let mut bytes = 0; for l in text.lines() { if let Some(t) = l.strip_prefix("#EXT-X-TARGETDURATION:") { target = json!(t.parse::<u64>().ok()); } else if let Some(t) = l.strip_prefix("#EXTINF:") { last = t.trim_end_matches(',').parse().unwrap_or(0.0); } else if !l.starts_with('#') && !l.is_empty() { bytes += std::fs::metadata(format!("{}/{}", folder.trim_end_matches('/'), l)).map(|m| m.len()).unwrap_or(0); segments.push(json!({ "file": l, "duration": last })); } } Ok(json!({ "playlist": playlist, "segments": segments, "target_duration": target, "ended": text.contains("#EXT-X-ENDLIST"), "bytes": bytes }))}/// A test video from ffmpeg's own lavfi source, colour bars and a tone: `seconds` (2), `width` (320), `height` (240), `fps` (25), `audio` (true). For tests and demos: no sample to ship.pub fn ffmpeg_generate(output: &str, options: serde_json::Value) -> Result<Value, String> { let n = |k: &str, d: f64| options.get(k).and_then(|v| v.as_f64()).unwrap_or(d); let (secs, w, h, fps) = (n("seconds", 2.0), n("width", 320.0) as u64, n("height", 240.0) as u64, n("fps", 25.0) as u64); if !(0.0..=3600.0).contains(&secs) || !(2..=4096).contains(&w) || !(2..=4096).contains(&h) || !(1..=120).contains(&fps) { return Err("seconds, width, height or fps is out of range".into()); } let audio = options.get("audio").and_then(|v| v.as_bool()).unwrap_or(true); let mut o = options.clone(); if let Some(m) = o.as_object_mut() { m.retain(|k, _| k == "overwrite" || k == "timeout_ms"); } let mut a = base(&o); a.extend(["-f".into(), "lavfi".into(), "-i".into(), format!("testsrc2=size={}x{}:rate={}:duration={}", w, h, fps, secs)]); if audio { a.extend(["-f".into(), "lavfi".into(), "-i".into(), format!("sine=frequency=440:sample_rate=48000:duration={}", secs)]); } a.extend(["-pix_fmt".into(), "yuv420p".into(), "-g".into(), fps.to_string()]); a.push(output_arg(output, &o)?); run(&a, output, &o)}fn jobs() -> &'static Mutex<HashMap<String, Arc<Job>>> { static J: OnceLock<Mutex<HashMap<String, Arc<Job>>>> = OnceLock::new(); J.get_or_init(|| Mutex::new(HashMap::new()))}/// Starts a transcode in the background under `name` and answers at once; `ffmpeg_progress` follows it, with `percent` worked out from the input's length.pub fn ffmpeg_start(name: &str, input: &str, output: &str, options: serde_json::Value) -> Result<bool, String> { if jobs().lock().map_err(|_| "lock")?.contains_key(name) { return Err(format!("`{}` is already running", name)); } let args = transcode_args(input, output, &options)?; let duration = ffmpeg_probe(input).ok().and_then(|p| p["duration"].as_f64()).map(|d| (d * 1000.0) as u64); let j = spawn(&args, duration, output)?; jobs().lock().map_err(|_| "lock")?.insert(name.to_string(), Arc::new(j)); Ok(true)}/// Where a background job is: `{ running, percent, frame, fps, speed, out_ms, size, finished }`; once it has ended, its `result`, or `error`. Its name is free after that.pub fn ffmpeg_progress(name: &str) -> Result<Value, String> { let j = jobs().lock().map_err(|_| "lock")?.get(name).cloned().ok_or(format!("no job `{}`", name))?; let done = j.child.lock().map_err(|_| "lock")?.try_wait().map_err(|e| e.to_string())?; let mut m = progress_map(&*j.progress.lock().map_err(|_| "lock")?, j.duration_ms); m["running"] = json!(done.is_none()); if let Some(s) = done { match finish(&j, s.code()) { Ok(r) => m["result"] = r, Err(e) => m["error"] = json!(e), } m["running"] = json!(false); jobs().lock().map_err(|_| "lock")?.remove(name); } Ok(m)}/// Stops a background job and removes its partial output; true when there was one.pub fn ffmpeg_cancel(name: &str) -> Result<bool, String> { let Some(j) = jobs().lock().map_err(|_| "lock")?.remove(name) else { return Ok(false) }; kill_group(j.pid); let _ = j.child.lock().map_err(|_| "lock")?.wait(); let _ = std::fs::remove_file(&j.output); Ok(true)}/// Which ffmpeg this is: its version line, and what it can encode among the usual codecs.pub fn ffmpeg_version() -> Result<Value, String> { let o = Command::new(ffmpeg()).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output().map_err(|_| "ffmpeg is not installed, or not on PATH (FFMPEG_BIN names it)")?; let v = Command::new(ffmpeg()).arg("-version").stdin(Stdio::null()).output().map_err(|e| e.to_string())?; let list = String::from_utf8_lossy(&o.stdout).to_string(); let mut m = Map::new(); m.insert("version".into(), json!(String::from_utf8_lossy(&v.stdout).lines().next().unwrap_or("").split_whitespace().nth(2))); for c in ["libx264", "libx265", "libvpx-vp9", "libsvtav1", "aac", "libopus", "libmp3lame"] { m.insert(c.replace('-', "_"), json!(list.lines().any(|l| l.split_whitespace().nth(1) == Some(c)))); } Ok(Value::Object(m))}Related
cli_args.qwer · 1.0.0Command-line parsing for Quillo programs and scripts, described as a map and run by clap: flags, counted flags, typed options, positionals, nested subcommands, choices, required, conflicting and dependent arguments, env fallbacks with where each value came from, generated help and usage, and suggestions for misspellings.3 views · 8 downloadsGit commands git_cli.qwer · 1.0.0The git command driven through argument lists and parsed to maps: status --porcelain=v2 with staged, unstaged, untracked and conflicted files, branches with upstreams, tags, diff --numstat with renames and binaries, blame --porcelain, show, and a file at a revision. Unlike git_log, it reads working trees; a repository's config cannot run commands through it.2 views · 5 downloadsRun a command cli_run.qwer · 1.0.0Other programs run from Quillo with arguments as a list, never a shell string: a timeout that ends the whole process group, SIGTERM then SIGKILL, an output limit that kills or truncates, stdin, environment and working folder, exit status and signal reported apart, shell-free pipelines with pipefail, and background jobs watched and stopped.4 views · 6 downloadsThread 0 posts
On ffmpeg and ffprobe: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.