ffmpeg-skill 3.8.15 → 3.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -282,16 +282,32 @@ def _slice_atom(atom: str, max_em: float) -> "List[str]":
282
282
  No dictionary, no hyphenation library: an existing hyphen is preferred as the cut (the same
283
283
  break R1 already allows), then whatever is left is cut again at exactly the widest prefix
284
284
  that still measures within `max_em`, one character at a time. The characters themselves are
285
- never rewritten -- every piece concatenates back to the original atom exactly."""
286
- if text_width_em(atom) <= max_em:
287
- return [atom]
288
- for i in range(len(atom) - 2, 0, -1):
289
- # latest hyphen whose left side still fits -- keeps the left half as large as possible
290
- if atom[i] in _HYPHENS and text_width_em(atom[:i + 1]) <= max_em:
291
- return [atom[:i + 1]] + _slice_atom(atom[i + 1:], max_em)
285
+ never rewritten -- every piece concatenates back to the original atom exactly.
286
+
287
+ Iterative, and each piece measures only the prefixes up to the first that is too wide (a
288
+ prefix is never narrower than a shorter one): the recursive form measured every prefix of the
289
+ remaining atom for each piece it cut, so a 1 KB hyphenated cue took seconds and 4 KB minutes
290
+ (A11, #465), in caption.py's own process, which --timeout (a limit on each ffmpeg call) does
291
+ not bound. The pieces are the ones the recursive form cut."""
292
292
  pieces: "List[str]" = []
293
+ rest = atom
294
+ while True:
295
+ # latest hyphen whose left side still fits -- keeps the left half as large as possible;
296
+ # a rest whose every prefix fits, itself included, is the last piece
297
+ cut = 0
298
+ for end in range(1, len(rest) + 1):
299
+ if text_width_em(rest[:end]) > max_em:
300
+ break
301
+ if 1 <= end - 1 <= len(rest) - 2 and rest[end - 1] in _HYPHENS:
302
+ cut = end
303
+ else:
304
+ return pieces + [rest]
305
+ if not cut:
306
+ break
307
+ pieces.append(rest[:cut])
308
+ rest = rest[cut:]
293
309
  cur = ""
294
- for ch in atom:
310
+ for ch in rest:
295
311
  candidate = cur + ch
296
312
  if cur and text_width_em(candidate) > max_em:
297
313
  pieces.append(cur)
@@ -436,6 +436,7 @@ ERROR_REASONS = {
436
436
  "output_is_directory": "the output path is a directory; details: {output}",
437
437
  "media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
438
438
  "media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
439
+ "still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
439
440
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
440
441
  "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
441
442
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
@@ -446,7 +447,7 @@ ERROR_REASONS = {
446
447
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
447
448
  "output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
448
449
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
449
- "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
450
+ "output_locked": "another run holds the output's lock, or is taking a stale one over (why: takeover_in_progress), or a stale lock cannot be taken over safely because the filesystem gives no OS file lock (why: takeover_unavailable: delete it by hand); details: {lock, output, pid, pid_alive, deadline, why}",
450
451
  "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
451
452
  "inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
452
453
  "sha256_head_tail} | \"missing\"}], unbound_inputs, audit_log}",
@@ -1943,6 +1944,11 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
1943
1944
  "to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
1944
1945
  "look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
1945
1946
  "only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
1947
+ # A23 (#465): strip_dovi was taken and changed nothing; true now asks for the other recipe
1948
+ "flags": {"strip_dovi": (
1949
+ "Keep {input} HDR and drop only its Dolby Vision layer: `color.py {input} --strip-dovi`, a stream copy "
1950
+ "(HEVC only) that removes the Dolby Vision RPU so players use the HLG/HDR10 base layer. A source with no "
1951
+ "Dolby Vision metadata gets a `dovi_not_detected` warning.")},
1946
1952
  },
1947
1953
  ]
1948
1954
 
@@ -1968,7 +1974,17 @@ def mcp_prompt_get(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
1968
1974
  fields: Dict[str, str] = {a["name"]: str((arguments or {}).get(a["name"]) or a.get("default", "")) for a in prompt["arguments"]}
1969
1975
  for opt_name, clause in prompt["optional"].items():
1970
1976
  fields[opt_name] = clause.format(**fields) if fields.get(opt_name) else ""
1971
- text = prompt["template"].format(**fields)
1977
+ template = prompt["template"]
1978
+ for flag, flag_template in (prompt.get("flags") or {}).items():
1979
+ # a yes/no argument picks the recipe; every prompt argument arrives as a string, so
1980
+ # "false" is no, and a value that is neither is a caller mistake (ValueError, as above)
1981
+ value = (arguments or {}).get(flag)
1982
+ word = str(value).strip().lower() if value is not None else ""
1983
+ if word in ("true", "1", "yes"):
1984
+ template = flag_template
1985
+ elif word not in ("", "false", "0", "no"):
1986
+ raise ValueError(f"prompt {name!r}: {flag} must be true or false, got {value!r}")
1987
+ text = template.format(**fields)
1972
1988
  return {"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
1973
1989
 
1974
1990
 
package/scripts/batch.py CHANGED
@@ -699,10 +699,15 @@ def main() -> int:
699
699
  created = [d for d in (outdir, work) if not d.exists()]
700
700
  outdir.mkdir(parents=True, exist_ok=True)
701
701
  work.mkdir(parents=True, exist_ok=True)
702
- if STATE.dry_run and created:
702
+ # a real run that is refused before any item starts (--jobs, an output collision, a final path
703
+ # that is an input) leaves nothing behind either (A187, #465: it left out/ and out/.work)
704
+ item_started = {"item": False}
705
+ if created:
703
706
  import atexit
704
707
 
705
708
  def _remove_empty_dirs() -> None:
709
+ if not STATE.dry_run and item_started["item"]:
710
+ return
706
711
  for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
707
712
  try:
708
713
  if not any(d.iterdir()):
@@ -850,6 +855,7 @@ def main() -> int:
850
855
  def run_item(i: int, src: Path) -> Dict[str, Any]:
851
856
  """process() in the item's own work directory, which goes when it ends, however it ends."""
852
857
  import shutil
858
+ item_started["item"] = True
853
859
  iw = item_work(i, src)
854
860
  try:
855
861
  return process(src, recipe, outdir, iw, deadline)
package/scripts/broll.py CHANGED
@@ -24,7 +24,7 @@ from typing import Any, Dict, List
24
24
 
25
25
  from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
26
26
  grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
27
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
27
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, cfr_at_input, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
28
28
  from _common.emit import note, record_fallback
29
29
  from _common.probe import display_size, square_pixels, square_pixels_note
30
30
  from _common.emit import input_length
@@ -242,7 +242,10 @@ def main() -> int:
242
242
  parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,{tail}[aout]")
243
243
  aout = "[aout]"
244
244
 
245
- cmd += ["-filter_complex", ";".join(parts), "-map", vout]
245
+ # a VFR A-roll is conformed by the fps filter as it is decoded, at the rate cfr_args() delivers:
246
+ # -fps_mode cfr alone let a frame run up to two frames late against the sound (#512)
247
+ graph = cfr_at_input(";".join(parts), meta_a)
248
+ cmd += ["-filter_complex", graph, "-map", vout]
246
249
  if aout:
247
250
  cmd += ["-map", aout]
248
251
  cmd += video_args(meta_a, args.crf, args.preset, convert_matrix=False) + cfr_args(meta_a, None)
@@ -46,7 +46,7 @@ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
46
46
  strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
47
47
  from _common.emit import refuse # noqa: E402
48
48
  from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
49
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
50
50
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
51
51
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
52
52
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -55,7 +55,7 @@ from _common import asr as _asr
55
55
  from _common.asr import ENGINE_CHOICES
56
56
  from _common.emit import warn
57
57
  from _common.probe import display_size, square_pixels, square_pixels_note
58
- from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
58
+ from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
59
59
  from _common.fonts import font_choice, record_font_choice
60
60
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
61
61
  write_srt)
@@ -1586,9 +1586,12 @@ def main() -> int:
1586
1586
  up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
1587
1587
  if args.ass and args.offset and os.path.exists(args.ass):
1588
1588
  up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
1589
- # -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
1590
- if os.path.realpath(planned_out) != os.path.realpath(args.input):
1591
- up_front.append((planned_out, "video"))
1589
+ # -o naming the input (the same name, a hard link, another case on a case-insensitive
1590
+ # volume) is refused here, before any sidecar or transcript is written: run()'s own guard
1591
+ # came after the .srt was already beside it (review of #530)
1592
+ if same_file(planned_out, args.input):
1593
+ refuse_output_is_input(planned_out, args.input, why="the captioned video would replace the source")
1594
+ up_front.append((planned_out, "video"))
1592
1595
  claim(up_front)
1593
1596
  if args.transcribe:
1594
1597
  # the sidecar goes next to the output like the --text one, not into the source folder
@@ -2082,10 +2085,11 @@ def main() -> int:
2082
2085
  asset = spec[-1]
2083
2086
  if asset not in STATE.plan_inputs:
2084
2087
  STATE.plan_inputs.append(asset)
2085
- graph = ";".join([f"[0:v]{vf}[vsub]"] + chains)
2088
+ # the conform of a VFR source is the fps filter at the end of the chain (#512)
2089
+ graph = cfr_at_input(";".join([f"[0:v]{vf}[vsub]"] + chains), meta)
2086
2090
  cmd += ["-filter_complex", graph, "-map", "[vout]"]
2087
2091
  else:
2088
- cmd += ["-map", "0:v:0", "-vf", vf]
2092
+ cmd += ["-map", "0:v:0", "-vf", with_cfr(vf, meta)]
2089
2093
  if meta.get("audio"):
2090
2094
  cmd += ["-map", f"0:a:{args.audio_stream}"]
2091
2095
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
package/scripts/color.py CHANGED
@@ -33,7 +33,7 @@ import sys
33
33
  from typing import List, Optional
34
34
 
35
35
  from _common.runner import carried_stream_maps, fail_if_classified
36
- from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
36
+ from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
37
37
  from _common.emit import note, warn
38
38
  from _common.color import is_rgb, rgb_to_bt709_filter
39
39
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
@@ -648,13 +648,14 @@ def main() -> int:
648
648
  # converts the graded YUV in front of alphamerge, so the matrix is labelled there too
649
649
  fc = (f"[0:v]split[ka_c][ka_a];[ka_c]{vf}{matrix_label(meta)}[ka_g];[ka_a]alphaextract[ka_m];"
650
650
  f"[ka_g][ka_m]alphamerge,{finish_chain(alpha_codec, args.alpha_mode, meta)}[ka_v]")
651
+ fc = cfr_at_input(fc, meta) # the conform of a VFR source: the fps filter (#512)
651
652
  cmd = ffmpeg_base() + decoder_args_for(meta, args.input) + ["-i", args.input, "-filter_complex", fc,
652
653
  "-map", "[ka_v]", "-map", f"0:a:{args.audio_stream}?"]
653
654
  cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta) + (audio_line(alpha_codec) if has_audio else [])
654
655
  if STATE.hw:
655
656
  note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
656
657
  else:
657
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
658
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(vf, meta), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
658
659
  # --lut/--correct grade the source's own pixels, so a BT.601 source is converted to the BT.709
659
660
  # the line is tagged with; --to-sdr's zscale chain already writes BT.709 and converts nothing more
660
661
  cmd += x264_args(args.crf, args.preset, source=None if args.to_sdr else meta) + cfr_args(meta) + (aac_args() if has_audio else [])
package/scripts/crop.py CHANGED
@@ -22,7 +22,7 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
25
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.probe import display_size, square_pixels, square_pixels_note
27
27
  from _common.runner import default_selection_maps
28
28
  from _common.emit import note
@@ -70,7 +70,7 @@ def main() -> int:
70
70
  vf.insert(0, square_pixels(meta["video"]))
71
71
  note(square_pixels_note(meta["video"]))
72
72
  # the picture the rectangle was checked against, not the largest one default selection takes
73
- cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", ",".join(vf)]
73
+ cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(",".join(vf), meta, args.fps)]
74
74
  cmd += video_args(meta, args.crf, args.preset)
75
75
  cmd += cfr_args(meta, args.fps)
76
76
  if has_audio:
package/scripts/cut.py CHANGED
@@ -65,7 +65,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, seve
65
65
  from _common.runner import subtitle_codec_for, subtitle_container_holds
66
66
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
67
67
  from _common.timeline import output_timecode_args
68
- from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
68
+ from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, cfr_filter, graph_with_cfr, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
69
69
 
70
70
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
71
71
  DROPPED_STREAMS: List[str] = []
@@ -925,6 +925,9 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
925
925
  m = min(SEEK_MARGIN, start)
926
926
  cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
927
927
  cmd += stream_maps(meta, dst, copy=False)
928
+ # a VFR source is conformed by the fps filter at the rate cfr_args() delivers: the
929
+ # encoder line's -fps_mode cfr alone let a frame run up to two frames late (#512)
930
+ cmd += ["-vf", cfr_filter(meta)] if cfr_filter(meta) else []
928
931
  # ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
929
932
  # trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
930
933
  # sweep F2), so the re-encode carries video/audio only and the result says so
@@ -1162,8 +1165,11 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
1162
1165
  pads += f"[a{i}]"
1163
1166
  outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
1164
1167
  graph.append(f"{pads}concat=n={len(segments)}:v={int(has_v)}:a={int(has_a)}{outs}")
1165
- cmd += ["-filter_complex", ";".join(graph)]
1166
- for pad in ("[v]" if has_v else None, "[a]" if has_a else None):
1168
+ # a VFR source is conformed by the fps filter after the concat, at the rate cfr_args() delivers
1169
+ # (#512); a constant-rate source's graph is unchanged
1170
+ fc, vpad = graph_with_cfr(";".join(graph), "[v]", meta) if has_v else (";".join(graph), None)
1171
+ cmd += ["-filter_complex", fc]
1172
+ for pad in (vpad, "[a]" if has_a else None):
1167
1173
  if pad:
1168
1174
  cmd += ["-map", pad]
1169
1175
  if intermediate:
@@ -22,7 +22,7 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
25
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
26
26
 
27
27
  MODES = {"frame": 0, "field": 1}
28
28
  PARITIES = {"auto": -1, "tff": 0, "bff": 1}
@@ -58,16 +58,21 @@ def main() -> int:
58
58
  output = args.output or default_output(args.input, "deint")
59
59
 
60
60
  vf = f"yadif=mode={MODES[args.mode]}:parity={PARITIES[args.parity]}:deint={1 if args.only_interlaced else 0}"
61
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
62
- if has_audio:
63
- cmd += ["-map", f"0:a:{args.audio_stream}?"]
64
- cmd += video_args(meta, args.crf, args.preset)
61
+ # the conform is the fps filter at the end of the chain, the rate the encoder line delivers:
62
+ # its -fps_mode cfr alone let a frame run up to two frames late (#512)
65
63
  if args.mode == "field":
66
64
  v = meta["video"]
67
65
  src_fps = v.get("fps") or 30.0
68
- cmd += ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
66
+ rate_args = ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
67
+ vf += f",fps={src_fps * 2:g}"
69
68
  else:
70
- cmd += cfr_args(meta)
69
+ rate_args = cfr_args(meta)
70
+ vf = with_cfr(vf, meta)
71
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
72
+ if has_audio:
73
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
74
+ cmd += video_args(meta, args.crf, args.preset)
75
+ cmd += rate_args
71
76
  if has_audio:
72
77
  cmd += aac_args()
73
78
  else:
@@ -18,7 +18,7 @@ Examples:
18
18
  import argparse
19
19
  import sys
20
20
 
21
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
21
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
22
22
 
23
23
  # hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
24
24
  # starting points its own documentation and common usage recommend (spatial then temporal,
@@ -72,7 +72,7 @@ def main() -> int:
72
72
  output = args.output or default_output(args.input, "denoise")
73
73
 
74
74
  vf = f"hqdn3d={ls:g}:{cs:g}:{lt:g}:{ct:g}"
75
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
75
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(vf, meta, args.fps), "-map", "0:v:0"]
76
76
  if has_audio:
77
77
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
78
78
  cmd += video_args(meta, args.crf, args.preset)
package/scripts/export.py CHANGED
@@ -48,8 +48,8 @@ from pathlib import Path
48
48
  from typing import Dict, List, Optional
49
49
 
50
50
  from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
51
- from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
52
- from _common.decision import hw_preset_video, restate_last_swap
51
+ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, cfr_filter, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
52
+ from _common.decision import chain_with_rate, hw_preset_video, restate_last_swap
53
53
  from _common.emit import note, record_fallback, record_skip, warn
54
54
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
55
55
  from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
@@ -207,7 +207,11 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
207
207
  key_chain = ",".join(vf + ["alphaextract", "scale=in_range=pc:out_range=tv", "format=yuv422p10le"])
208
208
  tc = ((meta.get("timecode") or {}).get("value"))
209
209
  tc_args = ["-timecode", tc] if tc else []
210
- cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
210
+ # a VFR source is conformed by the fps filter, once, ahead of the split: both files hold the same
211
+ # frames, and -fps_mode cfr alone let a frame run up to two frames late against the sound (#512)
212
+ conform = cfr_filter(meta)
213
+ cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]{conform + ',' if conform else ''}split[f][k];"
214
+ f"[f]{fill_chain}[fill];[k]{key_chain}[key]"]
211
215
  cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
212
216
  # the fill is always written to a hidden sibling and moved into place only after the encode
213
217
  # succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
@@ -696,8 +700,23 @@ def main() -> int:
696
700
  if args.preset != "copy":
697
701
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
698
702
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
703
+ rate_args: List[str] = []
699
704
  if "-r" not in video:
700
- cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
705
+ rate_args = _platform_rate_args(args.preset, meta) or cfr_args(meta)
706
+ cmd += rate_args
707
+ # the rate the line delivers (a preset's own -r, a platform's divided rate, or a VFR
708
+ # source's average) is reached by the fps filter, the nearest frame for each slot: the
709
+ # encoder line's -r alone let a frame run up to two frames late against the sound before
710
+ # it dropped one (#512)
711
+ rate_f = (f"fps={video[video.index('-r') + 1]}" if "-r" in video
712
+ else f"fps={rate_args[rate_args.index('-r') + 1]}" if "-r" in rate_args else "")
713
+ # it is placed as with_cfr() places it: first in a -vf that is a graph (a blur pad's
714
+ # split/overlay, a drawn pad's maskedmerge), whose last frame an fps filter after it lost
715
+ # on FFmpeg 6.1 (reels --pad-fill blur: 119 frames of a 4 s 30 fps picture)
716
+ if rate_f and "-vf" in cmd:
717
+ cmd[cmd.index("-vf") + 1] = chain_with_rate(cmd[cmd.index("-vf") + 1], rate_f)
718
+ elif rate_f:
719
+ cmd += ["-vf", rate_f]
701
720
  if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
702
721
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
703
722
  if not p.get("intermediate"):
package/scripts/fit.py CHANGED
@@ -38,9 +38,9 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
41
+ from _common.decision import SEEK_MARGIN, delivered_rate, frame_durations, frame_rate, grid_at_or_after, length_output_t, stream_starts
42
42
  from _common.timeline import exact_rate, output_timecode_args
43
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
43
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, cfr_filter, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
44
44
  from _common.emit import input_length
45
45
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
46
46
 
@@ -162,6 +162,7 @@ def main() -> int:
162
162
  post: List[str] = []
163
163
  factor = 1.0
164
164
  tc_first = None # seconds from the source's first frame to the output's, when a trim moves it
165
+ regridded = False # a speed change already put the picture on the delivered grid
165
166
 
166
167
  # ---- rotate / flip
167
168
  if args.rotate == 90:
@@ -204,6 +205,15 @@ def main() -> int:
204
205
  elif factor < 1.0 and args.smooth == "blend":
205
206
  vf.append(f"fps={src_fps:g}")
206
207
  vf.append("tblend=all_mode=average")
208
+ elif not args.fps:
209
+ # the retimed frames are put back on the output's grid by the fps filter, the
210
+ # nearest frame for each slot: left to the encoder line's rate, a 1.11x speed-up
211
+ # showed every picture up to two frames late against its sound (#512's class).
212
+ # A rate the caller names is that grid (the conform below): conformed here to
213
+ # the source's 30 first, a 2x speed-up to --fps 60 dropped every other picture
214
+ # and the second fps filter repeated the rest
215
+ vf.append(frame_durations(meta, delivered_rate(meta) or frame_rate(meta) or src_fps))
216
+ regridded = True
207
217
  if has_audio:
208
218
  af.append(atempo_chain(factor))
209
219
  # atempo's sound is not the input's length over the factor (it comes out some
@@ -243,11 +253,43 @@ def main() -> int:
243
253
  else:
244
254
  info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
245
255
 
256
+ # ---- the output's rate: after any retiming, ahead of the frame's own filters. --fit blur and
257
+ # --pad-fill blur are a split/overlay graph, and FFmpeg 6.1's overlay ends its output at its
258
+ # last frame's start, so an fps filter after it lost the last frame (#512's placement).
259
+ # A speed change without --fps has already been put on the delivered grid above
260
+ if args.fps:
261
+ vf.append(f"fps={args.fps:g}")
262
+ elif meta["video"].get("variable_frame_rate_suspected") and not regridded:
263
+ info("source looks variable-frame-rate; conforming to constant fps automatically")
264
+ # the conform is the fps filter, as --fps is: -fps_mode cfr alone let a frame run up to
265
+ # two frames late against the sound (#512)
266
+ vf.append(cfr_filter(meta))
267
+
246
268
  # ---- aspect / size
247
269
  if args.aspect or args.width or args.height:
248
270
  # the one sizing rule, shared with render.py --export-timeline's sequence frame
249
271
  out_w, out_h = frame_size(parse_aspect(args.aspect) if args.aspect else None, args.width, args.height, sw, sh)
250
- if args.fit == "crop":
272
+ if args.fit == "crop" and sw and sh:
273
+ # The scaled frame's margin on each axis is a multiple of 4 and the offset is computed
274
+ # here, to even: crop floors an odd offset to the chroma grid of a 4:2:0 frame, so
275
+ # --crop-x 1 lost the source's last column and showed one column of the margin, and a
276
+ # centred crop sat a pixel left of / above the centre (#512). exact=1 would put the
277
+ # chroma half a sample off the luma instead. The size is the one
278
+ # force_original_aspect_ratio=increase gives (av_rescale rounds half away from zero),
279
+ # widened by at most 3 px on the cropped axis.
280
+ sc_w = max(out_w, (2 * out_h * sw + sh) // (2 * sh))
281
+ sc_h = max(out_h, (2 * out_w * sh + sw) // (2 * sw))
282
+ mx, my = (-(-(sc - o) // 4) * 4 for sc, o in ((sc_w, out_w), (sc_h, out_h)))
283
+ ox, oy = (int(m * f / 2 + 0.5) * 2 for m, f in ((mx, args.crop_x), (my, args.crop_y)))
284
+ for axis, m, f, got in (("x", mx, args.crop_x, ox), ("y", my, args.crop_y, oy)):
285
+ if abs(m * f - got) > 1e-9:
286
+ # the offset is kept even (the chroma grid); a caller who asked for another one
287
+ # is told which it got, as proxy.py says which width it wrote
288
+ note(f"--crop-{axis} {f:g}: crop offset {got}px, not {m * f:g}px: the offset is kept even "
289
+ f"(4:2:0 chroma), on a {m}px margin")
290
+ vf.append(f"scale={out_w + mx}:{out_h + my}")
291
+ vf.append(f"crop={out_w}:{out_h}:{ox}:{oy}")
292
+ elif args.fit == "crop": # a dry run's pending input has no size: the expressions, planned only
251
293
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
252
294
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
253
295
  elif args.fit == "blur":
@@ -272,10 +314,6 @@ def main() -> int:
272
314
  vf.insert(0, square_pixels(meta["video"]))
273
315
  note(square_pixels_note(meta["video"]))
274
316
 
275
- if args.fps:
276
- vf.append(f"fps={args.fps:g}")
277
- elif meta["video"].get("variable_frame_rate_suspected"):
278
- info("source looks variable-frame-rate; conforming to constant fps automatically")
279
317
  # the start timecode is counted at the rate the output is delivered at: a --fps that changes
280
318
  # the rate relabels it (01:00:00:12 at 25 fps is 01:00:00:24 at 50) even with no trim, which
281
319
  # is also how a render.py clip cut at its own rate reaches the project's frame.fps
package/scripts/freeze.py CHANGED
@@ -19,8 +19,8 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
22
+ from _common.decision import av_trim, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
24
24
  from _common.emit import input_length
25
25
 
26
26
 
@@ -71,7 +71,7 @@ def main() -> int:
71
71
  if holds_end:
72
72
  # tpad's stop_duration clones the last frame for the given duration; no PTS surgery
73
73
  # needed since it only ever appends past the real end.
74
- vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
74
+ vf = with_cfr(f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}", meta)
75
75
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
76
76
  if has_audio:
77
77
  cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
@@ -80,7 +80,7 @@ def main() -> int:
80
80
  # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
81
81
  # it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
82
82
  # end: tpad's start_duration clones the *first* frame backwards instead.
83
- vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
83
+ vf = with_cfr(f"tpad=start_mode=clone:start_duration={args.hold:.3f}", meta)
84
84
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
85
85
  if has_audio:
86
86
  # the sound waits as long as the picture is held: whole frames (tpad_length), as the
@@ -92,12 +92,12 @@ def main() -> int:
92
92
  # FIRST frame -- the frame shown at `at` -- for --hold seconds (a segment of its own, below).
93
93
  # Through 3.1.3 the head's last frame was held, the frame before `at`: at a cut, the last
94
94
  # frame of the previous shot (#411).
95
- n = max(1, round(args.hold * fps))
95
+ n = max(1, round(args.hold * rate))
96
96
  # The video hold length is rounded to a whole number of frames (n / fps), but the audio
97
97
  # side used to pad by the raw --hold value -- up to half a frame duration off from what
98
98
  # the video actually holds for, a permanent A/V drift from this point on. Pad audio by
99
99
  # the same, frame-rounded duration the video actually gets.
100
- actual_hold = n / fps
100
+ actual_hold = n / rate
101
101
  head_v, head_a = av_trim(v0, cut_at, rate)
102
102
  tail_v, tail_a = av_trim(cut_at, None, rate)
103
103
  # The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
@@ -112,11 +112,13 @@ def main() -> int:
112
112
  # and 0.100, --at 0.06), and the hold was lost (#416 review). A second trim keeps that one
113
113
  # frame; on a constant rate it is the frame at cut_at, as before.
114
114
  half = 0.5 / rate
115
+ # the graph closes on the rate the encoder line delivers (a VFR source is conformed to its
116
+ # average): closed on r_frame_rate, -r then resampled it and the picture ran late (#512)
115
117
  hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
116
118
  f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
117
119
  vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
118
120
  f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
119
- f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, rate)}[outv]")
121
+ f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, delivered_rate(meta) or rate)}[outv]")
120
122
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
121
123
  if has_audio:
122
124
  af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
38
38
  from _common.drawtext import shaping_refusal
39
- from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
39
+ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
40
40
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
41
41
  ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
42
42
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
@@ -837,14 +837,17 @@ def main(force_route: Optional[str] = None) -> int:
837
837
  if finish:
838
838
  chains.append(f"[{last}]{finish}[ka_v]")
839
839
  last = "ka_v"
840
- cmd += ["-filter_complex", ";".join(chains), "-map", f"[{last}]",
840
+ # the conform of a VFR source is the fps filter on the picture as it is decoded (#512)
841
+ cmd += ["-filter_complex", cfr_at_input(";".join(chains), meta), "-map", f"[{last}]",
841
842
  "-map", f"0:a:{args.audio_stream}?"]
842
843
  elif fc:
843
844
  if finish:
844
845
  fc.append(f"[vout]{finish}[ka_v]")
845
- cmd += ["-filter_complex", ";".join(fc), "-map", "[ka_v]" if finish else "[vout]", "-map", f"0:a:{args.audio_stream}?"]
846
+ cmd += ["-filter_complex", cfr_at_input(";".join(fc), meta), "-map", "[ka_v]" if finish else "[vout]",
847
+ "-map", f"0:a:{args.audio_stream}?"]
846
848
  else:
847
- cmd += ["-vf", ",".join(p for p in (square, text, finish) if p) if finish else (square + "," if square else "") + text,
849
+ vf = ",".join(p for p in (square, text, finish) if p) if finish else (square + "," if square else "") + text
850
+ cmd += ["-vf", with_cfr(vf, meta),
848
851
  "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
849
852
  if alpha_codec:
850
853
  cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
package/scripts/loop.py CHANGED
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  import tempfile
34
34
 
35
- from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
35
+ from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
37
  from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
@@ -296,7 +296,9 @@ def main() -> int:
296
296
  die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
297
297
  stream_loop = math.ceil(target / unit) - 1
298
298
 
299
- # a variable-rate source is conformed by the output's own -fps_mode cfr -r (cfr_args)
299
+ # a variable-rate source is conformed to the rate cfr_args() delivers, by the fps filter in the
300
+ # picture's chain (below) and the output's -fps_mode cfr -r; the fps stage for a sound that
301
+ # outlasts the picture is for a constant-rate clip only, so the two never meet
300
302
  forced_rate = cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
301
303
  if args.boomerang:
302
304
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
@@ -319,6 +321,9 @@ def main() -> int:
319
321
  with open(listfile, "w", encoding="utf-8") as fh:
320
322
  fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
321
323
  vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
324
+ # the conform of a VFR source is the fps filter at the end of the picture's chain, at the
325
+ # rate cfr_args() below delivers (#512)
326
+ vtrim = with_cfr(vtrim, meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
322
327
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
323
328
  v_end = stream_starts(meta)[0] + (v.get("duration") or 0.0)
324
329
  if has_audio and not forced_rate and not fade_frames and src_dur - v_end > 0.5 / fps:
@@ -219,8 +219,11 @@ def main() -> int:
219
219
  args = ap.parse_args()
220
220
  apply_common(args)
221
221
  if args.auto_chapters: # the only mode that writes them
222
- guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, args.output))
223
- guard_sidecar(args.description_out, args.input, siblings=(args.output,))
222
+ # against the video this run writes, the default name too (A6, #465: --chapters-out at
223
+ # input_meta.mp4 wrote the chapter text over the video it had just written)
224
+ video_out = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
225
+ guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, video_out))
226
+ guard_sidecar(args.description_out, args.input, siblings=(video_out,))
224
227
 
225
228
  if args.chapters and args.clear_chapters:
226
229
  die("--chapters and --clear-chapters exclude each other")