From 017d9ad0068d22aa2a15aaf3abc9039e84344598 Mon Sep 17 00:00:00 2001 From: Weetile Date: Wed, 17 Jun 2026 15:51:05 +0100 Subject: [PATCH] Decompose build() into a Plan accumulator with named phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ffmpeg.rs | 680 +++++++++++++++++++++++++++++--------------------- 1 file changed, 392 insertions(+), 288 deletions(-) diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 30b2dee..7789fcb 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -115,36 +115,19 @@ pub struct Built { pub notes: Vec, } -pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built { - let mut pre: Vec = Vec::new(); // before -i - let mut vf: Vec = Vec::new(); // video filter chain - let mut af: Vec = Vec::new(); // audio filter chain - let mut out: Vec = Vec::new(); // output options - let mut aout: Vec = Vec::new(); // audio output options (skipped in pass 1) - let mut notes: Vec = 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 = ops - .iter() - .filter(|o| o.kind == OpKind::TargetSize) - .find_map(|o| o.text(0).parse::().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, // args before -i (input seeking) + vf: Vec, // video filter chain + af: Vec, // audio filter chain + out: Vec, // output options + aout: Vec, // audio output options + notes: Vec, // 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, + ) { 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(); - } - 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()); - } else { - notes.push("Stream copy disabled: other edits require re-encoding".into()); + 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 speed_factor != 1.0 && expected > 0.0 { - expected /= speed_factor; + /// 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(); } - // 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 = 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()); + /// 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 { - // 3% margin for container overhead. - 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( - "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()); - if has_audio && !audio_removed { - aout.push("-c:a".into()); - aout.push("aac".into()); - aout.push("-b:a".into()); - aout.push("128k".into()); - } - 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(), - ); - } - 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())), + self.notes + .push("Stream copy disabled: other edits require re-encoding".into()); + } + } + + /// 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, + media: &MediaInfo, + has_video: bool, + has_audio: bool, + expected: f64, + input: &Path, + ) -> Option { + 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; + } + + // 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; + self.notes.push( + "Fit to size: target is very small for this length \u{2014} expect poor quality, size may overshoot" + .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 { + self.aout.push("-c:a".into()); + self.aout.push("aac".into()); + self.aout.push("-b:a".into()); + self.aout.push("128k".into()); + } + 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 { + self.notes + .push("Target is larger than the input \u{2014} the file will grow, not shrink".into()); + } + + let mut hasher = DefaultHasher::new(); + input.hash(&mut hasher); + Some(std::env::temp_dir().join(format!("lazyff-2pass-{:016x}", hasher.finish()))) } - // Assemble the argument list(s). - let mut base: Vec = vec!["-y".into()]; - base.extend(pre); - base.push("-i".into()); - base.push(input.to_string_lossy().into_owned()); - - let mut filters: Vec = Vec::new(); - if gif { - // GIF needs a palette pass to avoid ugly dithering; bundle the user's - // filters into the palette graph. - let mut chain: Vec = Vec::new(); - if !has_fps_op { - chain.push("fps=12".into()); - } - if !has_resize_op { - chain.push("scale=480:-2:flags=lanczos".into()); - } - chain.extend(vf.clone()); - let fc = 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()); - } else { - if !vf.is_empty() { - filters.push("-vf".into()); - filters.push(vf.join(",")); - } - if !af.is_empty() { - filters.push("-af".into()); - filters.push(af.join(",")); + /// 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 { + 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 = Vec::new(); + if !self.has_fps_op { + chain.push("fps=12".into()); + } + if !self.has_resize_op { + chain.push("scale=480:-2:flags=lanczos".into()); + } + 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("-an".into()); + self.af.clear(); + self.notes + .push("GIF: palette pass added for good colors; audio removed".into()); + } else { + if !self.vf.is_empty() { + filters.push("-vf".into()); + filters.push(self.vf.join(",")); + } + if !self.af.is_empty() { + filters.push("-af".into()); + 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, + filters: Vec, + output: &Path, + passlog: Option<&Path>, + ) -> Vec> { + 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::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 = ops + .iter() + .filter(|o| o.kind == OpKind::TargetSize) + .find_map(|o| o.text(0).parse::().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 = 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.