ffmpeg-skill 0.10.0 → 0.12.5
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 +70 -10
- package/SKILL.md +89 -12
- package/bin/install.js +15 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +2 -0
- package/package.json +2 -2
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +139 -12
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +255 -14
- package/scripts/_contract.py +276 -13
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +106 -24
- package/scripts/color.py +129 -30
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +73 -14
- package/scripts/graphics.py +18 -7
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/look.py +13 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +78 -12
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +8 -2
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +101 -0
- package/scripts/sync.py +1 -1
package/scripts/caption.py
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Burn SRT/ASS subtitles into a video, or
|
|
2
|
+
"""Burn SRT/ASS subtitles into a video, or mux one in as a soft (toggleable)
|
|
3
|
+
subtitle stream, or generate an SRT from plain text.
|
|
3
4
|
|
|
4
5
|
Styling (font, size, colour, outline, position) applies to SRT input via
|
|
5
6
|
libass force_style. ASS files carry their own styles and are rendered as-is.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
are
|
|
11
|
-
subtitle
|
|
7
|
+
Styling and animation only apply to --mode burn (the default): they render
|
|
8
|
+
pixels, so they have no meaning for a soft subtitle stream.
|
|
9
|
+
|
|
10
|
+
--mode mux copies the video and audio streams untouched (see contract --json:
|
|
11
|
+
reencodes_video/reencodes_audio are "never" for this mode) and adds the SRT
|
|
12
|
+
as a separate subtitle stream a player can toggle -- the source is never
|
|
13
|
+
touched. It takes only a plain SRT (from --srt, --text or --transcribe), not
|
|
14
|
+
--ass: ASS styling has no equivalent soft-subtitle representation across
|
|
15
|
+
containers, so --mode mux --ass is refused with a pointer to --mode burn.
|
|
16
|
+
The subtitle codec is picked from the output container: mov_text for
|
|
17
|
+
.mp4/.m4v/.mov, srt for .mkv, webvtt for .webm.
|
|
12
18
|
|
|
13
19
|
Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
14
20
|
0:00-0:03 Hello and welcome
|
|
15
21
|
00:00:03.500 --> 00:00:06 Second line | with a manual line break
|
|
22
|
+
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
|
|
16
23
|
Text without a time is auto-timed after the previous cue (--auto-seconds)
|
|
17
24
|
|
|
18
25
|
Examples:
|
|
@@ -29,7 +36,7 @@ import sys
|
|
|
29
36
|
from pathlib import Path
|
|
30
37
|
from typing import List, Optional, Tuple
|
|
31
38
|
|
|
32
|
-
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
39
|
+
from _common import STATE, color_hex, load_brand, 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
|
|
33
40
|
|
|
34
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
35
42
|
|
|
@@ -38,7 +45,7 @@ TIME_RE = re.compile(
|
|
|
38
45
|
)
|
|
39
46
|
|
|
40
47
|
|
|
41
|
-
def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[float, float, str]]:
|
|
48
|
+
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
42
49
|
cues: List[Tuple[float, float, str]] = []
|
|
43
50
|
cursor = 0.0
|
|
44
51
|
with open(path, encoding="utf-8") as fh:
|
|
@@ -49,7 +56,9 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
49
56
|
m = TIME_RE.match(line)
|
|
50
57
|
if m:
|
|
51
58
|
try:
|
|
52
|
-
start, end = parse_time(m.group("a")), parse_time(m.group("b"))
|
|
59
|
+
start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
|
|
60
|
+
except MissingFpsError as e:
|
|
61
|
+
die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
|
|
53
62
|
except ValueError:
|
|
54
63
|
start, end, text = cursor, cursor + auto_seconds, line.strip()
|
|
55
64
|
else:
|
|
@@ -66,7 +75,7 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
66
75
|
return cues
|
|
67
76
|
|
|
68
77
|
|
|
69
|
-
def transcribe(video: str, out_srt: str, language: Optional[str], model: str) -> List[Tuple[float, float, str]]:
|
|
78
|
+
def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
|
|
70
79
|
"""Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
|
|
71
80
|
whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
|
|
72
81
|
No engine installed -> clear error with install hints; the skill never depends on one."""
|
|
@@ -77,7 +86,8 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str) ->
|
|
|
77
86
|
ffmpeg = require_tool("ffmpeg")
|
|
78
87
|
tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
|
|
79
88
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
80
|
-
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
89
|
+
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
90
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
|
|
81
91
|
# 1. whisper.cpp
|
|
82
92
|
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
|
|
83
93
|
if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
|
|
@@ -158,7 +168,7 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
|
158
168
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
159
169
|
|
|
160
170
|
|
|
161
|
-
def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
|
|
171
|
+
def word_durations_from_audio(video: str, start: float, end: float, n_words: int, audio_stream: int = 0) -> List[int]:
|
|
162
172
|
"""Split a cue's time across n_words in proportion to speech energy (centiseconds each).
|
|
163
173
|
|
|
164
174
|
Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
|
|
@@ -173,7 +183,7 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
173
183
|
return [total_cs]
|
|
174
184
|
ffmpeg = require_tool("ffmpeg")
|
|
175
185
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
176
|
-
"-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
186
|
+
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
177
187
|
proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
|
|
178
188
|
n = len(proc.stdout) // 2
|
|
179
189
|
if proc.returncode != 0 or n < 800:
|
|
@@ -257,7 +267,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
257
267
|
segments = body.split("\\N")
|
|
258
268
|
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
259
269
|
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
260
|
-
durs = word_durations_from_audio(video, start, end, len(words))
|
|
270
|
+
durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
|
|
261
271
|
else:
|
|
262
272
|
per = max(1, dur_cs // max(1, len(words)))
|
|
263
273
|
durs = [per] * len(words)
|
|
@@ -272,6 +282,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
272
282
|
fh.write("\n".join(header + lines) + "\n")
|
|
273
283
|
|
|
274
284
|
|
|
285
|
+
def mux_subtitle_codec(output: str) -> str:
|
|
286
|
+
ext = Path(output).suffix.lower()
|
|
287
|
+
if ext in (".mp4", ".m4v", ".mov"):
|
|
288
|
+
return "mov_text"
|
|
289
|
+
if ext == ".mkv":
|
|
290
|
+
return "srt"
|
|
291
|
+
if ext == ".webm":
|
|
292
|
+
return "webvtt"
|
|
293
|
+
die(f"--mode mux: don't know a soft-subtitle codec for '{ext}' output "
|
|
294
|
+
"(know .mp4/.m4v/.mov, .mkv, .webm) -- use --mode burn, or pick one of those containers with -o")
|
|
295
|
+
|
|
296
|
+
|
|
275
297
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
276
298
|
h = hex_rgb.lstrip("#")
|
|
277
299
|
if len(h) != 6:
|
|
@@ -284,16 +306,26 @@ def main() -> int:
|
|
|
284
306
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
285
307
|
ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
|
|
286
308
|
ap.add_argument("-o", "--output", help="output video (default: <name>_captioned.<ext>)")
|
|
309
|
+
ap.add_argument("--mode", choices=["burn", "mux"], default="burn",
|
|
310
|
+
help="'burn' renders subtitles into the picture (default); "
|
|
311
|
+
"'mux' copies video/audio untouched and adds the SRT as a soft, toggleable subtitle stream")
|
|
312
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
313
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
314
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
315
|
+
"the first track, same as leaving it unset always did")
|
|
287
316
|
src = ap.add_argument_group("subtitle source")
|
|
288
317
|
src.add_argument("--srt", help="SRT file to burn")
|
|
289
318
|
src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
|
|
290
319
|
src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
|
|
291
320
|
src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
|
|
292
|
-
src.add_argument("--language", help="language code for --transcribe (e.g. en, ja
|
|
321
|
+
src.add_argument("--language", help="language code for --transcribe (e.g. en, ja; default auto), also tagged on the subtitle stream with --mode mux")
|
|
293
322
|
src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
|
|
294
323
|
src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
|
|
295
324
|
src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
|
|
296
325
|
src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
|
|
326
|
+
src.add_argument("--fps", type=float, default=None,
|
|
327
|
+
help="frame rate for interpreting hh:mm:ss:ff SMPTE timecode cues in --text (non-drop-frame); "
|
|
328
|
+
"defaults to the input video's own fps when --input is given, required otherwise")
|
|
297
329
|
sty = ap.add_argument_group("style (SRT only)")
|
|
298
330
|
sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
|
|
299
331
|
sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
|
|
@@ -339,17 +371,37 @@ def main() -> int:
|
|
|
339
371
|
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
340
372
|
if not (args.srt or args.ass or args.text or args.transcribe):
|
|
341
373
|
die("give one of --srt, --ass, --text or --transcribe")
|
|
374
|
+
if args.mode == "mux":
|
|
375
|
+
if args.ass:
|
|
376
|
+
die("--mode mux takes --srt (or --text/--transcribe), not --ass -- "
|
|
377
|
+
"ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
|
|
378
|
+
if args.animate != "none" or args.karaoke:
|
|
379
|
+
die("--animate/--karaoke render pixels into the picture and require --mode burn")
|
|
380
|
+
|
|
381
|
+
meta = None
|
|
382
|
+
if args.input:
|
|
383
|
+
meta = probe(args.input)
|
|
384
|
+
if not meta.get("video"):
|
|
385
|
+
die("input has no video stream")
|
|
386
|
+
audio_streams = meta.get("audio_streams") or []
|
|
387
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
388
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
389
|
+
if args.audio_stream and not audio_streams:
|
|
390
|
+
die("--audio-stream needs an input with audio streams")
|
|
391
|
+
fps_for_tc = args.fps
|
|
392
|
+
if fps_for_tc is None and meta is not None:
|
|
393
|
+
fps_for_tc = meta.get("video", {}).get("fps")
|
|
342
394
|
|
|
343
395
|
srt_path = args.srt
|
|
344
396
|
if args.transcribe:
|
|
345
397
|
if not args.input:
|
|
346
398
|
die("--transcribe needs the input video")
|
|
347
399
|
srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
|
|
348
|
-
cues = transcribe(args.input, srt_path, args.language, args.model)
|
|
400
|
+
cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
|
|
349
401
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
350
402
|
args.text = None
|
|
351
403
|
if args.text:
|
|
352
|
-
cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
|
|
404
|
+
cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
|
|
353
405
|
if args.write_srt:
|
|
354
406
|
srt_path = args.write_srt
|
|
355
407
|
elif args.input:
|
|
@@ -360,18 +412,45 @@ def main() -> int:
|
|
|
360
412
|
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
361
413
|
if not STATE.dry_run:
|
|
362
414
|
write_srt(cues, srt_path)
|
|
363
|
-
|
|
415
|
+
tc_range = f", {fmt_smpte_time(cues[0][0], fps_for_tc)}-{fmt_smpte_time(cues[-1][1], fps_for_tc)} @ {fps_for_tc:g}fps" if fps_for_tc else ""
|
|
416
|
+
info(f"wrote {srt_path} ({len(cues)} cues{tc_range})")
|
|
364
417
|
if not args.input:
|
|
365
418
|
print(srt_path)
|
|
366
419
|
return 0
|
|
367
420
|
|
|
368
421
|
if not args.input:
|
|
369
422
|
die("input video is required unless you only use --text/--write-srt")
|
|
370
|
-
meta = probe(args.input)
|
|
371
|
-
if not meta.get("video"):
|
|
372
|
-
die("input has no video stream")
|
|
373
423
|
|
|
374
424
|
output = args.output or default_output(args.input, "captioned")
|
|
425
|
+
|
|
426
|
+
if args.mode == "mux":
|
|
427
|
+
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
428
|
+
die(f"SRT file not found: {srt_path}")
|
|
429
|
+
codec = mux_subtitle_codec(output)
|
|
430
|
+
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
431
|
+
# language to build a multi-language set) -- copied byte-identical, distinct from the
|
|
432
|
+
# newly-added SRT's own codec below.
|
|
433
|
+
existing_subs = meta.get("subtitle_streams") or 0
|
|
434
|
+
maps = ["-map", "0:v:0"]
|
|
435
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
|
|
436
|
+
if meta.get("audio"):
|
|
437
|
+
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
438
|
+
if existing_subs:
|
|
439
|
+
maps += ["-map", "0:s?"]
|
|
440
|
+
maps += ["-map", "1:0"]
|
|
441
|
+
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
442
|
+
for i in range(existing_subs):
|
|
443
|
+
cmd += [f"-c:s:{i}", "copy"]
|
|
444
|
+
cmd += [f"-c:s:{existing_subs}", codec]
|
|
445
|
+
if args.language:
|
|
446
|
+
cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
|
|
447
|
+
cmd += [output]
|
|
448
|
+
run(cmd)
|
|
449
|
+
result = probe(output, role="output")
|
|
450
|
+
info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
|
|
451
|
+
emit(output)
|
|
452
|
+
return 0
|
|
453
|
+
|
|
375
454
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
376
455
|
cues_for_ass = cues if args.text else parse_srt(srt_path)
|
|
377
456
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
@@ -409,10 +488,13 @@ def main() -> int:
|
|
|
409
488
|
if args.fonts_dir:
|
|
410
489
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
411
490
|
|
|
412
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-
|
|
491
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
|
|
492
|
+
if meta.get("audio"):
|
|
493
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
494
|
+
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
413
495
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
414
496
|
run(cmd)
|
|
415
|
-
result = probe(output)
|
|
497
|
+
result = probe(output, role="output")
|
|
416
498
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
|
417
499
|
emit(output)
|
|
418
500
|
return 0
|
package/scripts/color.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
3
|
real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
|
|
4
4
|
colour tags without re-encoding, or apply typed primary colour correction
|
|
5
|
-
(exposure, contrast, saturation, white balance
|
|
5
|
+
(exposure, contrast, saturation, gamma, white balance, three-way
|
|
6
|
+
lift/gain, levels, curves).
|
|
6
7
|
|
|
7
8
|
Examples:
|
|
8
9
|
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
@@ -13,21 +14,26 @@ Examples:
|
|
|
13
14
|
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
14
15
|
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
15
16
|
python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
|
|
17
|
+
python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
|
|
18
|
+
python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
|
|
16
19
|
"""
|
|
17
20
|
import argparse
|
|
18
21
|
import os
|
|
19
22
|
import sys
|
|
20
23
|
from typing import List
|
|
21
24
|
|
|
22
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
|
|
23
26
|
|
|
24
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
25
28
|
|
|
26
29
|
# Typed primary correction: each flag is one option of one real, always-available libavfilter filter
|
|
27
30
|
# (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
|
|
28
31
|
# documents (`ffmpeg -h filter=<name>`), not the filter's full technical range. default is each filter's
|
|
29
|
-
# own documented no-op value, so every stage
|
|
30
|
-
# which flags were actually given.
|
|
32
|
+
# own documented no-op value, so every stage in this dict is always emitted and the chain never depends
|
|
33
|
+
# on which flags were actually given (exposure/temperature/tint/gamma/lift/gain). `colorlevels` and
|
|
34
|
+
# `curves` (see LEVELS and CURVES_PRESETS below) are the exception: they only add a term to the chain
|
|
35
|
+
# when the caller actually asks for them, because "levels 0..255 in, 0..255 out" and "no curve" are
|
|
36
|
+
# already the identity operation without emitting a no-op filter term for it.
|
|
31
37
|
CORRECTION = {
|
|
32
38
|
# flag default lo hi unit
|
|
33
39
|
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
@@ -35,8 +41,27 @@ CORRECTION = {
|
|
|
35
41
|
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
36
42
|
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
|
|
37
43
|
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
44
|
+
"gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
|
|
45
|
+
"lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
|
|
46
|
+
"gain": (0.0, -1.0, 1.0, "x"), # colorbalance highlights (rh=gh=bh); 0=unchanged
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
# colorlevels takes fractional 0.0..1.0 input/output black/white points; this tool exposes the
|
|
50
|
+
# familiar 8-bit 0..255 unit instead and divides by 255.0 when building the filter (same convention
|
|
51
|
+
# as the rest of CORRECTION: a human-friendly CLI unit formatted into the filter's own native unit).
|
|
52
|
+
LEVELS = {
|
|
53
|
+
# flag default lo hi
|
|
54
|
+
"levels_in_black": (0, 0, 255),
|
|
55
|
+
"levels_in_white": (255, 0, 255),
|
|
56
|
+
"levels_out_black": (0, 0, 255),
|
|
57
|
+
"levels_out_white": (255, 0, 255),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# curves filter's real built-in presets (`ffmpeg -h filter=curves`), excluding its own "none" (0):
|
|
61
|
+
# omitting --curves already gets that identity result without adding a filter term for it.
|
|
62
|
+
CURVES_PRESETS = ["color_negative", "cross_process", "darker", "increase_contrast", "lighter",
|
|
63
|
+
"linear_contrast", "medium_contrast", "negative", "strong_contrast", "vintage"]
|
|
64
|
+
|
|
40
65
|
|
|
41
66
|
def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
42
67
|
_, lo, hi, unit = CORRECTION[flag]
|
|
@@ -46,26 +71,65 @@ def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
|
46
71
|
return value
|
|
47
72
|
|
|
48
73
|
|
|
74
|
+
def _checked_levels(args: argparse.Namespace, flag: str) -> int:
|
|
75
|
+
_, lo, hi = LEVELS[flag]
|
|
76
|
+
value = getattr(args, flag)
|
|
77
|
+
if not (lo <= value <= hi):
|
|
78
|
+
die(f"--{flag.replace('_', '-')} {value} is outside {lo}..{hi} (8-bit units, scaled to colorlevels' own 0..1 range)")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
49
82
|
def correction_chain(args: argparse.Namespace) -> str:
|
|
50
|
-
"""
|
|
51
|
-
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint
|
|
52
|
-
contrast/saturation act on colour-balanced footage) -> contrast
|
|
53
|
-
adjacent stage
|
|
54
|
-
expressed as colorbalance's three midtone channels (gm=-tint,
|
|
55
|
-
midtones toward magenta and a negative one toward green
|
|
56
|
-
the same balanced-axis convention colour tools use
|
|
83
|
+
"""Always-present filter stages, in a fixed order chosen so each stage sees a picture already
|
|
84
|
+
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
|
|
85
|
+
lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
|
|
86
|
+
(the most creative-adjacent stage of the always-on chain). `tint` (-1 green .. +1 magenta) is not
|
|
87
|
+
a single ffmpeg option: it is expressed as colorbalance's three midtone channels (gm=-tint,
|
|
88
|
+
rm=bm=tint/2) so a positive tint shifts midtones toward magenta and a negative one toward green
|
|
89
|
+
without changing overall midtone lightness, the same balanced-axis convention colour tools use
|
|
90
|
+
for a one-dial tint control. `lift` and `gain` extend the same colorbalance call to the shadow
|
|
91
|
+
(rs=gs=bs=lift) and highlight (rh=gh=bh=gain) channels, giving a classic three-way shadows/
|
|
92
|
+
midtones/highlights correction in one filter invocation. `gamma` is folded into the same `eq`
|
|
93
|
+
term contrast/saturation already use, as `eq`'s own `gamma` option. Two further stages are
|
|
94
|
+
appended only when asked for, since their own identity value would otherwise add a no-op filter
|
|
95
|
+
term to the chain: `colorlevels` (--levels-*, 8-bit units scaled to the filter's 0..1 range) and
|
|
96
|
+
`curves` (--curves, one of the filter's own named presets)."""
|
|
57
97
|
exposure = _checked(args, "exposure")
|
|
58
98
|
contrast = _checked(args, "contrast")
|
|
59
99
|
saturation = _checked(args, "saturation")
|
|
60
100
|
temperature = _checked(args, "temperature")
|
|
61
101
|
tint = _checked(args, "tint")
|
|
102
|
+
gamma = _checked(args, "gamma")
|
|
103
|
+
lift = _checked(args, "lift")
|
|
104
|
+
gain = _checked(args, "gain")
|
|
105
|
+
in_black = _checked_levels(args, "levels_in_black")
|
|
106
|
+
in_white = _checked_levels(args, "levels_in_white")
|
|
107
|
+
out_black = _checked_levels(args, "levels_out_black")
|
|
108
|
+
out_white = _checked_levels(args, "levels_out_white")
|
|
109
|
+
if in_black >= in_white:
|
|
110
|
+
die(f"--levels-in-black {in_black} must be less than --levels-in-white {in_white}")
|
|
111
|
+
if out_black >= out_white:
|
|
112
|
+
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
113
|
+
|
|
62
114
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
63
|
-
|
|
115
|
+
terms = [
|
|
64
116
|
f"exposure=exposure={exposure:g}",
|
|
65
117
|
f"colortemperature=temperature={temperature:g}",
|
|
66
|
-
f"colorbalance=rm={rm:g}:gm={gm:g}:bm={bm:g}",
|
|
67
|
-
f"eq=contrast={contrast:g}:saturation={saturation:g}",
|
|
68
|
-
]
|
|
118
|
+
f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
|
|
119
|
+
f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
|
|
120
|
+
]
|
|
121
|
+
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
122
|
+
rimin, rimax = in_black / 255.0, in_white / 255.0
|
|
123
|
+
romin, romax = out_black / 255.0, out_white / 255.0
|
|
124
|
+
terms.append(
|
|
125
|
+
f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
|
|
126
|
+
f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
|
|
127
|
+
f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
|
|
128
|
+
f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
|
|
129
|
+
)
|
|
130
|
+
if args.curves:
|
|
131
|
+
terms.append(f"curves=preset={args.curves}")
|
|
132
|
+
return ",".join(terms)
|
|
69
133
|
|
|
70
134
|
|
|
71
135
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
@@ -94,7 +158,7 @@ def main() -> int:
|
|
|
94
158
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
95
159
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
96
160
|
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
97
|
-
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint")
|
|
161
|
+
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
|
|
98
162
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
99
163
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
100
164
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
@@ -105,6 +169,20 @@ def main() -> int:
|
|
|
105
169
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
106
170
|
ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
|
|
107
171
|
ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
|
|
172
|
+
ap.add_argument("--gamma", type=float, default=CORRECTION["gamma"][0], help="--correct: master gamma (eq filter's own gamma), 0.1..10, 1=unchanged (default 1)")
|
|
173
|
+
ap.add_argument("--lift", type=float, default=CORRECTION["lift"][0], help="--correct: shadows lift (colorbalance rs/gs/bs), -1..1, 0=unchanged (default 0)")
|
|
174
|
+
ap.add_argument("--gain", type=float, default=CORRECTION["gain"][0], help="--correct: highlights gain (colorbalance rh/gh/bh), -1..1, 0=unchanged (default 0)")
|
|
175
|
+
ap.add_argument("--levels-in-black", type=int, default=LEVELS["levels_in_black"][0], help="--correct: colorlevels input black point, 0..255 (default 0, unchanged)")
|
|
176
|
+
ap.add_argument("--levels-in-white", type=int, default=LEVELS["levels_in_white"][0], help="--correct: colorlevels input white point, 0..255 (default 255, unchanged)")
|
|
177
|
+
ap.add_argument("--levels-out-black", type=int, default=LEVELS["levels_out_black"][0], help="--correct: colorlevels output black point, 0..255 (default 0, unchanged)")
|
|
178
|
+
ap.add_argument("--levels-out-white", type=int, default=LEVELS["levels_out_white"][0], help="--correct: colorlevels output white point, 0..255 (default 255, unchanged)")
|
|
179
|
+
ap.add_argument("--curves", choices=CURVES_PRESETS, default=None, help="--correct: curves filter built-in preset (default: none, no curves term added)")
|
|
180
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
181
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
182
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
183
|
+
"the first track, same as leaving it unset always did. Only affects modes that re-encode "
|
|
184
|
+
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
185
|
+
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
108
186
|
ap.add_argument("--crf", type=int, default=18)
|
|
109
187
|
ap.add_argument("--preset", default="medium")
|
|
110
188
|
add_common(ap)
|
|
@@ -116,6 +194,11 @@ def main() -> int:
|
|
|
116
194
|
die("input has no video stream")
|
|
117
195
|
v = meta["video"]
|
|
118
196
|
has_audio = bool(meta.get("audio"))
|
|
197
|
+
audio_streams = meta.get("audio_streams") or []
|
|
198
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
199
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
200
|
+
if args.audio_stream and not audio_streams:
|
|
201
|
+
die("--audio-stream needs an input with audio streams")
|
|
119
202
|
|
|
120
203
|
if args.strip_dovi:
|
|
121
204
|
output = args.output or default_output(args.input, "nodv")
|
|
@@ -128,7 +211,7 @@ def main() -> int:
|
|
|
128
211
|
cmd += ["-movflags", "+faststart"]
|
|
129
212
|
cmd.append(output)
|
|
130
213
|
run(cmd)
|
|
131
|
-
r = probe(output)
|
|
214
|
+
r = probe(output, role="output")
|
|
132
215
|
info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
|
|
133
216
|
emit(output)
|
|
134
217
|
return 0
|
|
@@ -148,14 +231,30 @@ def main() -> int:
|
|
|
148
231
|
cmd += ["-movflags", "+faststart"]
|
|
149
232
|
cmd.append(output)
|
|
150
233
|
proc = run(cmd, check=False)
|
|
234
|
+
dropped_streams = False
|
|
151
235
|
if proc.returncode != 0:
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
236
|
+
# Some codecs cannot carry retagged colour info without a bitstream filter, so the
|
|
237
|
+
# stream copy above fails and we fall back to re-encoding video+audio. The copy path
|
|
238
|
+
# (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
|
|
239
|
+
# attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
|
|
240
|
+
# for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
|
|
241
|
+
# also fails (e.g. a subtitle codec genuinely incompatible with the target container)
|
|
242
|
+
# do we drop to video+selected-audio-only, and even then we say so explicitly rather
|
|
243
|
+
# than silently reporting "completed" with streams missing.
|
|
244
|
+
info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
|
|
245
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
246
|
+
"-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
247
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
248
|
+
cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
|
|
249
|
+
proc2 = run(cmd, check=False)
|
|
250
|
+
if proc2.returncode != 0:
|
|
251
|
+
info("re-encode with subtitles/data streams kept also failed; dropping them")
|
|
252
|
+
dropped_streams = True
|
|
253
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
254
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
255
|
+
run(cmd)
|
|
157
256
|
info(f"wrote {output} (tags -> {args.retag})")
|
|
158
|
-
emit(output)
|
|
257
|
+
emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
|
|
159
258
|
return 0
|
|
160
259
|
|
|
161
260
|
measurements = None
|
|
@@ -184,17 +283,17 @@ def main() -> int:
|
|
|
184
283
|
output = args.output or default_output(args.input, "lut")
|
|
185
284
|
tag = "lut"
|
|
186
285
|
|
|
187
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:
|
|
188
|
-
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
189
|
-
|
|
190
|
-
r = probe(output)
|
|
286
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
287
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
288
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
289
|
+
r = probe(output, role="output")
|
|
191
290
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
192
291
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
292
|
+
extra = {"dropped_non_av_streams": dropped_streams}
|
|
193
293
|
if measurements is not None:
|
|
194
294
|
measurements["output"] = analyze_levels(output)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
emit(output)
|
|
295
|
+
extra["measurements"] = measurements
|
|
296
|
+
emit(output, **extra)
|
|
198
297
|
return 0
|
|
199
298
|
|
|
200
299
|
|
package/scripts/crop.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Crop a video to an exact pixel rectangle.
|
|
3
|
+
|
|
4
|
+
{x, y, width, height} are literal pixel offsets and dimensions in the SOURCE
|
|
5
|
+
frame, not aspect-ratio-relative -- for cropping to a target aspect ratio
|
|
6
|
+
(e.g. 16:9 -> 9:16) use fit.py --fit crop instead, which computes the
|
|
7
|
+
rectangle for you and lets you steer it with --crop-x/--crop-y. This tool is
|
|
8
|
+
for when the caller already knows the exact rectangle (a face-detection box,
|
|
9
|
+
a saved crop from a previous edit, a hand-picked region).
|
|
10
|
+
|
|
11
|
+
The rectangle must lie entirely inside the source frame after accounting for
|
|
12
|
+
any display rotation, and --width/--height must be even (required for 4:2:0
|
|
13
|
+
chroma subsampling, the pixel format every encoder here uses) -- given values
|
|
14
|
+
are validated and refused, never silently rounded, since a caller-specified
|
|
15
|
+
rectangle should do exactly what was asked or fail loudly.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
|
|
19
|
+
python3 crop.py input.mp4 --x 0 --y 140 --width 1920 --height 800 -o cropped.mp4
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
ap.add_argument("input")
|
|
30
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_crop.<ext>)")
|
|
31
|
+
ap.add_argument("--x", type=int, required=True, help="left edge of the crop rectangle, in source pixels")
|
|
32
|
+
ap.add_argument("--y", type=int, required=True, help="top edge of the crop rectangle, in source pixels")
|
|
33
|
+
ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
|
|
34
|
+
ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
|
|
35
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
36
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
37
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
38
|
+
add_common(ap)
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
apply_common(args)
|
|
41
|
+
|
|
42
|
+
if args.x < 0 or args.y < 0:
|
|
43
|
+
die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
|
|
44
|
+
if args.width <= 0 or args.height <= 0:
|
|
45
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
46
|
+
if args.width % 2 or args.height % 2:
|
|
47
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
48
|
+
|
|
49
|
+
meta = probe(args.input)
|
|
50
|
+
if not meta.get("video"):
|
|
51
|
+
die("input has no video stream")
|
|
52
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
53
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
54
|
+
sw, sh = sh, sw
|
|
55
|
+
if args.x + args.width > sw or args.y + args.height > sh:
|
|
56
|
+
die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
57
|
+
has_audio = bool(meta.get("audio"))
|
|
58
|
+
|
|
59
|
+
output = args.output or default_output(args.input, "crop")
|
|
60
|
+
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
|
|
61
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
|
|
62
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
63
|
+
cmd += cfr_args(meta, args.fps)
|
|
64
|
+
if has_audio:
|
|
65
|
+
cmd += aac_args()
|
|
66
|
+
else:
|
|
67
|
+
cmd += ["-an"]
|
|
68
|
+
cmd.append(output)
|
|
69
|
+
run(cmd)
|
|
70
|
+
|
|
71
|
+
result = probe(output, role="output")
|
|
72
|
+
v = result["video"]
|
|
73
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']})")
|
|
74
|
+
emit(output)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
package/scripts/cut.py
CHANGED
|
@@ -112,7 +112,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
112
112
|
if not reencode:
|
|
113
113
|
info("stream copy failed, falling back to re-encode")
|
|
114
114
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
115
|
-
die(f"ffmpeg failed:\n{proc.stderr.strip()}")
|
|
115
|
+
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
116
116
|
if not reencode and tolerance >= 0 and not STATE["dry_run"]:
|
|
117
117
|
got = probe(dst).get("duration") or 0.0
|
|
118
118
|
if abs(got - dur) > tolerance:
|
|
@@ -190,7 +190,7 @@ def main() -> int:
|
|
|
190
190
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + encode_args(meta, output, args.crf, args.preset) + [output]
|
|
191
191
|
run(cmd)
|
|
192
192
|
|
|
193
|
-
result = probe(output)
|
|
193
|
+
result = probe(output, role="output")
|
|
194
194
|
expected = sum(e - s for s, e in segments)
|
|
195
195
|
precision = precision_of(meta, output, reencoded)
|
|
196
196
|
got = result.get("duration")
|
package/scripts/export.py
CHANGED
|
@@ -118,7 +118,7 @@ def main() -> int:
|
|
|
118
118
|
cmd += ["-t", f"{p['max']:.3f}"]
|
|
119
119
|
cmd.append(output)
|
|
120
120
|
run(cmd)
|
|
121
|
-
result = probe(output)
|
|
121
|
+
result = probe(output, role="output")
|
|
122
122
|
v = result["video"]
|
|
123
123
|
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
|
|
124
124
|
emit(output)
|