Decompose build() into a Plan accumulator with named phases

The 250-line build() god-function is split into:
- resolve_timeline(): collapse Trim ops into a Timeline.
- Plan: the mutable arg/filter accumulator, with one method per phase —
  translate() (per-op dispatch), translate_format()/start_audio_only()
  (format handling), drop_inapplicable_video_filters(),
  resolve_stream_copy(), resolve_target_size() (two-pass planning),
  filter_args() (vf/af vs gif palette graph), assemble_passes().
- build() now just orchestrates these in order.

Pure refactor: argument output is byte-for-byte identical, guarded by
the existing 23-test suite (all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:51:05 +01:00
parent 4daca8247c
commit 017d9ad006
+361 -257
View File
@@ -115,36 +115,19 @@ pub struct Built {
pub notes: Vec<String>,
}
pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built {
let mut pre: Vec<String> = Vec::new(); // before -i
let mut vf: Vec<String> = Vec::new(); // video filter chain
let mut af: Vec<String> = Vec::new(); // audio filter chain
let mut out: Vec<String> = Vec::new(); // output options
let mut aout: Vec<String> = Vec::new(); // audio output options (skipped in pass 1)
let mut notes: Vec<String> = Vec::new();
/// A trim window collapsed from all Trim ops. `expected` is the best guess at
/// the *output* duration before any speed change, used for the progress bar
/// and the fit-to-size bitrate math.
struct Timeline {
start: f64,
trim_dur: f64,
explicit_end: bool,
expected: f64,
}
let mut ext = input
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| "mp4".into());
let has_video = media.video_codec.is_some();
let has_audio = media.audio_codec.is_some();
let mut gif = false;
let mut audio_only_output = false;
let mut copy_requested = false;
let mut speed_factor = 1.0_f64;
let mut has_fps_op = false;
let mut has_resize_op = false;
// "Fit to size" needs the final duration, so it is applied after the
// loop; it also overrides any Compress op's encoder settings.
let target_mb: Option<f64> = ops
.iter()
.filter(|o| o.kind == OpKind::TargetSize)
.find_map(|o| o.text(0).parse::<f64>().ok().filter(|m| *m > 0.0));
// Trim is resolved first because the fade-out effect needs to know the
// trimmed duration.
/// Trim is resolved before the main loop because the fade effects and the
/// fit-to-size budget both need the trimmed duration up front.
fn resolve_timeline(ops: &[Operation], media: &MediaInfo) -> Timeline {
let mut start = 0.0_f64;
let mut end = media.duration;
let mut explicit_end = false;
@@ -158,25 +141,75 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
}
}
let trim_dur = (end - start).max(0.0);
let mut expected = if media.duration > 0.0 { trim_dur } else { 0.0 };
let expected = if media.duration > 0.0 { trim_dur } else { 0.0 };
Timeline { start, trim_dur, explicit_end, expected }
}
for op in ops {
/// Mutable accumulator for the ffmpeg invocation as ops are translated into
/// arguments. `out`/`aout` are kept apart because pass 1 of a two-pass encode
/// drops the audio-output options.
struct Plan {
pre: Vec<String>, // args before -i (input seeking)
vf: Vec<String>, // video filter chain
af: Vec<String>, // audio filter chain
out: Vec<String>, // output options
aout: Vec<String>, // audio output options
notes: Vec<String>, // human-readable remarks for the preview
ext: String, // output container/extension
gif: bool,
audio_only_output: bool,
copy_requested: bool,
speed_factor: f64,
has_fps_op: bool,
has_resize_op: bool,
}
impl Plan {
fn new(ext: String) -> Plan {
Plan {
pre: Vec::new(),
vf: Vec::new(),
af: Vec::new(),
out: Vec::new(),
aout: Vec::new(),
notes: Vec::new(),
ext,
gif: false,
audio_only_output: false,
copy_requested: false,
speed_factor: 1.0,
has_fps_op: false,
has_resize_op: false,
}
}
/// Translate a single op into argument/filter fragments. Order matters:
/// fades read `speed_factor`, which is only set once an earlier Speed op
/// has been translated.
fn translate(
&mut self,
op: &Operation,
tl: &Timeline,
has_video: bool,
has_audio: bool,
target_mb: Option<f64>,
) {
match op.kind {
OpKind::Trim => {
if start > 0.0 {
pre.push("-ss".into());
pre.push(fmt_arg_secs(start));
if tl.start > 0.0 {
self.pre.push("-ss".into());
self.pre.push(fmt_arg_secs(tl.start));
}
if explicit_end {
out.push("-t".into());
out.push(fmt_arg_secs(trim_dur));
if tl.explicit_end {
self.out.push("-t".into());
self.out.push(fmt_arg_secs(tl.trim_dur));
}
if op.choice(2) == 1 {
copy_requested = true;
self.copy_requested = true;
}
}
OpKind::Resize => {
has_resize_op = true;
self.has_resize_op = true;
let f = match op.choice(0) {
0 => "scale=-2:1080".into(),
1 => "scale=-2:720".into(),
@@ -189,13 +222,13 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
let w = if op.text(1).is_empty() { "-2" } else { op.text(1) };
let h = if op.text(2).is_empty() { "-2" } else { op.text(2) };
if w == "-2" && h == "-2" {
notes.push("Resize: no custom size given, skipped".into());
continue;
self.notes.push("Resize: no custom size given, skipped".into());
return;
}
format!("scale={}:{}", w, h)
}
};
vf.push(f);
self.vf.push(f);
}
OpKind::Crop => {
let f = match op.choice(0) {
@@ -205,8 +238,8 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
3 => "crop='min(iw,ih*4/3)':'min(ih,iw*3/4)'".into(),
_ => {
if op.text(1).is_empty() || op.text(2).is_empty() {
notes.push("Crop: custom width/height missing, skipped".into());
continue;
self.notes.push("Crop: custom width/height missing, skipped".into());
return;
}
let mut f = format!("crop={}:{}", op.text(1), op.text(2));
if !op.text(3).is_empty() && !op.text(4).is_empty() {
@@ -215,10 +248,10 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
f
}
};
vf.push(f);
self.vf.push(f);
}
OpKind::Rotate => {
vf.push(
self.vf.push(
match op.choice(0) {
0 => "transpose=1",
1 => "transpose=2",
@@ -231,12 +264,12 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
}
OpKind::Speed => {
let f: f64 = op.code(0).parse().unwrap_or(1.0);
speed_factor = f;
self.speed_factor = f;
if has_video {
vf.push(format!("setpts=PTS/{}", f));
self.vf.push(format!("setpts=PTS/{}", f));
}
if has_audio {
af.extend(atempo_chain(f));
self.af.extend(atempo_chain(f));
}
}
OpKind::Adjust => {
@@ -244,47 +277,48 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
let c = op.code(1);
let s = op.code(2);
if b == "0" && c == "1.0" && s == "1.0" {
notes.push("Color adjust: everything at default, skipped".into());
self.notes.push("Color adjust: everything at default, skipped".into());
} else {
vf.push(format!("eq=brightness={}:contrast={}:saturation={}", b, c, s));
self.vf
.push(format!("eq=brightness={}:contrast={}:saturation={}", b, c, s));
}
}
OpKind::Effect => {
// If a Speed op already pushed setpts ahead of us, this fade
// runs on the sped-up timeline, so its start must be scaled to
// match. speed_factor is 1.0 until that point.
let fade_out_start = (trim_dur / speed_factor - 1.0).max(0.0);
let fade_out_start = (tl.trim_dur / self.speed_factor - 1.0).max(0.0);
match op.choice(0) {
0 => vf.push("hue=s=0".into()),
1 => vf.push(
0 => self.vf.push("hue=s=0".into()),
1 => self.vf.push(
"colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131"
.into(),
),
2 => vf.push("gblur=sigma=8".into()),
3 => vf.push("unsharp=5:5:1.0".into()),
4 => vf.push("vignette".into()),
5 => vf.push("hqdn3d=4".into()),
6 => vf.push("fade=t=in:st=0:d=1".into()),
7 => vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start)),
2 => self.vf.push("gblur=sigma=8".into()),
3 => self.vf.push("unsharp=5:5:1.0".into()),
4 => self.vf.push("vignette".into()),
5 => self.vf.push("hqdn3d=4".into()),
6 => self.vf.push("fade=t=in:st=0:d=1".into()),
7 => self.vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start)),
_ => {
vf.push("fade=t=in:st=0:d=1".into());
vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start));
self.vf.push("fade=t=in:st=0:d=1".into());
self.vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start));
}
}
}
OpKind::Fps => {
has_fps_op = true;
vf.push(format!("fps={}", op.code(0)));
self.has_fps_op = true;
self.vf.push(format!("fps={}", op.code(0)));
}
OpKind::TargetSize => {
if target_mb.is_none() {
notes.push("Fit to size: enter a size in MB, e.g. 25".into());
self.notes.push("Fit to size: enter a size in MB, e.g. 25".into());
}
}
OpKind::Compress => {
if target_mb.is_some() {
notes.push("Compress ignored: Fit to size controls the encoder".into());
continue;
self.notes.push("Compress ignored: Fit to size controls the encoder".into());
return;
}
let h265 = op.choice(0) == 1;
let crf = match (h265, op.choice(1)) {
@@ -302,110 +336,47 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
1 => "fast",
_ => "slow",
};
out.push("-c:v".into());
out.push(if h265 { "libx265" } else { "libx264" }.into());
out.push("-crf".into());
out.push(crf.to_string());
out.push("-preset".into());
out.push(preset.into());
out.push("-pix_fmt".into());
out.push("yuv420p".into());
self.out.push("-c:v".into());
self.out.push(if h265 { "libx265" } else { "libx264" }.into());
self.out.push("-crf".into());
self.out.push(crf.to_string());
self.out.push("-preset".into());
self.out.push(preset.into());
self.out.push("-pix_fmt".into());
self.out.push("yuv420p".into());
if has_audio {
aout.push("-c:a".into());
aout.push("aac".into());
aout.push("-b:a".into());
aout.push("128k".into());
self.aout.push("-c:a".into());
self.aout.push("aac".into());
self.aout.push("-b:a".into());
self.aout.push("128k".into());
}
if h265 && (ext == "mp4" || ext == "mov") {
out.push("-tag:v".into());
out.push("hvc1".into());
notes.push("H.265 tagged as hvc1 so Apple players accept it".into());
if h265 && (self.ext == "mp4" || self.ext == "mov") {
self.out.push("-tag:v".into());
self.out.push("hvc1".into());
self.notes.push("H.265 tagged as hvc1 so Apple players accept it".into());
}
}
// Format and Audio offer different choices for video and audio
// inputs, so they are matched by stable code rather than index.
OpKind::Format => match op.code(0) {
"mp4" => ext = "mp4".into(),
"mkv" => ext = "mkv".into(),
"webm" => {
ext = "webm".into();
out.push("-c:v".into());
out.push("libvpx-vp9".into());
out.push("-crf".into());
out.push("32".into());
out.push("-b:v".into());
out.push("0".into());
if has_audio {
aout.push("-c:a".into());
aout.push("libopus".into());
}
}
"mov" => ext = "mov".into(),
"gif" => {
ext = "gif".into();
gif = true;
}
"mp3" => {
audio_only_output = true;
out.push("-vn".into());
ext = "mp3".into();
out.push("-c:a".into());
out.push("libmp3lame".into());
out.push("-q:a".into());
out.push("2".into());
}
"m4a" => {
audio_only_output = true;
out.push("-vn".into());
ext = "m4a".into();
out.push("-c:a".into());
out.push("aac".into());
out.push("-b:a".into());
out.push("192k".into());
}
"flac" => {
audio_only_output = true;
out.push("-vn".into());
ext = "flac".into();
out.push("-c:a".into());
out.push("flac".into());
}
"ogg" => {
audio_only_output = true;
out.push("-vn".into());
ext = "ogg".into();
out.push("-c:a".into());
out.push("libopus".into());
out.push("-b:a".into());
out.push("128k".into());
}
"wav" => {
audio_only_output = true;
out.push("-vn".into());
ext = "wav".into();
}
_ => {}
},
OpKind::Format => self.translate_format(op, has_audio),
OpKind::Audio => {
if !has_audio {
notes.push("Audio: this file has no audio track, skipped".into());
continue;
self.notes.push("Audio: this file has no audio track, skipped".into());
return;
}
match op.code(0) {
"remove" => {
out.push("-an".into());
}
"vol+50" => af.push("volume=1.5".into()),
"vol2x" => af.push("volume=2.0".into()),
"vol-50" => af.push("volume=0.5".into()),
"normalize" => af.push("loudnorm".into()),
"remove" => self.out.push("-an".into()),
"vol+50" => self.af.push("volume=1.5".into()),
"vol2x" => self.af.push("volume=2.0".into()),
"vol-50" => self.af.push("volume=0.5".into()),
"normalize" => self.af.push("loudnorm".into()),
_ => {
// Fade. Like the video fade, scale the out point if a
// speed change already sits ahead of us in the chain.
af.push("afade=t=in:st=0:d=1".into());
af.push(format!(
self.af.push("afade=t=in:st=0:d=1".into());
self.af.push(format!(
"afade=t=out:st={:.2}:d=1",
(trim_dur / speed_factor - 1.0).max(0.0)
(tl.trim_dur / self.speed_factor - 1.0).max(0.0)
));
}
}
@@ -413,168 +384,301 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
}
}
if !has_video && !vf.is_empty() {
notes.push("This file has no video stream; picture edits were skipped".into());
vf.clear();
fn translate_format(&mut self, op: &Operation, has_audio: bool) {
match op.code(0) {
"mp4" => self.ext = "mp4".into(),
"mkv" => self.ext = "mkv".into(),
"webm" => {
self.ext = "webm".into();
self.out.push("-c:v".into());
self.out.push("libvpx-vp9".into());
self.out.push("-crf".into());
self.out.push("32".into());
self.out.push("-b:v".into());
self.out.push("0".into());
if has_audio {
self.aout.push("-c:a".into());
self.aout.push("libopus".into());
}
}
"mov" => self.ext = "mov".into(),
"gif" => {
self.ext = "gif".into();
self.gif = true;
}
"mp3" => {
self.start_audio_only("mp3");
self.out.push("-c:a".into());
self.out.push("libmp3lame".into());
self.out.push("-q:a".into());
self.out.push("2".into());
}
"m4a" => {
self.start_audio_only("m4a");
self.out.push("-c:a".into());
self.out.push("aac".into());
self.out.push("-b:a".into());
self.out.push("192k".into());
}
"flac" => {
self.start_audio_only("flac");
self.out.push("-c:a".into());
self.out.push("flac".into());
}
"ogg" => {
self.start_audio_only("ogg");
self.out.push("-c:a".into());
self.out.push("libopus".into());
self.out.push("-b:a".into());
self.out.push("128k".into());
}
"wav" => self.start_audio_only("wav"),
_ => {}
}
if audio_only_output && !vf.is_empty() {
notes.push("Audio-only output: picture edits were dropped".into());
vf.clear();
}
// Stream copy is only possible when nothing has to be re-encoded.
let only_trims = ops.iter().all(|o| o.kind == OpKind::Trim);
if copy_requested {
if only_trims {
out.push("-c".into());
out.push("copy".into());
notes.push("Stream copy: instant, but cuts land on keyframes (\u{b1}a few seconds)".into());
/// Begin an audio-only output of the given extension: drop the video
/// stream and remember that picture edits no longer apply.
fn start_audio_only(&mut self, ext: &str) {
self.audio_only_output = true;
self.out.push("-vn".into());
self.ext = ext.into();
}
/// Drop video filters that can't apply to this output (no video stream, or
/// an audio-only container), leaving a note for each case.
fn drop_inapplicable_video_filters(&mut self, has_video: bool) {
if !has_video && !self.vf.is_empty() {
self.notes
.push("This file has no video stream; picture edits were skipped".into());
self.vf.clear();
}
if self.audio_only_output && !self.vf.is_empty() {
self.notes.push("Audio-only output: picture edits were dropped".into());
self.vf.clear();
}
}
/// Stream copy is only possible when every op is a Trim (nothing needs
/// re-encoding).
fn resolve_stream_copy(&mut self, ops: &[Operation]) {
if !self.copy_requested {
return;
}
if ops.iter().all(|o| o.kind == OpKind::Trim) {
self.out.push("-c".into());
self.out.push("copy".into());
self.notes
.push("Stream copy: instant, but cuts land on keyframes (\u{b1}a few seconds)".into());
} else {
notes.push("Stream copy disabled: other edits require re-encoding".into());
self.notes
.push("Stream copy disabled: other edits require re-encoding".into());
}
}
if speed_factor != 1.0 && expected > 0.0 {
expected /= speed_factor;
/// Resolve "Fit to size": pick a video bitrate that lands on the target and
/// switch to a two-pass encode so the budget is actually met. Returns the
/// stats-file prefix when two-pass is engaged (None otherwise).
fn resolve_target_size(
&mut self,
target_mb: Option<f64>,
media: &MediaInfo,
has_video: bool,
has_audio: bool,
expected: f64,
input: &Path,
) -> Option<PathBuf> {
let mb = target_mb?;
if !has_video || self.audio_only_output {
self.notes.push("Fit to size: needs a video output, skipped".into());
return None;
}
if self.gif {
self.notes.push("Fit to size: not available for GIF, skipped".into());
return None;
}
if self.ext == "webm" {
self.notes.push(
"Fit to size: not supported for WebM here \u{2014} convert to MP4 instead".into(),
);
return None;
}
if expected <= 0.0 {
self.notes.push("Fit to size: unknown duration, skipped".into());
return None;
}
// Resolve "Fit to size": pick a video bitrate that lands on the target,
// encoded in two passes so the budget is actually met.
let audio_removed = out.iter().any(|a| a == "-an");
let mut two_pass = false;
let mut passlog: Option<PathBuf> = None;
if let Some(mb) = target_mb {
if !has_video || audio_only_output {
notes.push("Fit to size: needs a video output, skipped".into());
} else if gif {
notes.push("Fit to size: not available for GIF, skipped".into());
} else if ext == "webm" {
notes.push("Fit to size: not supported for WebM here \u{2014} convert to MP4 instead".into());
} else if expected <= 0.0 {
notes.push("Fit to size: unknown duration, skipped".into());
} else {
// 3% margin for container overhead.
let audio_removed = self.out.iter().any(|a| a == "-an");
let audio_kbps = if has_audio && !audio_removed { 128.0 } else { 0.0 };
let mut v_kbps = (mb * 8000.0 * 0.97) / expected - audio_kbps;
if v_kbps < 50.0 {
v_kbps = 50.0;
notes.push(
self.notes.push(
"Fit to size: target is very small for this length \u{2014} expect poor quality, size may overshoot"
.into(),
);
}
out.push("-c:v".into());
out.push("libx264".into());
out.push("-b:v".into());
out.push(format!("{:.0}k", v_kbps));
out.push("-preset".into());
out.push("medium".into());
out.push("-pix_fmt".into());
out.push("yuv420p".into());
self.out.push("-c:v".into());
self.out.push("libx264".into());
self.out.push("-b:v".into());
self.out.push(format!("{:.0}k", v_kbps));
self.out.push("-preset".into());
self.out.push("medium".into());
self.out.push("-pix_fmt".into());
self.out.push("yuv420p".into());
if has_audio && !audio_removed {
aout.push("-c:a".into());
aout.push("aac".into());
aout.push("-b:a".into());
aout.push("128k".into());
self.aout.push("-c:a".into());
self.aout.push("aac".into());
self.aout.push("-b:a".into());
self.aout.push("128k".into());
}
notes.push(format!(
self.notes.push(format!(
"Two-pass H.264 at {:.0} kb/s video to land near {} MB",
v_kbps, mb
));
if mb * 1e6 > media.size_bytes as f64 && media.size_bytes > 0 {
notes.push(
"Target is larger than the input \u{2014} the file will grow, not shrink"
.into(),
);
self.notes
.push("Target is larger than the input \u{2014} the file will grow, not shrink".into());
}
two_pass = true;
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
passlog = Some(
std::env::temp_dir().join(format!("lazyff-2pass-{:016x}", hasher.finish())),
);
}
Some(std::env::temp_dir().join(format!("lazyff-2pass-{:016x}", hasher.finish())))
}
// Assemble the argument list(s).
let mut base: Vec<String> = vec!["-y".into()];
base.extend(pre);
base.push("-i".into());
base.push(input.to_string_lossy().into_owned());
let mut filters: Vec<String> = Vec::new();
if gif {
// GIF needs a palette pass to avoid ugly dithering; bundle the user's
// filters into the palette graph.
/// Build the `-vf`/`-af` (or, for GIF, `-filter_complex`) fragment. The
/// video filter chain in `self.vf` is left intact for the two-pass pass-1
/// command, which needs it separately.
fn filter_args(&mut self) -> Vec<String> {
let mut filters = Vec::new();
if self.gif {
// GIF needs a palette pass to avoid ugly dithering; bundle the
// user's filters into the palette graph.
let mut chain: Vec<String> = Vec::new();
if !has_fps_op {
if !self.has_fps_op {
chain.push("fps=12".into());
}
if !has_resize_op {
if !self.has_resize_op {
chain.push("scale=480:-2:flags=lanczos".into());
}
chain.extend(vf.clone());
let fc = format!(
chain.extend(self.vf.iter().cloned());
filters.push("-filter_complex".into());
filters.push(format!(
"{},split[a][b];[a]palettegen[p];[b][p]paletteuse",
chain.join(",")
);
filters.push("-filter_complex".into());
filters.push(fc);
));
filters.push("-an".into());
af.clear();
notes.push("GIF: palette pass added for good colors; audio removed".into());
self.af.clear();
self.notes
.push("GIF: palette pass added for good colors; audio removed".into());
} else {
if !vf.is_empty() {
if !self.vf.is_empty() {
filters.push("-vf".into());
filters.push(vf.join(","));
filters.push(self.vf.join(","));
}
if !af.is_empty() {
if !self.af.is_empty() {
filters.push("-af".into());
filters.push(af.join(","));
filters.push(self.af.join(","));
}
}
filters
}
let dir = input.parent().unwrap_or(Path::new("."));
let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() };
let output = dir.join(format!("{}.{}", stem, ext));
/// Assemble the final per-pass argument lists. With a `passlog` prefix this
/// produces an analysis pass (video only, output discarded) followed by the
/// real encode; otherwise a single pass.
fn assemble_passes(
&self,
base: Vec<String>,
filters: Vec<String>,
output: &Path,
passlog: Option<&Path>,
) -> Vec<Vec<String>> {
let out_arg = output.to_string_lossy().into_owned();
let Some(prefix) = passlog else {
let mut args = base;
args.extend(filters);
args.extend(self.out.iter().cloned());
args.extend(self.aout.iter().cloned());
args.push(out_arg);
return vec![args];
};
let mut passes: Vec<Vec<String>> = Vec::new();
if two_pass {
let log = passlog.as_ref().unwrap().to_string_lossy().into_owned();
let log = prefix.to_string_lossy().into_owned();
// Pass 1: video analysis only, no audio, output discarded.
let mut p1 = base.clone();
if !vf.is_empty() {
if !self.vf.is_empty() {
p1.push("-vf".into());
p1.push(vf.join(","));
p1.push(self.vf.join(","));
}
p1.extend(out.iter().cloned());
p1.extend(self.out.iter().cloned());
p1.extend(
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", NULL_DEVICE]
.map(String::from),
);
passes.push(p1);
let mut p2 = base;
p2.extend(filters);
p2.extend(out);
p2.extend(aout);
p2.extend(self.out.iter().cloned());
p2.extend(self.aout.iter().cloned());
p2.extend(["-pass", "2", "-passlogfile", &log].map(String::from));
p2.push(output.to_string_lossy().into_owned());
passes.push(p2);
} else {
let mut args = base;
args.extend(filters);
args.extend(out);
args.extend(aout);
args.push(output.to_string_lossy().into_owned());
passes.push(args);
p2.push(out_arg);
vec![p1, p2]
}
}
pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built {
let has_video = media.video_codec.is_some();
let has_audio = media.audio_codec.is_some();
// "Fit to size" needs the final duration, so it is applied after the loop;
// it also overrides any Compress op's encoder settings.
let target_mb: Option<f64> = ops
.iter()
.filter(|o| o.kind == OpKind::TargetSize)
.find_map(|o| o.text(0).parse::<f64>().ok().filter(|m| *m > 0.0));
let tl = resolve_timeline(ops, media);
let ext = input
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| "mp4".into());
let mut plan = Plan::new(ext);
for op in ops {
plan.translate(op, &tl, has_video, has_audio, target_mb);
}
plan.drop_inapplicable_video_filters(has_video);
plan.resolve_stream_copy(ops);
let mut expected = tl.expected;
if plan.speed_factor != 1.0 && expected > 0.0 {
expected /= plan.speed_factor;
}
let passlog = plan.resolve_target_size(target_mb, media, has_video, has_audio, expected, input);
let filters = plan.filter_args();
// Assemble the argument list(s).
let mut base: Vec<String> = vec!["-y".into()];
base.extend(plan.pre.iter().cloned());
base.push("-i".into());
base.push(input.to_string_lossy().into_owned());
let dir = input.parent().unwrap_or(Path::new("."));
let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() };
let output = dir.join(format!("{}.{}", stem, plan.ext));
let passes = plan.assemble_passes(base, filters, &output, passlog.as_deref());
if output.exists() {
notes.push("Output file already exists and will be overwritten (-y)".into());
plan.notes
.push("Output file already exists and will be overwritten (-y)".into());
}
Built { passes, output, passlog, expected_duration: expected, notes }
Built { passes, output, passlog, expected_duration: expected, notes: plan.notes }
}
/// Remove the stats files left behind by a two-pass encode.