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/README.md +5 -3
- package/SKILL.md +18 -27
- package/mcp/server.py +16 -2
- package/package.json +4 -2
- package/references/ci-platform-pitfalls.md +15 -0
- package/scripts/_common.py +130 -20
- package/scripts/audio.py +3 -3
- package/scripts/background.py +2 -2
- package/scripts/batch.py +5 -6
- package/scripts/broll.py +5 -4
- package/scripts/caption.py +2 -2
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +18 -6
- package/scripts/deinterlace.py +2 -2
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +4 -4
- package/scripts/freeze.py +2 -2
- package/scripts/graphics.py +2 -2
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +2 -2
- package/scripts/join.py +5 -5
- package/scripts/loop.py +2 -2
- package/scripts/loudness.py +1 -1
- package/scripts/metadata.py +4 -4
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +2 -2
- package/scripts/pad.py +2 -2
- package/scripts/redact.py +2 -2
- package/scripts/render.py +21 -14
- package/scripts/report.py +14 -9
- package/scripts/reverse.py +2 -2
- package/scripts/scenes.py +4 -15
- package/scripts/sequence.py +2 -2
- package/scripts/silence.py +15 -2
- package/scripts/speedramp.py +2 -2
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +3 -3
- package/scripts/straighten.py +2 -2
- package/scripts/sync.py +4 -19
- package/scripts/waveform.py +2 -2
- package/references/process-pitfalls.md +0 -229
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
|
|
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
|
|
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
|
|
package/scripts/deinterlace.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_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)
|
package/scripts/denoise.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|
|
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", "-"]
|
package/scripts/metadata.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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)
|
package/scripts/multicam.py
CHANGED
|
@@ -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)
|
package/scripts/overlay.py
CHANGED
|
@@ -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 = [
|
|
84
|
-
info("→ " + " ".join(os.path.basename(c) if i <
|
|
85
|
-
proc =
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
354
|
-
proc =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
45
|
+
proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
|
|
48
46
|
try:
|
|
49
|
-
|
|
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 =
|
|
103
|
-
notes =
|
|
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
|
|
package/scripts/reverse.py
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
package/scripts/sequence.py
CHANGED
|
@@ -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)
|