ffmpeg-skill 3.8.12 → 3.8.14

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.
Files changed (44) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +1 -1
  3. package/docs/contract.md +11 -5
  4. package/mcp/server.py +0 -1
  5. package/package.json +5 -2
  6. package/references/flags.md +2 -2
  7. package/references/scripts.md +72 -27
  8. package/scripts/_common/asr.py +1 -1
  9. package/scripts/_common/color.py +7 -2
  10. package/scripts/_common/decision.py +40 -8
  11. package/scripts/_common/emit.py +38 -10
  12. package/scripts/_common/probe.py +34 -6
  13. package/scripts/_common/runner.py +76 -7
  14. package/scripts/_contract.py +24 -10
  15. package/scripts/_platforms.py +0 -5
  16. package/scripts/_smartcut.py +23 -5
  17. package/scripts/audio.py +86 -28
  18. package/scripts/background.py +1 -1
  19. package/scripts/caption.py +2 -3
  20. package/scripts/check.py +3 -3
  21. package/scripts/color.py +7 -1
  22. package/scripts/crop.py +3 -2
  23. package/scripts/cropdetect.py +1 -1
  24. package/scripts/cut.py +50 -17
  25. package/scripts/export.py +39 -6
  26. package/scripts/fit.py +4 -3
  27. package/scripts/freeze.py +1 -1
  28. package/scripts/graphics.py +2 -3
  29. package/scripts/insert.py +1 -1
  30. package/scripts/look.py +9 -4
  31. package/scripts/loop.py +3 -3
  32. package/scripts/loudness.py +20 -12
  33. package/scripts/multicam.py +56 -15
  34. package/scripts/overlay.py +1 -1
  35. package/scripts/probe.py +13 -2
  36. package/scripts/proxy.py +11 -2
  37. package/scripts/redact.py +5 -1
  38. package/scripts/scenes.py +82 -21
  39. package/scripts/silence.py +62 -16
  40. package/scripts/sphere.py +23 -3
  41. package/scripts/stabilize.py +5 -2
  42. package/scripts/sync.py +11 -6
  43. package/scripts/verify.py +82 -20
  44. package/scripts/waveform.py +12 -2
@@ -37,17 +37,16 @@ import json
37
37
  import os
38
38
  import re
39
39
  import sys
40
- import unicodedata
41
40
  from pathlib import Path
42
41
  from typing import Any, Dict, List, Optional, Tuple
43
42
 
44
43
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
44
  resolve as resolve_platform)
46
- from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
45
+ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
47
46
  strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
48
47
  from _common.emit import refuse # noqa: E402
49
48
  from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
50
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, 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, label_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_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, 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, 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
51
50
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
52
51
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
53
52
  # shares them). They stay caption.py's public names -- every caller and test that reached for
package/scripts/check.py CHANGED
@@ -50,10 +50,10 @@ from typing import Any, Dict, List, Optional, Tuple
50
50
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
51
51
  from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
52
52
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
53
- from _common.emit import file_identity
53
+ from _common.emit import file_identity, own_length
54
54
  from _common.probe import PROBED_AUDIO
55
55
  from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
56
- from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
56
+ from _common import STATE, add_common, apply_common, die, emit, probe, require_tool, run_analysis, dry_run_input_pending
57
57
 
58
58
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
59
59
  # render.py templates all read it, so a platform's loudness spec is stated once. Only the
@@ -618,7 +618,7 @@ def main() -> int:
618
618
  row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
619
619
  reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
620
620
  elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
621
- and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
621
+ and shorter_than_a_loudness_block(own_length(a) or dur_read)):
622
622
  # A257: shorter than one 400 ms gating block: a true peak but no integrated
623
623
  # loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
624
624
  # the sound's length: a longer track whose every block is under the gate reads
package/scripts/color.py CHANGED
@@ -581,7 +581,13 @@ def main() -> int:
581
581
  else:
582
582
  vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
583
583
  sdr_path = "tonemap"
584
- sdr_note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
584
+ # the note names what hdr_to_sdr_chain read the source as: its own tags, PQ / BT.2020
585
+ # only where a tag is missing. Through 3.8.8 any non-HDR file was called "untagged
586
+ # input as PQ", a BT.709-tagged one included, which the chain linearised as BT.709 (A161)
587
+ read_as = v.get("hdr_format") or (
588
+ f"a non-HDR input read as transfer {v.get('color_transfer') or 'smpte2084 (untagged, taken as PQ)'}, "
589
+ f"primaries {v.get('color_primaries') or 'bt2020 (untagged)'} (--force)")
590
+ sdr_note = f"tone-mapped {read_as} to SDR BT.709 with {args.tonemap}"
585
591
  info(f"note: {sdr_note}")
586
592
  sdr_extra = {"sdr_path": sdr_path, "notes": [sdr_note]}
587
593
  output = args.output or default_output(args.input, "sdr")
package/scripts/crop.py CHANGED
@@ -12,7 +12,8 @@ The rectangle must lie entirely inside the source frame after accounting for
12
12
  any display rotation, and --width/--height must be even (required for 4:2:0
13
13
  chroma subsampling, the pixel format every encoder here uses) -- given values
14
14
  are validated and refused, never silently rounded, since a caller-specified
15
- rectangle should do exactly what was asked or fail loudly.
15
+ rectangle should do exactly what was asked or fail loudly. An odd --x/--y is
16
+ taken as given (the crop starts on that pixel), not moved to the chroma grid.
16
17
 
17
18
  Examples:
18
19
  python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
@@ -63,7 +64,7 @@ def main() -> int:
63
64
 
64
65
  output = args.output or default_output(args.input, "crop")
65
66
  # exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
66
- # and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
67
+ # and the rectangle written was one pixel left of / above the one asked for (A170's sibling, A157)
67
68
  vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
68
69
  if square_pixels(meta["video"]):
69
70
  vf.insert(0, square_pixels(meta["video"]))
@@ -33,7 +33,7 @@ import sys
33
33
  from collections import Counter
34
34
  from typing import Dict, List, Optional, Tuple
35
35
 
36
- from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
36
+ from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
37
37
  from _common.emit import note, warn
38
38
  from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
39
39
 
package/scripts/cut.py CHANGED
@@ -56,7 +56,7 @@ import os
56
56
  import sys
57
57
  import tempfile
58
58
  import zlib
59
- from typing import List, Tuple
59
+ from typing import List, Optional, Tuple
60
60
 
61
61
  from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
62
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
@@ -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, MissingFpsError, 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, 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] = []
@@ -81,12 +81,27 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
81
81
  return time_arg(value, flag, fps)
82
82
 
83
83
 
84
- def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None) -> List[Tuple[float, float]]:
84
+ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None,
85
+ picture: Optional[Tuple[float, float]] = None,
86
+ dropped: Optional[List[Tuple[float, float]]] = None) -> List[Tuple[float, float]]:
85
87
  """--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
86
88
  clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
87
- piece anyone asked to keep. Refuses a removal that would leave nothing."""
89
+ piece anyone asked to keep. With `picture` (where the video starts and ends, for a video
90
+ output), neither is a leftover that holds less than one frame of it: sound before the first
91
+ frame or after the last (a track that outlasts its picture) has no picture to cut, and was
92
+ kept and then refused as a segment the caller never gave (#462 A51). Such leftovers are added
93
+ to `dropped`. Refuses a removal that would leave nothing."""
88
94
  least = 1.0 / fps if fps else 0.01
89
95
  keep, cursor = [], 0.0
96
+
97
+ def add(a: float, b: float) -> None:
98
+ if b - a < least:
99
+ return
100
+ if picture is not None and min(b, picture[1]) - max(a, picture[0]) < least - 1e-6:
101
+ if dropped is not None:
102
+ dropped.append((a, b))
103
+ return
104
+ keep.append((a, b))
90
105
  outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
91
106
  if outside:
92
107
  die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
@@ -94,12 +109,14 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
94
109
  for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
95
110
  if e <= cursor:
96
111
  continue
97
- if s - cursor >= least:
98
- keep.append((cursor, s))
112
+ add(cursor, s)
99
113
  cursor = max(cursor, e)
100
- if total - cursor >= least:
101
- keep.append((cursor, total))
114
+ add(cursor, total)
102
115
  if not keep:
116
+ if dropped:
117
+ die(f"--remove leaves only sound with no picture ({', '.join(f'{a:.3f}-{b:.3f}s' for a, b in dropped)}, "
118
+ f"outside the video's {picture[0]:.3f}-{picture[1]:.3f}s); nothing with a picture would be left to keep. "
119
+ "Write the sound out with an audio -o (e.g. -o part.wav) instead", kind="input")
103
120
  die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
104
121
  return keep
105
122
 
@@ -277,11 +294,12 @@ def source_skew(src: str, meta: dict, t: float) -> float:
277
294
 
278
295
  def video_end(src: str, meta: dict):
279
296
  """Where the video stream ends on the cut's clock (seconds from the file's start), or None when
280
- the stream's length is unknown."""
281
- v = meta.get("video") or {}
282
- if not v.get("duration"):
283
- return None
284
- return (v.get("start_time") or 0.0) + v["duration"] - file_origin(src)
297
+ the stream's length is unknown. A Matroska/WebM stream states no duration, only its DURATION
298
+ tag, which is already that end (own_end()); read from `duration` alone, a .mkv had no picture
299
+ end and --remove kept a sound-only leftover past it."""
300
+ from _common.emit import own_end
301
+ end = own_end(meta.get("video"))
302
+ return None if end is None else end - file_origin(src)
285
303
 
286
304
 
287
305
  _SEEK_SHIFTS: dict = {}
@@ -893,9 +911,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
893
911
  if is_audio_output(dst) or not meta.get("video"):
894
912
  # -ss before -i seeks, then decoding discards samples up to the exact start
895
913
  # (accurate_seek); atrim bounds the decoded stream to the requested length at sample
896
- # resolution.
914
+ # resolution. The input seek counts from the file's start, so a sound that starts after
915
+ # it (late against its picture) arrives at its offset from `start`; it is padded with
916
+ # silence from 0, as the --segments re-cut pads it (av_trim), not rebased to its own
917
+ # first sample, which moved it earlier and made the cut shorter than asked (#462 A46)
897
918
  cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
898
- cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
919
+ cmd += ["-af", f"atrim=end={dur:.6f},aresample=async=1:first_pts=0"]
899
920
  else:
900
921
  # Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
901
922
  # discards it from both streams alike. An input seek straight to `start` can land (the
@@ -1515,6 +1536,7 @@ def main() -> int:
1515
1536
  forced.append("codec")
1516
1537
 
1517
1538
  fps = (meta.get("video") or {}).get("fps")
1539
+ remove_notes: List[str] = []
1518
1540
  if args.remove:
1519
1541
  if args.segments or args.end or args.duration or args.start is not None:
1520
1542
  die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
@@ -1525,7 +1547,18 @@ def main() -> int:
1525
1547
  if not total:
1526
1548
  die(f"--remove needs the input's duration, which {args.input} does not report"
1527
1549
  + (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
1528
- args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
1550
+ picture = None
1551
+ if (meta.get("video") or {}).get("fps") and not is_audio_output(args.output or "") and not dry_run_input_pending(args.input):
1552
+ vend = video_end(args.input, meta)
1553
+ if vend is not None:
1554
+ picture = ((meta["video"].get("start_time") or 0.0) - file_origin(args.input), vend)
1555
+ sound_only: List[Tuple[float, float]] = []
1556
+ args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(
1557
+ parse_segments(args.remove, fps, clamp=True), total, fps, picture, sound_only))
1558
+ for a, b in sound_only:
1559
+ remove_notes.append(f"--remove left {a:.3f}-{b:.3f}s, which holds sound but no frame of the picture "
1560
+ f"(the video runs {picture[0]:.3f}-{picture[1]:.3f}s); it is not kept")
1561
+ info(remove_notes[-1])
1529
1562
  elif args.segments and (args.end or args.duration or args.start is not None):
1530
1563
  # A50: the single-range flags were accepted beside --segments and not read, where --remove
1531
1564
  # refuses the same combination
@@ -1588,7 +1621,7 @@ def main() -> int:
1588
1621
  # clean copy). clip_length's rule, as join.py uses it: less than a frame past -> end the
1589
1622
  # segment with its picture; more -> hold the last frame for the sound, which only a re-cut
1590
1623
  # can do. The last segment is left as the source had it: nothing is placed after it.
1591
- join_notes: List[str] = []
1624
+ join_notes: List[str] = list(remove_notes)
1592
1625
  hold_needed = False
1593
1626
  vend = (video_end(args.input, meta) if len(segments) > 1 and video.get("fps") and not is_audio_output(output)
1594
1627
  and not dry_run_input_pending(args.input) else None)
package/scripts/export.py CHANGED
@@ -43,6 +43,7 @@ import re
43
43
  import os
44
44
  import sys
45
45
  import tempfile
46
+ from fractions import Fraction
46
47
  from pathlib import Path
47
48
  from typing import Dict, List, Optional
48
49
 
@@ -51,8 +52,8 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
51
52
  from _common.decision import hw_preset_video, restate_last_swap
52
53
  from _common.emit import note, record_fallback, record_skip, warn
53
54
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
54
- from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
- from _common.emit import file_identity, input_length
55
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
56
+ from _common.emit import file_identity, input_length, own_length
56
57
  from check import SPECS as PLATFORMS, measure_loudness
57
58
  from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
58
59
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
@@ -254,7 +255,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
254
255
  pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
255
256
  # the video streams' lengths: the fill's container also counts its sound, which may run longer
256
257
  # than the picture, and the key has none
257
- pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
258
+ pair["duration"] = [round(own_length(v) or 0, 3) for v in (fv, kv)]
258
259
  same = all(a == b and a not in (None, 0) for a, b in pair.values())
259
260
  # the two written files against each other: no expected value, so no provenance (#403: it said
260
261
  # "output_measurement", which is not one of the contract's values)
@@ -442,6 +443,32 @@ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; re
442
443
  SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
443
444
 
444
445
 
446
+ def _platform_rate_args(preset: str, meta: Dict) -> List[str]:
447
+ """A platform preset that keeps the source's frame rate (the youtube family) conforms a source
448
+ faster than the platform's fps_max to the source rate divided by the smallest whole number that
449
+ brings it within the limit (120 -> 60, 119.88 -> 59.94, 100 -> 50), so every frame of the
450
+ output is one of the source's own. Through 3.8.8 a 120 fps source kept 120 fps and the
451
+ platform's own check FAILed the export (audit A115). [] when the preset has no limit or the
452
+ source is within it (cfr_args() then decides, as before)."""
453
+ platform = PLATFORM_OF.get(preset)
454
+ fps_max = (PLATFORMS.get(platform) or {}).get("fps_max") if platform else None
455
+ v = meta.get("video") or {}
456
+ src = v.get("fps")
457
+ if not fps_max or not src or src <= fps_max + 0.01:
458
+ return []
459
+ try:
460
+ exact = Fraction(v.get("avg_frame_rate") or "0")
461
+ except (ValueError, ZeroDivisionError):
462
+ exact = Fraction(0)
463
+ if not exact or abs(float(exact) - src) > 0.01:
464
+ exact = Fraction(src).limit_denominator(1001)
465
+ n = math.ceil(float(exact) / (fps_max + 0.01))
466
+ rate = exact / n
467
+ note(f"source is {src:g} fps, above {platform}'s limit of {fps_max:g}: exported at {float(rate):.6g} fps, "
468
+ f"one source frame in {n}")
469
+ return ["-fps_mode", "cfr", "-r", str(rate.numerator) if rate.denominator == 1 else f"{rate.numerator}/{rate.denominator}"]
470
+
471
+
445
472
  def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
446
473
  """--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
447
474
  for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
@@ -669,7 +696,7 @@ def main() -> int:
669
696
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
670
697
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
671
698
  if "-r" not in video:
672
- cmd += cfr_args(meta)
699
+ cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
673
700
  if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
674
701
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
675
702
  if not p.get("intermediate"):
@@ -741,7 +768,7 @@ def main() -> int:
741
768
  m = measure_loudness(output)
742
769
  written_audio = probe(output, role="output").get("audio") or {}
743
770
  if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
744
- and shorter_than_a_loudness_block(written_audio.get("duration") or written_audio.get("tagged_duration"))):
771
+ and shorter_than_a_loudness_block(own_length(written_audio))):
745
772
  # A257: too short for a gating block is not silent (it has a measured peak); a longer
746
773
  # track under the gate is not "too short" and takes the path it took before
747
774
  _loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
@@ -767,7 +794,13 @@ def main() -> int:
767
794
  # a private name: <stem>_loudnorm.<ext> is loudness.py's own default output, so a
768
795
  # real file of that name next to the export was overwritten and renamed away (review 7)
769
796
  tmp = str(Path(output).with_name(f".{Path(output).stem}.normalize-{os.getpid()}{Path(output).suffix}"))
770
- proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"] + child_args())
797
+ # under --max-mb the sound is re-encoded at the bitrate the size budget was built on
798
+ # (the preset's); loudness.py's own 192k, raised to 256k/320k on an overshoot, put
799
+ # the file over the limit it had been encoded to meet (through 3.8.8, audit A112)
800
+ pinned = (["--audio-bitrate", p["audio"][p["audio"].index("-b:a") + 1]]
801
+ if args.max_mb is not None and "-b:a" in p["audio"] else [])
802
+ proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"]
803
+ + pinned + child_args())
771
804
  try:
772
805
  child = json.loads(proc.stdout)
773
806
  except ValueError:
package/scripts/fit.py CHANGED
@@ -34,14 +34,13 @@ Examples:
34
34
  python3 fit.py input.mp4 --flip h # mirror horizontally
35
35
  """
36
36
  import argparse
37
- import math
38
37
  import sys
39
38
  from fractions import Fraction
40
39
  from typing import List
41
40
 
42
41
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
43
42
  from _common.timeline import exact_rate, output_timecode_args
44
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, 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, 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
45
44
  from _common.emit import input_length
46
45
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
47
46
 
@@ -227,7 +226,9 @@ def main() -> int:
227
226
  # after it (a B-frame HEVC source) and lose the frames before it, so the first
228
227
  # picture would not be the one the start timecode below names
229
228
  m = min(SEEK_MARGIN, round(start, 3))
230
- pre_input += ["-ss", f"{start - m:.3f}"]
229
+ # a trim from the start seeks nowhere: no -ss, so the timeline stays where it is and
230
+ # a subtitle/data track is kept below (`-ss 0.000` dropped it, A158)
231
+ pre_input += ["-ss", f"{start - m:.3f}"] if round(start, 3) > 0 else []
231
232
  # without sound the -t only counts frames (picture_output_t). Not from the centre:
232
233
  # there the output's clock starts at the seek, between two frames, and the encoder
233
234
  # rounds each frame to its own tick
package/scripts/freeze.py CHANGED
@@ -20,7 +20,7 @@ import argparse
20
20
  import sys
21
21
 
22
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, MissingFpsError, time_arg, fmt_secs, run
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
24
24
  from _common.emit import input_length
25
25
 
26
26
 
@@ -38,11 +38,11 @@ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
38
38
  from _common.drawtext import shaping_refusal
39
39
  from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
40
40
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
41
- ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
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,
43
43
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
44
44
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
45
- EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
45
+ EMOJI_ASSET_HINT, text_width_em, wrap_text, WRAP_MODES,
46
46
  SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
47
47
  from _common.emit import note, warn
48
48
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
@@ -429,7 +429,6 @@ def main(force_route: Optional[str] = None) -> int:
429
429
  el["text"] = text
430
430
  elements.append(el)
431
431
 
432
- extra_inputs: List[str] = []
433
432
  fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
434
433
  if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
435
434
  die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
package/scripts/insert.py CHANGED
@@ -28,7 +28,7 @@ import math
28
28
  import sys
29
29
 
30
30
  from _common import still_input_args # noqa: E402
31
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
31
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
32
32
  from _common.emit import note
33
33
  from _common.probe import display_size, square_pixels, square_pixels_note
34
34
 
package/scripts/look.py CHANGED
@@ -19,7 +19,7 @@ from typing import List, Optional
19
19
 
20
20
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
21
21
  from _common import still_input_args # noqa: E402
22
- from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
22
+ from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, probe, run, run_analysis, time_arg
23
23
  from _common import drawtext_fontfile # noqa: E402
24
24
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
@@ -30,6 +30,8 @@ from _common.emit import input_length # noqa: E402
30
30
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
31
31
  # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
32
32
  # is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
33
+ # the extensions an -o is taken verbatim with (one picture); any other -o is a stem
34
+ IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
33
35
  GUIDES = {"action": 0.93, "title": 0.90}
34
36
  GUIDE_43 = {"action": 0.90, "title": 0.80}
35
37
  GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
@@ -319,7 +321,7 @@ def main() -> int:
319
321
  csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
320
322
  for idx, t in enumerate(args.at):
321
323
  sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
322
- if args.output and len(args.at) == 1:
324
+ if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
323
325
  out = args.output # one frame, one named image file: the caller's -o is the contract
324
326
  else:
325
327
  # several frames, or -o given as a stem/prefix: an occurrence index keeps every
@@ -352,7 +354,7 @@ def main() -> int:
352
354
  sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
353
355
  if dur and sec > dur:
354
356
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
355
- if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
357
+ if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
356
358
  out = args.output # one frame, one named image file: the caller's -o is the contract
357
359
  else:
358
360
  # several frames, or -o given as a stem/prefix without an image extension
@@ -382,7 +384,10 @@ def main() -> int:
382
384
  info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
383
385
  step = dur / n
384
386
  tile_w = max(2, (args.width // cols) // 2 * 2)
385
- out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
387
+ # -o without an image extension is a stem here too (<stem>_sheet.png), as for --at and
388
+ # --compare: given verbatim, ffmpeg could not choose a muxer for it (kind ffmpeg, A159)
389
+ out = (args.output if args.output and Path(args.output).suffix.lower() in IMAGE_EXTS
390
+ else os.path.join(outdir, f"{(args.output and Path(args.output).stem) or stem}_sheet.png"))
386
391
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
387
392
  # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
388
393
  seek = 0.0 if frames_avail < 4 else step / 2
package/scripts/loop.py CHANGED
@@ -32,11 +32,11 @@ 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, parse_time, 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, 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
39
- from _common.emit import input_length
39
+ from _common.emit import input_length, own_length
40
40
 
41
41
 
42
42
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -131,7 +131,7 @@ def _conformed_frames(v, rate: float, src_dur: float) -> int:
131
131
  its length are counted in these, or a repeat is a frame or more off the length planned."""
132
132
  # the video stream's length, else Matroska's tag, else the container's (main() refuses a source
133
133
  # without one): never the stored frame count, which is what fps= does not keep
134
- d = v.get("duration") or v.get("tagged_duration") or src_dur
134
+ d = own_length(v) or src_dur
135
135
  return int(round(float(d) * rate))
136
136
 
137
137
 
@@ -24,8 +24,9 @@ from _platforms import TP_TOLERANCE_DB
24
24
  AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
25
25
  LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
26
26
  from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
27
- from _common.emit import refuse
27
+ from _common.emit import own_length, refuse
28
28
  from _common.runner import ffmpeg_failure_extra
29
+ from _common.decision import LOSSLESS_AUDIO_EXT
29
30
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
30
31
 
31
32
 
@@ -113,15 +114,15 @@ def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
113
114
 
114
115
  def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
115
116
  """One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
116
- af = (
117
- f"loudnorm=I={lufs}:TP={tp_max}:LRA={args.lra}"
118
- f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
119
- f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
120
- )
121
117
  sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
122
118
  ext = os.path.splitext(output)[1].lower()
123
119
  bitrate_pinned = args.audio_bitrate is not None
124
- bitrate = args.audio_bitrate or "192k"
120
+ # an AAC audio extension starts at its own rate (256k for .m4a/.aac), which is what it was
121
+ # written at: a pinned --audio-bitrate and the raised retries below never reached it, so the
122
+ # retries re-encoded at 256k and audio_bitrate_used said 192k, then 256k and 320k (A143)
123
+ codec = AUDIO_CODECS.get(ext, [])
124
+ own_rate = codec[codec.index("-b:a") + 1] if "aac" in codec and "-b:a" in codec else None
125
+ bitrate = args.audio_bitrate or own_rate or "192k"
125
126
 
126
127
  dropped_streams = False
127
128
  # loudnorm emits its last block whole, and through the AAC encoder that came out as 0.1 s more
@@ -142,7 +143,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
142
143
  af += f",atrim=duration={float(in_audio):.6f}"
143
144
  cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
144
145
  if ext in AUDIO_CODECS or not meta.get("video"):
145
- cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
146
+ # the rate reaches an audio extension's codec when it was pinned or is AAC's (an
147
+ # .mp3/.ogg/.opus left alone keeps its own setting, as it always has)
148
+ cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate if ext not in AUDIO_CODECS or bitrate_pinned or own_rate else None)
146
149
  else:
147
150
  cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
148
151
  if ext in (".mp4", ".mov", ".m4v"):
@@ -185,7 +188,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
185
188
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {lufs} LUFS, TP <= {tp_max:g})")
186
189
  result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
187
190
  result["tp_ceiling_used"] = round(ceiling, 2)
188
- if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
191
+ if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext] or bitrate_pinned:
192
+ # a pinned rate reaches an .mp3/.ogg/.opus codec too (A143); unpinned, those keep their own
189
193
  result["audio_bitrate_used"] = bitrate
190
194
  result["encodes"] = rounds + 1
191
195
  # the mode of the last encode: its ceiling is the one the file was made with
@@ -277,7 +281,8 @@ def main() -> int:
277
281
  ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
278
282
  ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
279
283
  ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
280
- ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
284
+ ap.add_argument("--audio-bitrate", default=None, help="audio bitrate: AAC's in a video container (default 192k), and the codec's of a lossy audio output (.m4a/.aac default 256k, .mp3/.ogg/.opus their own setting); "
285
+ "refused for .wav/.flac. Raised to 256k/320k for AAC only when the encoder overshoots the true-peak ceiling and you did not pin it")
281
286
  ap.add_argument("--versions", metavar="LIST",
282
287
  help="write one normalised file per target from this master, each measured after: a comma list of "
283
288
  + ", ".join(f"{n} ({i:g} LUFS / {t:g} dBTP)" for n, (i, t) in VERSION_TARGETS.items())
@@ -303,6 +308,10 @@ def main() -> int:
303
308
  meta = probe(args.input)
304
309
  if not meta.get("audio"):
305
310
  die("input has no audio stream")
311
+ out_ext = os.path.splitext(args.output or default_output(args.input, "loudnorm"))[1].lower()
312
+ if args.audio_bitrate is not None and not args.measure_only and out_ext in LOSSLESS_AUDIO_EXT:
313
+ # A143: the rate was dropped without a word; a lossless codec has none to set
314
+ die(f"--audio-bitrate has no effect on a lossless {out_ext} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
306
315
  if plan is not None and not args.measure_only:
307
316
  # each version measures the master against its own target (loudnorm's offset depends on
308
317
  # it): a pass against -I/--tp first would be one more full decode whose result is unused
@@ -349,8 +358,7 @@ def audio_length(meta: dict) -> "Optional[float]":
349
358
  """The sound's own length: the stream's duration, else Matroska's DURATION tag. Never the
350
359
  container's, which is the picture's when the picture runs longer (and would fail a correct
351
360
  output against an expected length the sound never had)."""
352
- audio = meta.get("audio") or {}
353
- return audio.get("duration") or audio.get("tagged_duration")
361
+ return own_length(meta.get("audio"))
354
362
 
355
363
 
356
364
  def _kbps(value: str) -> int: