ffmpeg-skill 1.4.4 → 1.4.6

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.
package/scripts/cut.py CHANGED
@@ -30,7 +30,11 @@ import sys
30
30
  import tempfile
31
31
  from typing import List, Tuple
32
32
 
33
- from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run
33
+ from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near
34
+
35
+ # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
+ # (reported so the caller can choose a lossless cut at one of them next time)
37
+ NEAREST_KEYFRAMES: list = []
34
38
 
35
39
 
36
40
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -113,11 +117,18 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
113
117
  info("stream copy failed, falling back to re-encode")
114
118
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
115
119
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
116
- if not reencode and tolerance >= 0 and not STATE["dry_run"]:
120
+ if not reencode and tolerance >= 0 and not STATE.dry_run:
117
121
  got = probe(dst).get("duration") or 0.0
118
122
  if abs(got - dur) > tolerance:
123
+ near = keyframes_near(src, start)
124
+ alt = ""
125
+ if near:
126
+ closest = min(near, key=lambda k: abs(k - start))
127
+ alt = (f"; for a lossless cut move --start to a keyframe (nearest: {closest:.3f}s"
128
+ + (f", others within 5 s: {', '.join(f'{k:.3f}' for k in near if k != closest)}" if len(near) > 1 else "") + ")")
129
+ NEAREST_KEYFRAMES.extend(k for k in near if k not in NEAREST_KEYFRAMES)
119
130
  info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
120
- f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
131
+ f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
121
132
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
122
133
  return reencode
123
134
 
@@ -134,7 +145,7 @@ def main() -> int:
134
145
  ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
135
146
  ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
136
147
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
137
- ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
148
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
138
149
  add_common(ap)
139
150
  args = ap.parse_args()
140
151
  apply_common(args)
@@ -198,7 +209,7 @@ def main() -> int:
198
209
  expected = sum(e - s for s, e in segments)
199
210
  precision = precision_of(meta, output, reencoded)
200
211
  got = result.get("duration")
201
- error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE["dry_run"] else None
212
+ error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE.dry_run else None
202
213
  # mode: "copy" (untouched lossless), "accurate" (--accurate was asked for), "hybrid" (asked for
203
214
  # lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
204
215
  mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
@@ -211,7 +222,8 @@ def main() -> int:
211
222
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
212
223
  requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
213
224
  duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
214
- mode=mode, keyframe_snapped=keyframe_snapped)
225
+ mode=mode, keyframe_snapped=keyframe_snapped,
226
+ nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None)
215
227
  return 0
216
228
 
217
229
 
@@ -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
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
26
26
 
27
27
  MODES = {"frame": 0, "field": 1}
28
28
  PARITIES = {"auto": -1, "tff": 0, "bff": 1}
@@ -41,7 +41,7 @@ def main() -> int:
41
41
  ap.add_argument("--audio-stream", type=int, default=0,
42
42
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
43
43
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
44
- ap.add_argument("--preset", default="medium", help="x264 preset")
44
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
45
45
  add_common(ap)
46
46
  args = ap.parse_args()
47
47
  apply_common(args)
@@ -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
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
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,
@@ -43,7 +43,7 @@ def main() -> int:
43
43
  ap.add_argument("--audio-stream", type=int, default=0,
44
44
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
45
45
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
46
- ap.add_argument("--preset", default="medium", help="x264 preset")
46
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
47
47
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
48
48
  add_common(ap)
49
49
  args = ap.parse_args()
package/scripts/export.py CHANGED
@@ -100,7 +100,7 @@ def main() -> int:
100
100
  video = list(p["video"])
101
101
  if args.crf is not None and "-crf" in video:
102
102
  video[video.index("-crf") + 1] = str(args.crf)
103
- if STATE["fast"] and "-preset" in video:
103
+ if STATE.fast and "-preset" in video:
104
104
  video[video.index("-preset") + 1] = "veryfast"
105
105
  cmd += video
106
106
  if args.preset != "copy":
package/scripts/fit.py CHANGED
@@ -38,7 +38,7 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, 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
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, 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
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -101,7 +101,7 @@ def main() -> int:
101
101
  r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
102
102
  e = ap.add_argument_group("encoding")
103
103
  e.add_argument("--crf", type=int, default=18)
104
- e.add_argument("--preset", default="medium")
104
+ e.add_argument("--preset", default="medium", choices=X264_PRESETS)
105
105
  e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
106
106
  add_common(ap)
107
107
  args = ap.parse_args()
@@ -159,7 +159,7 @@ def main() -> int:
159
159
  if target <= 0:
160
160
  die("target duration must be > 0")
161
161
  if args.method == "speed":
162
- if src_dur <= 0 and STATE["dry_run"]:
162
+ if src_dur <= 0 and STATE.dry_run:
163
163
  src_dur = target # planning against an intermediate that does not exist yet
164
164
  factor = src_dur / target # >1 = speed up
165
165
  if factor > args.max_speed or factor < 1 / args.max_speed:
@@ -175,7 +175,7 @@ def main() -> int:
175
175
  if has_audio:
176
176
  af.append(atempo_chain(factor))
177
177
  post += ["-t", f"{target:.3f}"]
178
- STATE["duration_hint"] = target
178
+ STATE.duration_hint = target
179
179
  else:
180
180
  if target < src_dur:
181
181
  start = (src_dur - target) / 2 if args.from_center else 0.0
package/scripts/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
22
+ 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
23
23
 
24
24
 
25
25
  def main() -> int:
@@ -31,7 +31,7 @@ def main() -> int:
31
31
  ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
32
32
  help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
33
33
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
34
- ap.add_argument("--preset", default="medium", help="x264 preset")
34
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
35
35
  add_common(ap)
36
36
  args = ap.parse_args()
37
37
  apply_common(args)
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -62,7 +62,7 @@ def main() -> int:
62
62
  ap.add_argument("--font-file")
63
63
  ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
64
64
  ap.add_argument("--crf", type=int, default=18)
65
- ap.add_argument("--preset", default="medium")
65
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
66
66
  add_common(ap)
67
67
  args = ap.parse_args()
68
68
  apply_common(args)
package/scripts/grid.py CHANGED
@@ -25,7 +25,7 @@ import argparse
25
25
  import os
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, \
28
+ from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, \
29
29
  escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
30
30
 
31
31
  LABEL_MARGIN = 10
@@ -50,7 +50,7 @@ def main() -> int:
50
50
  ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
51
51
  ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
52
52
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
53
- ap.add_argument("--preset", default="medium", help="x264 preset")
53
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
54
54
  add_common(ap)
55
55
  args = ap.parse_args()
56
56
  apply_common(args)
package/scripts/insert.py CHANGED
@@ -27,7 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
31
31
 
32
32
 
33
33
  def even(n: float) -> int:
@@ -47,7 +47,7 @@ def main() -> int:
47
47
  ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
48
48
  ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
49
49
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
50
- ap.add_argument("--preset", default="medium", help="x264 preset")
50
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
51
51
  add_common(ap)
52
52
  args = ap.parse_args()
53
53
  apply_common(args)
package/scripts/join.py CHANGED
@@ -23,7 +23,7 @@ import argparse
23
23
  import sys
24
24
  from typing import List
25
25
 
26
- from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color
26
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS
27
27
 
28
28
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
29
29
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -36,7 +36,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
36
36
  durs = [m.get("duration") or 0.0 for m in metas]
37
37
  d = args.duration if args.transition != "none" else 0.0
38
38
  for p, dur in zip(args.inputs, durs):
39
- if d and dur <= d * 2 and not STATE["dry_run"]:
39
+ if d and dur <= d * 2 and not STATE.dry_run:
40
40
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
41
41
  rates = [m["audio"].get("sample_rate") or 48000 for m in metas]
42
42
  chans = [m["audio"].get("channels") or 2 for m in metas]
@@ -70,7 +70,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
70
70
  expected = sum(durs) - d * (n - 1)
71
71
  r = probe(output)
72
72
  a = r.get("audio") or {}
73
- if not STATE["dry_run"]:
73
+ if not STATE.dry_run:
74
74
  if r.get("video"):
75
75
  die(f"{output} unexpectedly contains a video stream")
76
76
  if a.get("sample_rate") != rate or a.get("channels") != channels:
@@ -94,7 +94,7 @@ def main() -> int:
94
94
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
95
95
  ap.add_argument("--pad-color", default="black")
96
96
  ap.add_argument("--crf", type=int, default=18)
97
- ap.add_argument("--preset", default="medium")
97
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
98
98
  aud = ap.add_argument_group("audio-only inputs")
99
99
  aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
100
100
  aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
@@ -137,7 +137,7 @@ def main() -> int:
137
137
  durs = [m.get("duration") or 0.0 for m in metas]
138
138
  d = args.duration if args.transition != "none" else 0.0
139
139
  for p, dur in zip(args.inputs, durs):
140
- if d and dur <= d * 2 and not STATE["dry_run"]:
140
+ if d and dur <= d * 2 and not STATE.dry_run:
141
141
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
142
142
 
143
143
  cmd = ffmpeg_base()
package/scripts/loop.py CHANGED
@@ -18,7 +18,7 @@ import argparse
18
18
  import math
19
19
  import sys
20
20
 
21
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
21
+ from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -29,7 +29,7 @@ def main() -> int:
29
29
  group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
30
30
  group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
31
31
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
- ap.add_argument("--preset", default="medium", help="x264 preset")
32
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
33
  add_common(ap)
34
34
  args = ap.parse_args()
35
35
  apply_common(args)
@@ -23,7 +23,7 @@ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_c
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
- if STATE["dry_run"]:
26
+ if STATE.dry_run:
27
27
  return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
@@ -30,7 +30,7 @@ import tempfile
30
30
  from pathlib import Path
31
31
  from typing import Any, Dict, List, Optional
32
32
 
33
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE
33
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE, read_text_or_die
34
34
 
35
35
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
36
36
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -39,7 +39,7 @@ TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
39
39
  def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
40
40
  """`TIME TITLE` per line -> [{"start", "end", "title"}], validated: ascending starts, every
41
41
  start inside the file, the last chapter running to the file's end."""
42
- text = Path(path).read_text(encoding="utf-8")
42
+ text = read_text_or_die(path, "--chapters")
43
43
  entries: List[Dict[str, Any]] = []
44
44
  for n, raw in enumerate(text.splitlines(), start=1):
45
45
  line = raw.strip()
@@ -136,9 +136,9 @@ def main() -> int:
136
136
 
137
137
  result = probe(output, role="output")
138
138
  written = result.get("chapters") or []
139
- if chapters is not None and not STATE["dry_run"] and len(written) != len(chapters):
139
+ if chapters is not None and not STATE.dry_run and len(written) != len(chapters):
140
140
  die(f"wrote {len(written)} chapters but {len(chapters)} were asked for", kind="output")
141
- if args.clear_chapters and not STATE["dry_run"] and written:
141
+ if args.clear_chapters and not STATE.dry_run and written:
142
142
  die(f"{len(written)} chapters survived --clear-chapters", kind="output")
143
143
  info(f"wrote {output} ({len(written)} chapters, tags: {', '.join(sorted(tags)) or 'unchanged'}, streams copied)")
144
144
  emit(output, chapters=written, tags=result.get("tags") or {}, streams_copied=True)
@@ -29,7 +29,7 @@ import argparse
29
29
  import sys
30
30
  from typing import List, Tuple
31
31
 
32
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
32
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS
33
33
  from sync import measure_offset
34
34
 
35
35
 
@@ -69,7 +69,7 @@ def main() -> int:
69
69
  ap.add_argument("--height", type=int, help="output height (default: reference)")
70
70
  ap.add_argument("--fps", type=float, help="output fps (default: reference)")
71
71
  ap.add_argument("--crf", type=int, default=18)
72
- ap.add_argument("--preset", default="medium")
72
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
73
73
  add_common(ap)
74
74
  args = ap.parse_args()
75
75
  apply_common(args)
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -119,7 +119,7 @@ def main() -> int:
119
119
  txt.add_argument("--box-color", default="black@0.5")
120
120
  enc = ap.add_argument_group("encoding")
121
121
  enc.add_argument("--crf", type=int, default=18)
122
- enc.add_argument("--preset", default="medium")
122
+ enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
123
123
  add_common(ap)
124
124
  args = ap.parse_args()
125
125
  apply_common(args)
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -27,7 +27,7 @@ def main() -> int:
27
27
  ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
28
28
  ap.add_argument("--color", default="black", help="padding colour (default black)")
29
29
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
30
- ap.add_argument("--preset", default="medium", help="x264 preset")
30
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
31
31
  add_common(ap)
32
32
  args = ap.parse_args()
33
33
  apply_common(args)
package/scripts/redact.py CHANGED
@@ -20,7 +20,7 @@ Examples:
20
20
  import argparse
21
21
  import sys
22
22
 
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
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
24
24
 
25
25
 
26
26
  def main() -> int:
@@ -37,7 +37,7 @@ def main() -> int:
37
37
  ap.add_argument("--audio-stream", type=int, default=0,
38
38
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
39
39
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
40
- ap.add_argument("--preset", default="medium", help="x264 preset")
40
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
41
41
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
42
42
  add_common(ap)
43
43
  args = ap.parse_args()
package/scripts/render.py CHANGED
@@ -51,12 +51,11 @@ Examples:
51
51
  import argparse
52
52
  import json
53
53
  import os
54
- import subprocess
55
54
  import sys
56
55
  from pathlib import Path
57
56
  from typing import Any, Dict, List
58
57
 
59
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe
58
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool
60
59
 
61
60
  HERE = Path(__file__).resolve().parent
62
61
 
@@ -80,18 +79,26 @@ TEMPLATE = {
80
79
 
81
80
  def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
82
81
  """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
83
- cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args()
84
- info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
85
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
82
+ cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
83
+ info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
84
+ proc = run_tool(cmd)
86
85
  for line in proc.stderr.splitlines():
87
86
  if line.startswith("$ ") or line.startswith("[dry-run]"):
88
- STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
87
+ STATE.commands.append(line[2:] if line.startswith("$ ") else line)
89
88
  elif line.strip():
90
89
  info(" " + line)
90
+ try:
91
+ doc = json.loads(proc.stdout.strip() or "{}")
92
+ except ValueError:
93
+ doc = {}
91
94
  if proc.returncode != 0:
92
- die(f"{script} failed")
93
- out = proc.stdout.strip().splitlines()
94
- return out[-1] if out else ""
95
+ # Re-raise the stage's own failure: its kind, exit code and hint are what the caller
96
+ # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
97
+ err = doc.get("error") or {}
98
+ extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
99
+ die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
100
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
101
+ return str(doc.get("output") or "")
95
102
 
96
103
 
97
104
  def main() -> int:
@@ -144,7 +151,7 @@ def main() -> int:
144
151
  parts: List[str] = []
145
152
  for i, c in enumerate(clips):
146
153
  src = rel(c["src"])
147
- if not STATE["dry_run"]:
154
+ if not STATE.dry_run:
148
155
  probe(src)
149
156
  needs_cut = c.get("in") is not None or c.get("out") is not None
150
157
  part = str(work / f"clip{i:02d}.mp4")
@@ -159,7 +166,7 @@ def main() -> int:
159
166
  part = src
160
167
  if c.get("speed"):
161
168
  spd = float(c["speed"])
162
- dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
169
+ dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
163
170
  fitted = str(work / f"clip{i:02d}_speed.mp4")
164
171
  sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
165
172
  part = fitted
@@ -340,7 +347,7 @@ def main() -> int:
340
347
  sh("export.py", *argv)
341
348
  stages_done.append("export")
342
349
  else:
343
- if not STATE["dry_run"]:
350
+ if not STATE.dry_run:
344
351
  import shutil
345
352
  shutil.copyfile(current, output)
346
353
  info(f"copied final stage to {output}")
@@ -350,8 +357,8 @@ def main() -> int:
350
357
  ck = proj.get("check")
351
358
  check_result = None
352
359
  exit_code = 0
353
- if ck and ck.get("platform") and not STATE["dry_run"]:
354
- proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
360
+ if ck and ck.get("platform") and not STATE.dry_run:
361
+ proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"])
355
362
  try:
356
363
  check_result = json.loads(proc.stdout)
357
364
  except ValueError:
package/scripts/report.py CHANGED
@@ -13,13 +13,12 @@ import base64
13
13
  import html
14
14
  import json
15
15
  import os
16
- import subprocess
17
16
  import sys
18
17
  import tempfile
19
18
  from pathlib import Path
20
19
  from typing import Any, Dict, List, Optional
21
20
 
22
- from _common import STATE, add_common, apply_common, die, emit, info, probe
21
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die, run_tool
23
22
 
24
23
  HERE = Path(__file__).resolve().parent
25
24
 
@@ -27,15 +26,14 @@ HERE = Path(__file__).resolve().parent
27
26
  def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
27
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
28
  png = os.path.join(tmp, "sheet.png")
30
- proc = subprocess.run([sys.executable, str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png],
31
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
29
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png])
32
30
  if proc.returncode != 0 or not os.path.exists(png):
33
31
  return None
34
32
  return base64.b64encode(Path(png).read_bytes()).decode("ascii")
35
33
 
36
34
 
37
35
  def loudness(path: str) -> Dict[str, Any]:
38
- proc = subprocess.run([sys.executable, str(HERE / "loudness.py"), path, "--measure-only"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
36
+ proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"])
39
37
  try:
40
38
  d = json.loads(proc.stdout)
41
39
  return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
@@ -44,11 +42,18 @@ def loudness(path: str) -> Dict[str, Any]:
44
42
 
45
43
 
46
44
  def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
47
- proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
45
+ proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
48
46
  try:
49
- return json.loads(proc.stdout)
47
+ doc = json.loads(proc.stdout)
50
48
  except ValueError:
49
+ doc = None
50
+ if not isinstance(doc, dict) or "checks" not in doc:
51
+ # check.py could not run at all (missing ffmpeg, unreadable file): its failure document
52
+ # has no rows to render. A failed *verification* still carries its rows and is shown.
53
+ reason = ((doc or {}).get("error") or {}).get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1]
54
+ info(f"check.py could not run: {reason[:200]}")
51
55
  return None
56
+ return doc
52
57
 
53
58
 
54
59
  def fmt_dur(sec: Optional[float]) -> str:
@@ -99,8 +104,8 @@ def main() -> int:
99
104
  sheets["before"] = sheet_b64(args.before)
100
105
  if after.get("video"):
101
106
  sheets["after"] = sheet_b64(args.after)
102
- commands = Path(args.commands).read_text(encoding="utf-8").splitlines() if args.commands else []
103
- notes = Path(args.notes).read_text(encoding="utf-8") if args.notes else ""
107
+ commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
108
+ notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
104
109
  title = args.title or f"Delivery report — {Path(args.after).name}"
105
110
  output = args.output or str(Path(args.after).with_name(Path(args.after).stem + "_report.html"))
106
111
 
@@ -14,7 +14,7 @@ Examples:
14
14
  import argparse
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
17
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -23,7 +23,7 @@ def main() -> int:
23
23
  ap.add_argument("-o", "--output", help="output file (default: <name>_reverse.<ext>)")
24
24
  ap.add_argument("--no-audio", action="store_true", help="drop audio instead of reversing it")
25
25
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
26
- ap.add_argument("--preset", default="medium", help="x264 preset")
26
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
27
27
  add_common(ap)
28
28
  args = ap.parse_args()
29
29
  apply_common(args)
package/scripts/scenes.py CHANGED
@@ -21,11 +21,10 @@ import argparse
21
21
  import math
22
22
  import os
23
23
  import re
24
- import struct
25
24
  import sys
26
25
  from typing import Dict, List, Tuple
27
26
 
28
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis
27
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
29
28
 
30
29
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
31
30
 
@@ -87,19 +86,9 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
87
86
 
88
87
 
89
88
  def audio_envelope(path: str, step_s: float) -> List[float]:
90
- ffmpeg = require_tool("ffmpeg")
91
- proc = run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
92
- check=False, text=False)
93
- n = len(proc.stdout) // 2
94
- if n == 0:
95
- return []
96
- samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
97
- step = max(1, int(8000 * step_s))
98
- env = []
99
- for i in range(0, n, step):
100
- block = samples[i:i + step]
101
- env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
102
- return env
89
+ """RMS level per step_s window at 8 kHz, absolute (a loud scene scores higher); [] when the
90
+ audio cannot be decoded (the cut scoring then runs on the picture alone)."""
91
+ return rms_envelope(decode_pcm_mono(path, 8000, check=False), int(8000 * step_s))
103
92
 
104
93
 
105
94
  def main() -> int:
@@ -19,7 +19,7 @@ import sys
19
19
  import tempfile
20
20
  from pathlib import Path
21
21
 
22
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
22
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
23
23
 
24
24
 
25
25
  def even(n: float) -> int:
@@ -43,7 +43,7 @@ def main() -> int:
43
43
  ap.add_argument("--width", type=int, help="output width in px; with --height also given, both are used directly")
44
44
  ap.add_argument("--height", type=int, help="output height in px; with --width also given, both are used directly")
45
45
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
46
- ap.add_argument("--preset", default="medium", help="x264 preset")
46
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
47
47
  add_common(ap)
48
48
  args = ap.parse_args()
49
49
  apply_common(args)