app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
//! ffmpeg invocation: build the filter_complex graph and run it.
//!
//! The strategy is a single ffmpeg call that:
//! 1. Trims the input into segments at every marker boundary
//! 2. Normal segments: setpts=PTS-STARTPTS (passthrough timing)
//! 3. Speedup segments: setpts=(1/speed)*(PTS-STARTPTS) + drawtext overlay
//! 4. Concats all segments back into one output
//!
//! Audio is dropped (-an). Phase 1 expects silent recordings.
use crate::markers::Segment;
use std::path::Path;
use std::process::Command;
const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
/// Get the duration of a video file (in seconds) via ffprobe.
///
/// Handles three common cases:
/// 1. Format-level `duration` is a number: parse it and return.
/// 2. Format-level `duration` is `N/A` (common on MediaRecorder webms
/// that ship without a proper duration header): fall back to
/// querying the first video stream's duration.
/// 3. Stream-level duration is also missing: remux via ffmpeg to a
/// fresh file, let ffmpeg compute the duration on the fly, and
/// probe the fresh file.
pub fn probe_duration(input: &Path) -> Result<f64, String> {
// Try format-level duration first.
let format_dur = run_ffprobe_duration(input, &["-show_entries", "format=duration"])?;
if let Ok(v) = format_dur.parse::<f64>() {
if v > 0.0 {
return Ok(v);
}
}
// Fall back to stream-level duration of the first video stream.
let stream_dur = run_ffprobe_duration(
input,
&[
"-select_streams", "v:0",
"-show_entries", "stream=duration",
],
)?;
if let Ok(v) = stream_dur.parse::<f64>() {
if v > 0.0 {
return Ok(v);
}
}
// Last resort: remux into a temp file with `-c copy` and probe that.
// This is what MediaRecorder webms need because they never write a
// proper container duration header; remuxing forces ffmpeg to
// compute and write one.
let tmp = std::env::temp_dir().join(format!(
"adom-video-post-probe-{}.webm",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
));
let remux = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(input)
.args(["-c", "copy"])
.arg(&tmp)
.output()
.map_err(|e| format!("ffmpeg remux spawn failed: {}", e))?;
if !remux.status.success() {
return Err(format!(
"ffprobe could not determine duration (format='{}', stream='{}') and remux fallback also failed: {}",
format_dur, stream_dur, String::from_utf8_lossy(&remux.stderr).lines().last().unwrap_or("")
));
}
let after_remux = run_ffprobe_duration(&tmp, &["-show_entries", "format=duration"])?;
let _ = std::fs::remove_file(&tmp);
after_remux.parse::<f64>()
.map_err(|e| format!("duration still unavailable after remux: {} ({})", after_remux, e))
}
fn run_ffprobe_duration(input: &Path, extra_args: &[&str]) -> Result<String, String> {
let mut cmd = Command::new("ffprobe");
cmd.args(["-v", "error"]);
cmd.args(extra_args);
cmd.args(["-of", "default=noprint_wrappers=1:nokey=1"]);
cmd.arg(input);
let output = cmd.output().map_err(|e| format!("ffprobe spawn failed: {}", e))?;
if !output.status.success() {
return Err(format!(
"ffprobe failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Get the (width, height) of the first video stream via ffprobe.
pub fn probe_dimensions(input: &Path) -> Result<(u32, u32), String> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
])
.arg(input)
.output()
.map_err(|e| format!("ffprobe spawn failed: {}", e))?;
if !output.status.success() {
return Err(format!(
"ffprobe failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut parts = s.split('x');
let w = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse width from '{}'", s))?;
let h = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse height from '{}'", s))?;
Ok((w, h))
}
/// Compute a reasonable font size for the caption overlay based on video width.
/// Targets readability across resolutions: ~32px at 640w, ~48px at 1280w, ~64px at 1920w.
pub fn compute_font_size(width: u32) -> u32 {
let raw = (width as f64 / 30.0) as u32;
raw.clamp(20, 80)
}
/// Check ffmpeg + ffprobe versions. Returns (ffmpeg_version, ffprobe_version).
pub fn check_versions() -> Result<(String, String), String> {
let ffmpeg = Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|e| format!("ffmpeg not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;
let ffprobe = Command::new("ffprobe")
.arg("-version")
.output()
.map_err(|e| format!("ffprobe not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;
let parse_v = |out: &[u8]| -> String {
let s = String::from_utf8_lossy(out);
s.lines()
.next()
.and_then(|line| line.split_whitespace().nth(2))
.unwrap_or("?")
.to_string()
};
Ok((parse_v(&ffmpeg.stdout), parse_v(&ffprobe.stdout)))
}
/// Escape a string for ffmpeg drawtext text= argument.
/// drawtext is finicky: colons, single quotes, backslashes, percents all need escaping.
fn escape_drawtext(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(':', "\\:")
.replace('\'', "\\'")
.replace('%', "\\%")
}
/// Build the filter_complex graph string for the given segments and total duration.
/// font_size is the drawtext fontsize in pixels (use `compute_font_size` from the
/// probed video width).
/// Returns the filter_complex argument value (one big string with semicolons).
pub fn build_filter_complex(segments: &[Segment], total_duration: f64, font_size: u32) -> String {
build_filter_complex_with_trim(segments, total_duration, font_size, 0.0)
}
/// Same as `build_filter_complex` but also trims the first `trim_offset`
/// seconds off the start of the input. The first normal section starts
/// at `trim_offset` instead of 0, and segments whose start_offset is
/// less than `trim_offset` should already have been clamped by the caller.
pub fn build_filter_complex_with_trim(segments: &[Segment], total_duration: f64, font_size: u32, trim_offset: f64) -> String {
build_filter_complex_full(segments, total_duration, font_size, trim_offset, 1.0, false)
}
/// Full version: segments + trim_offset + base_speed + no_overlay.
/// `base_speed` > 1.0 applies a global setpts to normal (unmarked)
/// sections so the ENTIRE unmarked video runs faster. Marked speedup
/// sections still run at their own marker rate, the marker always
/// dominates the base. A base of 1.0 is a no-op and falls through to
/// the old behavior. `no_overlay` skips the red drawtext caption on
/// speedup sections — useful when the caller is already burning in
/// its own captions (e.g. desktop_caption step_marker) and the red
/// overlay would cover them up.
pub fn build_filter_complex_full(
segments: &[Segment],
total_duration: f64,
font_size: u32,
trim_offset: f64,
base_speed: f64,
no_overlay: bool,
) -> String {
// Compute the alternating list of (start, end, type) sections.
// Type is either "normal" (no speedup) or a speedup segment ref.
enum Section<'a> {
Normal { start: f64, end: f64 },
Speedup { seg: &'a Segment },
}
let mut sections: Vec<Section> = Vec::new();
let mut cursor = trim_offset.max(0.0);
for seg in segments {
if seg.start_offset > cursor + 0.001 {
sections.push(Section::Normal {
start: cursor,
end: seg.start_offset,
});
}
sections.push(Section::Speedup { seg });
cursor = seg.end_offset;
}
if cursor < total_duration - 0.001 {
sections.push(Section::Normal {
start: cursor,
end: total_duration,
});
}
let mut parts: Vec<String> = Vec::new();
let mut labels: Vec<String> = Vec::new();
for (i, sec) in sections.iter().enumerate() {
let label = format!("v{}", i);
match sec {
Section::Normal { start, end } => {
// Apply base_speed to every normal (unmarked) section so
// the global pace of the video is base_speed times the
// original recording. base_speed == 1.0 falls through to
// the plain passthrough setpts (unchanged).
if (base_speed - 1.0).abs() < 0.001 {
parts.push(format!(
"[0:v]trim=start={:.3}:end={:.3},setpts=PTS-STARTPTS[{}]",
start, end, label
));
} else {
let base_factor = 1.0 / base_speed;
parts.push(format!(
"[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",
start, end, base_factor, label
));
}
}
Section::Speedup { seg } => {
let setpts_factor = 1.0 / (seg.speed as f64);
if no_overlay {
parts.push(format!(
"[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",
seg.start_offset, seg.end_offset, setpts_factor, label
));
} else {
let caption = format!("{}x SPEEDUP: {}", seg.speed, seg.label);
let escaped = escape_drawtext(&caption);
parts.push(format!(
"[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS),\
drawtext=text='{}':fontfile={}:fontsize={}:fontcolor=white:\
box=1:[email protected]:boxborderw=20:x=(w-text_w)/2:y=80[{}]",
seg.start_offset, seg.end_offset, setpts_factor, escaped, FONT_PATH, font_size, label
));
}
}
}
labels.push(format!("[{}]", label));
}
if labels.is_empty() {
// No segments at all. Pass through unchanged.
return "[0:v]copy[out]".to_string();
}
let concat = format!(
"{}concat=n={}:v=1:a=0[out]",
labels.join(""),
labels.len()
);
parts.push(concat);
parts.join(";")
}
/// Run ffmpeg with the given filter_complex graph. Returns Ok on success or
/// an error message containing ffmpeg's stderr.
///
/// Encoder: libvpx-vp9 at 2 Mbps with `-deadline realtime -cpu-used 8`.
/// This is ~5-10x faster than libvpx-vp9's default "good" deadline, at a
/// tiny quality cost that does not matter for speedup content (drawn
/// captions + fast forward sections, not mastered video). For a 60 s
/// source on this hardware that is the difference between ~45 s encode
/// and ~6 minutes encode. When we start wanting higher quality we can
/// expose the speed knob as a CLI flag.
pub fn run_process(input: &Path, output: &Path, filter_complex: &str) -> Result<(), String> {
run_process_with_duration(input, output, filter_complex, None)
}
/// Same as `run_process` but accepts an optional source duration for ETA
/// calculation. When provided, progress lines include an ETA.
/// `max_width` downscales the output (0 = no scaling).
pub fn run_process_with_duration(
input: &Path,
output: &Path,
filter_complex: &str,
source_duration: Option<f64>,
) -> Result<(), String> {
run_process_full(input, output, filter_complex, source_duration, 0)
}
/// Full version with optional downscale. max_width > 0 appends a
/// scale filter to the output, e.g. 1280 → scale=1280:-2.
pub fn run_process_full(
input: &Path,
output: &Path,
filter_complex: &str,
source_duration: Option<f64>,
max_width: u32,
) -> Result<(), String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
// -r 30 + -fps_mode cfr forces constant 30 fps output. This is
// essential: the source recordings come from MediaRecorder which
// emits variable-framerate frames (idle screens get long gaps
// between frames). After setpts compression the gaps pack together
// into stuttering bursts — the player sits on one frame for
// seconds then jumps. Resampling to CFR evens it all out.
//
// -g 60 -keyint_min 60 force a keyframe every 2 seconds at 30 fps,
// so the browser can seek and recover decoder state quickly.
//
// -progress pipe:1 writes machine-readable progress to stdout.
//
// If max_width is set and the source is wider, append a scale filter
// to the filter_complex graph so ffmpeg encodes fewer pixels.
let final_filter = if max_width > 0 {
// Append scale to the [out] label: rename [out] → [pre], add scale
let patched = filter_complex.replace("[out]", "[pre]");
format!("{};[pre]scale='min({},iw)':-2[out]", patched, max_width)
} else {
filter_complex.to_string()
};
let mut child = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(input)
.args(["-filter_complex", &final_filter])
.args(["-map", "[out]"])
.args([
"-c:v", "libvpx-vp9",
"-b:v", "2M",
"-deadline", "realtime",
"-cpu-used", "8",
"-row-mt", "1",
"-r", "30",
"-fps_mode", "cfr",
"-g", "60",
"-keyint_min", "60",
"-an",
"-progress", "pipe:1",
])
.arg(output)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
// Stream stdout for progress (ffmpeg -progress pipe:1 writes
// key=value lines with out_time_ms= showing encode position).
let stdout = child.stdout.take().expect("stdout piped");
let start = std::time::Instant::now();
let reader = BufReader::new(stdout);
let mut last_print = std::time::Instant::now();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
// Parse out_time_ms=<microseconds>
if let Some(val) = line.strip_prefix("out_time_ms=") {
if let Ok(us) = val.trim().parse::<i64>() {
let encoded_s = us as f64 / 1_000_000.0;
let elapsed = start.elapsed().as_secs_f64();
// Throttle prints to once per second
if last_print.elapsed().as_secs_f64() >= 1.0 {
last_print = std::time::Instant::now();
if let Some(dur) = source_duration {
if encoded_s > 0.1 && dur > 0.1 {
let pct = (encoded_s / dur * 100.0).min(100.0);
let speed = encoded_s / elapsed;
let remaining_s = if speed > 0.01 {
(dur - encoded_s) / speed
} else {
0.0
};
eprint!(
"\r\x1b[K \x1b[36m{:.0}%\x1b[0m {:.0}s/{:.0}s encoded \x1b[2m{:.1}x speed ~{:.0}s remaining\x1b[0m",
pct, encoded_s, dur, speed, remaining_s
);
}
} else {
eprint!(
"\r\x1b[K \x1b[36m{:.0}s\x1b[0m encoded \x1b[2m{:.0}s elapsed\x1b[0m",
encoded_s, elapsed
);
}
}
}
}
}
eprintln!(); // newline after progress
let result = child.wait().map_err(|e| format!("ffmpeg wait failed: {}", e))?;
if !result.success() {
// Read stderr for error details
let mut stderr_str = String::new();
if let Some(mut stderr) = child.stderr.take() {
use std::io::Read;
let _ = stderr.read_to_string(&mut stderr_str);
}
let normalised = stderr_str.replace('\r', "\n");
let meaningful: Vec<&str> = normalised
.lines()
.map(str::trim_end)
.filter(|l| {
!l.is_empty()
&& !l.starts_with("frame=")
&& !l.starts_with("size=")
&& !l.starts_with("Press [q]")
})
.collect();
let tail_start = meaningful.len().saturating_sub(60);
let tail = &meaningful[tail_start..];
return Err(format!("ffmpeg failed:\n{}", tail.join("\n")));
}
let total_elapsed = start.elapsed().as_secs_f64();
eprintln!(" \x1b[32mdone\x1b[0m in {:.0}s", total_elapsed);
Ok(())
}
/// Generate a first-frame JPG poster from a video.
///
/// Used by the storyboard subcommand to populate `clip.poster_path`
/// lazily when the manifest doesn't already have one. The resulting
/// JPG is used as the `<video poster=...>` attribute so cards render
/// a visible thumbnail before the user clicks play, without
/// downloading any video bytes.
pub fn generate_poster(video: &Path, output: &Path) -> Result<(), String> {
let result = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-i"])
.arg(video)
.args(["-frames:v", "1", "-q:v", "3"])
.arg(output)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
if !result.status.success() {
return Err(format!(
"ffmpeg poster failed: {}",
String::from_utf8_lossy(&result.stderr).lines().rev().take(5).collect::<Vec<_>>().join("; ")
));
}
Ok(())
}
/// Append a still image (held for `duration` seconds) to the end
/// of a video. Used by demo workflows to give viewers a readable
/// pause on the actual exported data (console output, gerber file
/// list, BOM rows, etc.) right after a sped-up action.
///
/// The still is padded to match the source video's dimensions and
/// encoded with the same CFR 30 fps + 2s keyframe interval as the
/// main speedup pipeline, so `-c copy` concat works without
/// re-encoding. If the concat-copy fails for any reason, falls back
/// to a full re-encode.
pub fn append_still(
video: &Path,
image: &Path,
duration: f64,
output: &Path,
) -> Result<(), String> {
let (w, h) = probe_dimensions(video)?;
// Step 1: encode the still as a short webm matching the source
// dimensions and codec params.
let still_tmp = std::env::temp_dir().join(format!(
"adom-video-post-still-{}.webm",
std::process::id()
));
let pad_filter = format!(
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2:black,setsar=1",
w, h, w, h
);
let still_result = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-loop", "1", "-framerate", "30", "-t"])
.arg(format!("{}", duration))
.args(["-i"])
.arg(image)
.args([
"-vf", &pad_filter,
"-c:v", "libvpx-vp9",
"-b:v", "2M",
"-deadline", "realtime",
"-cpu-used", "8",
"-row-mt", "1",
"-r", "30",
"-fps_mode", "cfr",
"-g", "60",
"-keyint_min", "60",
"-pix_fmt", "yuv420p",
"-an",
])
.arg(&still_tmp)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
if !still_result.status.success() {
let _ = std::fs::remove_file(&still_tmp);
return Err(format!(
"ffmpeg still encode failed: {}",
String::from_utf8_lossy(&still_result.stderr)
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.join("; ")
));
}
// Step 2: concat video + still into output. Try copy first,
// fall back to re-encode.
let list_tmp = std::env::temp_dir().join(format!(
"adom-video-post-concat-{}.txt",
std::process::id()
));
std::fs::write(
&list_tmp,
format!("file '{}'\nfile '{}'\n", video.display(), still_tmp.display()),
)
.map_err(|e| format!("write concat list: {}", e))?;
let copy_result = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_tmp)
.args(["-c", "copy", "-an"])
.arg(output)
.output();
let ok = matches!(©_result, Ok(r) if r.status.success());
if !ok {
// Fall back to re-encode.
let re_result = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_tmp)
.args([
"-c:v", "libvpx-vp9",
"-b:v", "2M",
"-deadline", "realtime",
"-cpu-used", "8",
"-row-mt", "1",
"-r", "30",
"-fps_mode", "cfr",
"-g", "60",
"-keyint_min", "60",
"-pix_fmt", "yuv420p",
"-an",
])
.arg(output)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
if !re_result.status.success() {
let _ = std::fs::remove_file(&still_tmp);
let _ = std::fs::remove_file(&list_tmp);
return Err(format!(
"ffmpeg concat re-encode failed: {}",
String::from_utf8_lossy(&re_result.stderr)
.lines()
.rev()
.take(10)
.collect::<Vec<_>>()
.join("; ")
));
}
}
let _ = std::fs::remove_file(&still_tmp);
let _ = std::fs::remove_file(&list_tmp);
Ok(())
}
/// Concatenate multiple webm videos into one. Uses ffmpeg's concat
/// demuxer with `-c copy` (lossless, fast) — assumes the inputs
/// share codec and timebase, which is true for everything produced
/// by `adom-video-post process` + `append_still`.
pub fn concat_videos(inputs: &[std::path::PathBuf], output: &Path) -> Result<(), String> {
if inputs.is_empty() {
return Err("concat: no input videos".to_string());
}
let list_tmp = std::env::temp_dir().join(format!(
"adom-video-post-concat-list-{}.txt",
std::process::id()
));
let mut body = String::new();
for p in inputs {
if !p.exists() {
let _ = std::fs::remove_file(&list_tmp);
return Err(format!("concat: input missing: {}", p.display()));
}
body.push_str(&format!("file '{}'\n", p.display()));
}
std::fs::write(&list_tmp, body).map_err(|e| format!("write concat list: {}", e))?;
let result = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_tmp)
.args(["-c", "copy", "-an"])
.arg(output)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
let _ = std::fs::remove_file(&list_tmp);
if !result.status.success() {
return Err(format!(
"ffmpeg concat failed: {}",
String::from_utf8_lossy(&result.stderr)
.lines()
.rev()
.take(10)
.collect::<Vec<_>>()
.join("; ")
));
}
Ok(())
}