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:
+361
-257
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user