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:
+392
-288
@@ -115,36 +115,19 @@ pub struct Built {
|
|||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built {
|
/// A trim window collapsed from all Trim ops. `expected` is the best guess at
|
||||||
let mut pre: Vec<String> = Vec::new(); // before -i
|
/// the *output* duration before any speed change, used for the progress bar
|
||||||
let mut vf: Vec<String> = Vec::new(); // video filter chain
|
/// and the fit-to-size bitrate math.
|
||||||
let mut af: Vec<String> = Vec::new(); // audio filter chain
|
struct Timeline {
|
||||||
let mut out: Vec<String> = Vec::new(); // output options
|
start: f64,
|
||||||
let mut aout: Vec<String> = Vec::new(); // audio output options (skipped in pass 1)
|
trim_dur: f64,
|
||||||
let mut notes: Vec<String> = Vec::new();
|
explicit_end: bool,
|
||||||
|
expected: f64,
|
||||||
|
}
|
||||||
|
|
||||||
let mut ext = input
|
/// Trim is resolved before the main loop because the fade effects and the
|
||||||
.extension()
|
/// fit-to-size budget both need the trimmed duration up front.
|
||||||
.map(|e| e.to_string_lossy().to_lowercase())
|
fn resolve_timeline(ops: &[Operation], media: &MediaInfo) -> Timeline {
|
||||||
.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.
|
|
||||||
let mut start = 0.0_f64;
|
let mut start = 0.0_f64;
|
||||||
let mut end = media.duration;
|
let mut end = media.duration;
|
||||||
let mut explicit_end = false;
|
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 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 {
|
match op.kind {
|
||||||
OpKind::Trim => {
|
OpKind::Trim => {
|
||||||
if start > 0.0 {
|
if tl.start > 0.0 {
|
||||||
pre.push("-ss".into());
|
self.pre.push("-ss".into());
|
||||||
pre.push(fmt_arg_secs(start));
|
self.pre.push(fmt_arg_secs(tl.start));
|
||||||
}
|
}
|
||||||
if explicit_end {
|
if tl.explicit_end {
|
||||||
out.push("-t".into());
|
self.out.push("-t".into());
|
||||||
out.push(fmt_arg_secs(trim_dur));
|
self.out.push(fmt_arg_secs(tl.trim_dur));
|
||||||
}
|
}
|
||||||
if op.choice(2) == 1 {
|
if op.choice(2) == 1 {
|
||||||
copy_requested = true;
|
self.copy_requested = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Resize => {
|
OpKind::Resize => {
|
||||||
has_resize_op = true;
|
self.has_resize_op = true;
|
||||||
let f = match op.choice(0) {
|
let f = match op.choice(0) {
|
||||||
0 => "scale=-2:1080".into(),
|
0 => "scale=-2:1080".into(),
|
||||||
1 => "scale=-2:720".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 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) };
|
let h = if op.text(2).is_empty() { "-2" } else { op.text(2) };
|
||||||
if w == "-2" && h == "-2" {
|
if w == "-2" && h == "-2" {
|
||||||
notes.push("Resize: no custom size given, skipped".into());
|
self.notes.push("Resize: no custom size given, skipped".into());
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
format!("scale={}:{}", w, h)
|
format!("scale={}:{}", w, h)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
vf.push(f);
|
self.vf.push(f);
|
||||||
}
|
}
|
||||||
OpKind::Crop => {
|
OpKind::Crop => {
|
||||||
let f = match op.choice(0) {
|
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(),
|
3 => "crop='min(iw,ih*4/3)':'min(ih,iw*3/4)'".into(),
|
||||||
_ => {
|
_ => {
|
||||||
if op.text(1).is_empty() || op.text(2).is_empty() {
|
if op.text(1).is_empty() || op.text(2).is_empty() {
|
||||||
notes.push("Crop: custom width/height missing, skipped".into());
|
self.notes.push("Crop: custom width/height missing, skipped".into());
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
let mut f = format!("crop={}:{}", op.text(1), op.text(2));
|
let mut f = format!("crop={}:{}", op.text(1), op.text(2));
|
||||||
if !op.text(3).is_empty() && !op.text(4).is_empty() {
|
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
|
f
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
vf.push(f);
|
self.vf.push(f);
|
||||||
}
|
}
|
||||||
OpKind::Rotate => {
|
OpKind::Rotate => {
|
||||||
vf.push(
|
self.vf.push(
|
||||||
match op.choice(0) {
|
match op.choice(0) {
|
||||||
0 => "transpose=1",
|
0 => "transpose=1",
|
||||||
1 => "transpose=2",
|
1 => "transpose=2",
|
||||||
@@ -231,12 +264,12 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
}
|
}
|
||||||
OpKind::Speed => {
|
OpKind::Speed => {
|
||||||
let f: f64 = op.code(0).parse().unwrap_or(1.0);
|
let f: f64 = op.code(0).parse().unwrap_or(1.0);
|
||||||
speed_factor = f;
|
self.speed_factor = f;
|
||||||
if has_video {
|
if has_video {
|
||||||
vf.push(format!("setpts=PTS/{}", f));
|
self.vf.push(format!("setpts=PTS/{}", f));
|
||||||
}
|
}
|
||||||
if has_audio {
|
if has_audio {
|
||||||
af.extend(atempo_chain(f));
|
self.af.extend(atempo_chain(f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Adjust => {
|
OpKind::Adjust => {
|
||||||
@@ -244,47 +277,48 @@ pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaIn
|
|||||||
let c = op.code(1);
|
let c = op.code(1);
|
||||||
let s = op.code(2);
|
let s = op.code(2);
|
||||||
if b == "0" && c == "1.0" && s == "1.0" {
|
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 {
|
} else {
|
||||||
vf.push(format!("eq=brightness={}:contrast={}:saturation={}", b, c, s));
|
self.vf
|
||||||
|
.push(format!("eq=brightness={}:contrast={}:saturation={}", b, c, s));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Effect => {
|
OpKind::Effect => {
|
||||||
// If a Speed op already pushed setpts ahead of us, this fade
|
// 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
|
// runs on the sped-up timeline, so its start must be scaled to
|
||||||
// match. speed_factor is 1.0 until that point.
|
// 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) {
|
match op.choice(0) {
|
||||||
0 => vf.push("hue=s=0".into()),
|
0 => self.vf.push("hue=s=0".into()),
|
||||||
1 => vf.push(
|
1 => self.vf.push(
|
||||||
"colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131"
|
"colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131"
|
||||||
.into(),
|
.into(),
|
||||||
),
|
),
|
||||||
2 => vf.push("gblur=sigma=8".into()),
|
2 => self.vf.push("gblur=sigma=8".into()),
|
||||||
3 => vf.push("unsharp=5:5:1.0".into()),
|
3 => self.vf.push("unsharp=5:5:1.0".into()),
|
||||||
4 => vf.push("vignette".into()),
|
4 => self.vf.push("vignette".into()),
|
||||||
5 => vf.push("hqdn3d=4".into()),
|
5 => self.vf.push("hqdn3d=4".into()),
|
||||||
6 => vf.push("fade=t=in:st=0:d=1".into()),
|
6 => self.vf.push("fade=t=in:st=0:d=1".into()),
|
||||||
7 => vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start)),
|
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());
|
self.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(format!("fade=t=out:st={:.2}:d=1", fade_out_start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpKind::Fps => {
|
OpKind::Fps => {
|
||||||
has_fps_op = true;
|
self.has_fps_op = true;
|
||||||
vf.push(format!("fps={}", op.code(0)));
|
self.vf.push(format!("fps={}", op.code(0)));
|
||||||
}
|
}
|
||||||
OpKind::TargetSize => {
|
OpKind::TargetSize => {
|
||||||
if target_mb.is_none() {
|
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 => {
|
OpKind::Compress => {
|
||||||
if target_mb.is_some() {
|
if target_mb.is_some() {
|
||||||
notes.push("Compress ignored: Fit to size controls the encoder".into());
|
self.notes.push("Compress ignored: Fit to size controls the encoder".into());
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
let h265 = op.choice(0) == 1;
|
let h265 = op.choice(0) == 1;
|
||||||
let crf = match (h265, op.choice(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",
|
1 => "fast",
|
||||||
_ => "slow",
|
_ => "slow",
|
||||||
};
|
};
|
||||||
out.push("-c:v".into());
|
self.out.push("-c:v".into());
|
||||||
out.push(if h265 { "libx265" } else { "libx264" }.into());
|
self.out.push(if h265 { "libx265" } else { "libx264" }.into());
|
||||||
out.push("-crf".into());
|
self.out.push("-crf".into());
|
||||||
out.push(crf.to_string());
|
self.out.push(crf.to_string());
|
||||||
out.push("-preset".into());
|
self.out.push("-preset".into());
|
||||||
out.push(preset.into());
|
self.out.push(preset.into());
|
||||||
out.push("-pix_fmt".into());
|
self.out.push("-pix_fmt".into());
|
||||||
out.push("yuv420p".into());
|
self.out.push("yuv420p".into());
|
||||||
if has_audio {
|
if has_audio {
|
||||||
aout.push("-c:a".into());
|
self.aout.push("-c:a".into());
|
||||||
aout.push("aac".into());
|
self.aout.push("aac".into());
|
||||||
aout.push("-b:a".into());
|
self.aout.push("-b:a".into());
|
||||||
aout.push("128k".into());
|
self.aout.push("128k".into());
|
||||||
}
|
}
|
||||||
if h265 && (ext == "mp4" || ext == "mov") {
|
if h265 && (self.ext == "mp4" || self.ext == "mov") {
|
||||||
out.push("-tag:v".into());
|
self.out.push("-tag:v".into());
|
||||||
out.push("hvc1".into());
|
self.out.push("hvc1".into());
|
||||||
notes.push("H.265 tagged as hvc1 so Apple players accept it".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
|
// Format and Audio offer different choices for video and audio
|
||||||
// inputs, so they are matched by stable code rather than index.
|
// inputs, so they are matched by stable code rather than index.
|
||||||
OpKind::Format => match op.code(0) {
|
OpKind::Format => self.translate_format(op, has_audio),
|
||||||
"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::Audio => {
|
OpKind::Audio => {
|
||||||
if !has_audio {
|
if !has_audio {
|
||||||
notes.push("Audio: this file has no audio track, skipped".into());
|
self.notes.push("Audio: this file has no audio track, skipped".into());
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
match op.code(0) {
|
match op.code(0) {
|
||||||
"remove" => {
|
"remove" => self.out.push("-an".into()),
|
||||||
out.push("-an".into());
|
"vol+50" => self.af.push("volume=1.5".into()),
|
||||||
}
|
"vol2x" => self.af.push("volume=2.0".into()),
|
||||||
"vol+50" => af.push("volume=1.5".into()),
|
"vol-50" => self.af.push("volume=0.5".into()),
|
||||||
"vol2x" => af.push("volume=2.0".into()),
|
"normalize" => self.af.push("loudnorm".into()),
|
||||||
"vol-50" => af.push("volume=0.5".into()),
|
|
||||||
"normalize" => af.push("loudnorm".into()),
|
|
||||||
_ => {
|
_ => {
|
||||||
// Fade. Like the video fade, scale the out point if a
|
// Fade. Like the video fade, scale the out point if a
|
||||||
// speed change already sits ahead of us in the chain.
|
// speed change already sits ahead of us in the chain.
|
||||||
af.push("afade=t=in:st=0:d=1".into());
|
self.af.push("afade=t=in:st=0:d=1".into());
|
||||||
af.push(format!(
|
self.af.push(format!(
|
||||||
"afade=t=out:st={:.2}:d=1",
|
"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() {
|
fn translate_format(&mut self, op: &Operation, has_audio: bool) {
|
||||||
notes.push("This file has no video stream; picture edits were skipped".into());
|
match op.code(0) {
|
||||||
vf.clear();
|
"mp4" => self.ext = "mp4".into(),
|
||||||
}
|
"mkv" => self.ext = "mkv".into(),
|
||||||
if audio_only_output && !vf.is_empty() {
|
"webm" => {
|
||||||
notes.push("Audio-only output: picture edits were dropped".into());
|
self.ext = "webm".into();
|
||||||
vf.clear();
|
self.out.push("-c:v".into());
|
||||||
}
|
self.out.push("libvpx-vp9".into());
|
||||||
|
self.out.push("-crf".into());
|
||||||
// Stream copy is only possible when nothing has to be re-encoded.
|
self.out.push("32".into());
|
||||||
let only_trims = ops.iter().all(|o| o.kind == OpKind::Trim);
|
self.out.push("-b:v".into());
|
||||||
if copy_requested {
|
self.out.push("0".into());
|
||||||
if only_trims {
|
if has_audio {
|
||||||
out.push("-c".into());
|
self.aout.push("-c:a".into());
|
||||||
out.push("copy".into());
|
self.aout.push("libopus".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());
|
"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 {
|
/// Begin an audio-only output of the given extension: drop the video
|
||||||
expected /= speed_factor;
|
/// 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,
|
/// Drop video filters that can't apply to this output (no video stream, or
|
||||||
// encoded in two passes so the budget is actually met.
|
/// an audio-only container), leaving a note for each case.
|
||||||
let audio_removed = out.iter().any(|a| a == "-an");
|
fn drop_inapplicable_video_filters(&mut self, has_video: bool) {
|
||||||
let mut two_pass = false;
|
if !has_video && !self.vf.is_empty() {
|
||||||
let mut passlog: Option<PathBuf> = None;
|
self.notes
|
||||||
if let Some(mb) = target_mb {
|
.push("This file has no video stream; picture edits were skipped".into());
|
||||||
if !has_video || audio_only_output {
|
self.vf.clear();
|
||||||
notes.push("Fit to size: needs a video output, skipped".into());
|
}
|
||||||
} else if gif {
|
if self.audio_only_output && !self.vf.is_empty() {
|
||||||
notes.push("Fit to size: not available for GIF, skipped".into());
|
self.notes.push("Audio-only output: picture edits were dropped".into());
|
||||||
} else if ext == "webm" {
|
self.vf.clear();
|
||||||
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());
|
|
||||||
|
/// 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 {
|
} else {
|
||||||
// 3% margin for container overhead.
|
self.notes
|
||||||
let audio_kbps = if has_audio && !audio_removed { 128.0 } else { 0.0 };
|
.push("Stream copy disabled: other edits require re-encoding".into());
|
||||||
let mut v_kbps = (mb * 8000.0 * 0.97) / expected - audio_kbps;
|
}
|
||||||
if v_kbps < 50.0 {
|
}
|
||||||
v_kbps = 50.0;
|
|
||||||
notes.push(
|
/// Resolve "Fit to size": pick a video bitrate that lands on the target and
|
||||||
"Fit to size: target is very small for this length \u{2014} expect poor quality, size may overshoot"
|
/// switch to a two-pass encode so the budget is actually met. Returns the
|
||||||
.into(),
|
/// stats-file prefix when two-pass is engaged (None otherwise).
|
||||||
);
|
fn resolve_target_size(
|
||||||
}
|
&mut self,
|
||||||
out.push("-c:v".into());
|
target_mb: Option<f64>,
|
||||||
out.push("libx264".into());
|
media: &MediaInfo,
|
||||||
out.push("-b:v".into());
|
has_video: bool,
|
||||||
out.push(format!("{:.0}k", v_kbps));
|
has_audio: bool,
|
||||||
out.push("-preset".into());
|
expected: f64,
|
||||||
out.push("medium".into());
|
input: &Path,
|
||||||
out.push("-pix_fmt".into());
|
) -> Option<PathBuf> {
|
||||||
out.push("yuv420p".into());
|
let mb = target_mb?;
|
||||||
if has_audio && !audio_removed {
|
if !has_video || self.audio_only_output {
|
||||||
aout.push("-c:a".into());
|
self.notes.push("Fit to size: needs a video output, skipped".into());
|
||||||
aout.push("aac".into());
|
return None;
|
||||||
aout.push("-b:a".into());
|
}
|
||||||
aout.push("128k".into());
|
if self.gif {
|
||||||
}
|
self.notes.push("Fit to size: not available for GIF, skipped".into());
|
||||||
notes.push(format!(
|
return None;
|
||||||
"Two-pass H.264 at {:.0} kb/s video to land near {} MB",
|
}
|
||||||
v_kbps, mb
|
if self.ext == "webm" {
|
||||||
));
|
self.notes.push(
|
||||||
if mb * 1e6 > media.size_bytes as f64 && media.size_bytes > 0 {
|
"Fit to size: not supported for WebM here \u{2014} convert to MP4 instead".into(),
|
||||||
notes.push(
|
);
|
||||||
"Target is larger than the input \u{2014} the file will grow, not shrink"
|
return None;
|
||||||
.into(),
|
}
|
||||||
);
|
if expected <= 0.0 {
|
||||||
}
|
self.notes.push("Fit to size: unknown duration, skipped".into());
|
||||||
two_pass = true;
|
return None;
|
||||||
let mut hasher = DefaultHasher::new();
|
}
|
||||||
input.hash(&mut hasher);
|
|
||||||
passlog = Some(
|
// 3% margin for container overhead.
|
||||||
std::env::temp_dir().join(format!("lazyff-2pass-{:016x}", hasher.finish())),
|
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).
|
/// Build the `-vf`/`-af` (or, for GIF, `-filter_complex`) fragment. The
|
||||||
let mut base: Vec<String> = vec!["-y".into()];
|
/// video filter chain in `self.vf` is left intact for the two-pass pass-1
|
||||||
base.extend(pre);
|
/// command, which needs it separately.
|
||||||
base.push("-i".into());
|
fn filter_args(&mut self) -> Vec<String> {
|
||||||
base.push(input.to_string_lossy().into_owned());
|
let mut filters = Vec::new();
|
||||||
|
if self.gif {
|
||||||
let mut filters: Vec<String> = Vec::new();
|
// GIF needs a palette pass to avoid ugly dithering; bundle the
|
||||||
if gif {
|
// user's filters into the palette graph.
|
||||||
// GIF needs a palette pass to avoid ugly dithering; bundle the user's
|
let mut chain: Vec<String> = Vec::new();
|
||||||
// filters into the palette graph.
|
if !self.has_fps_op {
|
||||||
let mut chain: Vec<String> = Vec::new();
|
chain.push("fps=12".into());
|
||||||
if !has_fps_op {
|
}
|
||||||
chain.push("fps=12".into());
|
if !self.has_resize_op {
|
||||||
}
|
chain.push("scale=480:-2:flags=lanczos".into());
|
||||||
if !has_resize_op {
|
}
|
||||||
chain.push("scale=480:-2:flags=lanczos".into());
|
chain.extend(self.vf.iter().cloned());
|
||||||
}
|
filters.push("-filter_complex".into());
|
||||||
chain.extend(vf.clone());
|
filters.push(format!(
|
||||||
let fc = format!(
|
"{},split[a][b];[a]palettegen[p];[b][p]paletteuse",
|
||||||
"{},split[a][b];[a]palettegen[p];[b][p]paletteuse",
|
chain.join(",")
|
||||||
chain.join(",")
|
));
|
||||||
);
|
filters.push("-an".into());
|
||||||
filters.push("-filter_complex".into());
|
self.af.clear();
|
||||||
filters.push(fc);
|
self.notes
|
||||||
filters.push("-an".into());
|
.push("GIF: palette pass added for good colors; audio removed".into());
|
||||||
af.clear();
|
} else {
|
||||||
notes.push("GIF: palette pass added for good colors; audio removed".into());
|
if !self.vf.is_empty() {
|
||||||
} else {
|
filters.push("-vf".into());
|
||||||
if !vf.is_empty() {
|
filters.push(self.vf.join(","));
|
||||||
filters.push("-vf".into());
|
}
|
||||||
filters.push(vf.join(","));
|
if !self.af.is_empty() {
|
||||||
}
|
filters.push("-af".into());
|
||||||
if !af.is_empty() {
|
filters.push(self.af.join(","));
|
||||||
filters.push("-af".into());
|
}
|
||||||
filters.push(af.join(","));
|
|
||||||
}
|
}
|
||||||
|
filters
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir = input.parent().unwrap_or(Path::new("."));
|
/// Assemble the final per-pass argument lists. With a `passlog` prefix this
|
||||||
let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() };
|
/// produces an analysis pass (video only, output discarded) followed by the
|
||||||
let output = dir.join(format!("{}.{}", stem, ext));
|
/// 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();
|
let log = prefix.to_string_lossy().into_owned();
|
||||||
if two_pass {
|
|
||||||
let log = passlog.as_ref().unwrap().to_string_lossy().into_owned();
|
|
||||||
// Pass 1: video analysis only, no audio, output discarded.
|
// Pass 1: video analysis only, no audio, output discarded.
|
||||||
let mut p1 = base.clone();
|
let mut p1 = base.clone();
|
||||||
if !vf.is_empty() {
|
if !self.vf.is_empty() {
|
||||||
p1.push("-vf".into());
|
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(
|
p1.extend(
|
||||||
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", NULL_DEVICE]
|
["-an", "-pass", "1", "-passlogfile", &log, "-f", "null", NULL_DEVICE]
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
);
|
);
|
||||||
passes.push(p1);
|
|
||||||
|
|
||||||
let mut p2 = base;
|
let mut p2 = base;
|
||||||
p2.extend(filters);
|
p2.extend(filters);
|
||||||
p2.extend(out);
|
p2.extend(self.out.iter().cloned());
|
||||||
p2.extend(aout);
|
p2.extend(self.aout.iter().cloned());
|
||||||
p2.extend(["-pass", "2", "-passlogfile", &log].map(String::from));
|
p2.extend(["-pass", "2", "-passlogfile", &log].map(String::from));
|
||||||
p2.push(output.to_string_lossy().into_owned());
|
p2.push(out_arg);
|
||||||
passes.push(p2);
|
|
||||||
} else {
|
vec![p1, p2]
|
||||||
let mut args = base;
|
|
||||||
args.extend(filters);
|
|
||||||
args.extend(out);
|
|
||||||
args.extend(aout);
|
|
||||||
args.push(output.to_string_lossy().into_owned());
|
|
||||||
passes.push(args);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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.
|
/// Remove the stats files left behind by a two-pass encode.
|
||||||
|
|||||||
Reference in New Issue
Block a user