ffmpeg-skill 0.10.0 → 0.12.0
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 +33 -9
- package/SKILL.md +77 -12
- package/bin/install.js +15 -1
- 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 +122 -11
- package/scripts/_common.py +200 -12
- package/scripts/_contract.py +204 -12
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -24
- package/scripts/color.py +36 -9
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +60 -10
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +59 -5
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -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,36 @@ 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
|
+
maps = ["-map", "0:v:0"]
|
|
431
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
|
|
432
|
+
if meta.get("audio"):
|
|
433
|
+
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
434
|
+
maps += ["-map", "1:0"]
|
|
435
|
+
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else []) + ["-c:s", codec]
|
|
436
|
+
if args.language:
|
|
437
|
+
cmd += ["-metadata:s:s:0", f"language={args.language}"]
|
|
438
|
+
cmd += [output]
|
|
439
|
+
run(cmd)
|
|
440
|
+
result = probe(output, role="output")
|
|
441
|
+
info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
|
|
442
|
+
emit(output)
|
|
443
|
+
return 0
|
|
444
|
+
|
|
375
445
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
376
446
|
cues_for_ass = cues if args.text else parse_srt(srt_path)
|
|
377
447
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
@@ -409,10 +479,13 @@ def main() -> int:
|
|
|
409
479
|
if args.fonts_dir:
|
|
410
480
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
411
481
|
|
|
412
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-
|
|
482
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
|
|
483
|
+
if meta.get("audio"):
|
|
484
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
485
|
+
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
413
486
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
414
487
|
run(cmd)
|
|
415
|
-
result = probe(output)
|
|
488
|
+
result = probe(output, role="output")
|
|
416
489
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
|
417
490
|
emit(output)
|
|
418
491
|
return 0
|
package/scripts/color.py
CHANGED
|
@@ -105,6 +105,12 @@ def main() -> int:
|
|
|
105
105
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
106
106
|
ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
|
|
107
107
|
ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
|
|
108
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
109
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
110
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
111
|
+
"the first track, same as leaving it unset always did. Only affects modes that re-encode "
|
|
112
|
+
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
113
|
+
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
108
114
|
ap.add_argument("--crf", type=int, default=18)
|
|
109
115
|
ap.add_argument("--preset", default="medium")
|
|
110
116
|
add_common(ap)
|
|
@@ -116,6 +122,11 @@ def main() -> int:
|
|
|
116
122
|
die("input has no video stream")
|
|
117
123
|
v = meta["video"]
|
|
118
124
|
has_audio = bool(meta.get("audio"))
|
|
125
|
+
audio_streams = meta.get("audio_streams") or []
|
|
126
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
127
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
128
|
+
if args.audio_stream and not audio_streams:
|
|
129
|
+
die("--audio-stream needs an input with audio streams")
|
|
119
130
|
|
|
120
131
|
if args.strip_dovi:
|
|
121
132
|
output = args.output or default_output(args.input, "nodv")
|
|
@@ -128,7 +139,7 @@ def main() -> int:
|
|
|
128
139
|
cmd += ["-movflags", "+faststart"]
|
|
129
140
|
cmd.append(output)
|
|
130
141
|
run(cmd)
|
|
131
|
-
r = probe(output)
|
|
142
|
+
r = probe(output, role="output")
|
|
132
143
|
info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
|
|
133
144
|
emit(output)
|
|
134
145
|
return 0
|
|
@@ -148,14 +159,30 @@ def main() -> int:
|
|
|
148
159
|
cmd += ["-movflags", "+faststart"]
|
|
149
160
|
cmd.append(output)
|
|
150
161
|
proc = run(cmd, check=False)
|
|
162
|
+
dropped_streams = False
|
|
151
163
|
if proc.returncode != 0:
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
# Some codecs cannot carry retagged colour info without a bitstream filter, so the
|
|
165
|
+
# stream copy above fails and we fall back to re-encoding video+audio. The copy path
|
|
166
|
+
# (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
|
|
167
|
+
# attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
|
|
168
|
+
# for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
|
|
169
|
+
# also fails (e.g. a subtitle codec genuinely incompatible with the target container)
|
|
170
|
+
# do we drop to video+selected-audio-only, and even then we say so explicitly rather
|
|
171
|
+
# than silently reporting "completed" with streams missing.
|
|
172
|
+
info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
|
|
173
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
174
|
+
"-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
175
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
176
|
+
cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
|
|
177
|
+
proc2 = run(cmd, check=False)
|
|
178
|
+
if proc2.returncode != 0:
|
|
179
|
+
info("re-encode with subtitles/data streams kept also failed; dropping them")
|
|
180
|
+
dropped_streams = True
|
|
181
|
+
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)
|
|
182
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
183
|
+
run(cmd)
|
|
157
184
|
info(f"wrote {output} (tags -> {args.retag})")
|
|
158
|
-
emit(output)
|
|
185
|
+
emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
|
|
159
186
|
return 0
|
|
160
187
|
|
|
161
188
|
measurements = None
|
|
@@ -184,10 +211,10 @@ def main() -> int:
|
|
|
184
211
|
output = args.output or default_output(args.input, "lut")
|
|
185
212
|
tag = "lut"
|
|
186
213
|
|
|
187
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:
|
|
214
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
188
215
|
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
|
|
189
216
|
run(cmd)
|
|
190
|
-
r = probe(output)
|
|
217
|
+
r = probe(output, role="output")
|
|
191
218
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
192
219
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
193
220
|
if measurements is not None:
|
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)
|
package/scripts/fit.py
CHANGED
|
@@ -5,7 +5,13 @@ Duration: --duration N with --method speed (retime video+audio, pitch-preserving
|
|
|
5
5
|
via atempo chaining) or --method trim (keep the first N seconds, or a centred
|
|
6
6
|
window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
|
|
7
7
|
--fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
|
|
8
|
-
--width
|
|
8
|
+
--width and/or --height set the output size: give one and the other follows
|
|
9
|
+
the aspect (source aspect if --aspect is not also given); give both for an
|
|
10
|
+
exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
|
|
11
|
+
rotation/mirror to the picture -- distinct from the rotation metadata a
|
|
12
|
+
source already carries (read automatically to compute the displayed size,
|
|
13
|
+
never altered by these flags unless asked). Both can be combined; rotate is
|
|
14
|
+
applied before flip.
|
|
9
15
|
|
|
10
16
|
Crop keeps the centre of the frame by default, which is a guess: going from
|
|
11
17
|
16:9 to 9:16 throws away most of the width, and whatever isn't in the middle
|
|
@@ -20,6 +26,10 @@ Examples:
|
|
|
20
26
|
python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
|
|
21
27
|
python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
|
|
22
28
|
python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
|
|
29
|
+
python3 fit.py input.mp4 --height 1080 # width follows the source aspect
|
|
30
|
+
python3 fit.py input.mp4 --width 1920 --height 1080 # exact frame, no aspect needed
|
|
31
|
+
python3 fit.py input.mp4 --rotate 90 # rotate 90 degrees clockwise
|
|
32
|
+
python3 fit.py input.mp4 --flip h # mirror horizontally
|
|
23
33
|
"""
|
|
24
34
|
import argparse
|
|
25
35
|
import math
|
|
@@ -66,6 +76,10 @@ def main() -> int:
|
|
|
66
76
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
67
77
|
ap.add_argument("input")
|
|
68
78
|
ap.add_argument("-o", "--output", help="output file (default: <name>_fit.<ext>)")
|
|
79
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
80
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
81
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
82
|
+
"the first track, same as leaving it unset always did")
|
|
69
83
|
d = ap.add_argument_group("duration")
|
|
70
84
|
d.add_argument("--duration", help="target duration (seconds or mm:ss)")
|
|
71
85
|
d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
|
|
@@ -76,10 +90,14 @@ def main() -> int:
|
|
|
76
90
|
a = ap.add_argument_group("aspect")
|
|
77
91
|
a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
|
|
78
92
|
a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
|
|
79
|
-
a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect)")
|
|
93
|
+
a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
|
|
94
|
+
a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
|
|
80
95
|
a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
|
|
81
96
|
a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
|
|
82
97
|
a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
|
|
98
|
+
r = ap.add_argument_group("rotate / flip")
|
|
99
|
+
r.add_argument("--rotate", type=int, choices=[90, 180, 270], help="rotate the picture clockwise by this many degrees")
|
|
100
|
+
r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
|
|
83
101
|
e = ap.add_argument_group("encoding")
|
|
84
102
|
e.add_argument("--crf", type=int, default=18)
|
|
85
103
|
e.add_argument("--preset", default="medium")
|
|
@@ -88,8 +106,10 @@ def main() -> int:
|
|
|
88
106
|
args = ap.parse_args()
|
|
89
107
|
apply_common(args)
|
|
90
108
|
|
|
91
|
-
if
|
|
92
|
-
die("
|
|
109
|
+
if args.fps is not None and args.fps <= 0:
|
|
110
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
111
|
+
if not args.duration and not args.aspect and not args.width and not args.height and not args.fps and not args.rotate and not args.flip:
|
|
112
|
+
die("nothing to do: give --duration, --aspect, --width/--height, --rotate/--flip and/or --fps")
|
|
93
113
|
if not 0.0 <= args.crop_x <= 1.0:
|
|
94
114
|
die(f"--crop-x must be 0..1, got {args.crop_x}")
|
|
95
115
|
if not 0.0 <= args.crop_y <= 1.0:
|
|
@@ -98,10 +118,17 @@ def main() -> int:
|
|
|
98
118
|
meta = probe(args.input)
|
|
99
119
|
if not meta.get("video"):
|
|
100
120
|
die("input has no video stream")
|
|
121
|
+
audio_streams = meta.get("audio_streams") or []
|
|
122
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
123
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
124
|
+
if args.audio_stream and not audio_streams:
|
|
125
|
+
die("--audio-stream needs an input with audio streams")
|
|
101
126
|
src_dur = meta["duration"] or 0.0
|
|
102
127
|
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
103
128
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
104
129
|
sw, sh = sh, sw
|
|
130
|
+
if args.rotate in (90, 270):
|
|
131
|
+
sw, sh = sh, sw
|
|
105
132
|
has_audio = bool(meta.get("audio"))
|
|
106
133
|
|
|
107
134
|
vf: List[str] = []
|
|
@@ -110,6 +137,18 @@ def main() -> int:
|
|
|
110
137
|
post: List[str] = []
|
|
111
138
|
factor = 1.0
|
|
112
139
|
|
|
140
|
+
# ---- rotate / flip
|
|
141
|
+
if args.rotate == 90:
|
|
142
|
+
vf.append("transpose=1")
|
|
143
|
+
elif args.rotate == 270:
|
|
144
|
+
vf.append("transpose=2")
|
|
145
|
+
elif args.rotate == 180:
|
|
146
|
+
vf.append("transpose=2,transpose=2")
|
|
147
|
+
if args.flip == "h":
|
|
148
|
+
vf.append("hflip")
|
|
149
|
+
elif args.flip == "v":
|
|
150
|
+
vf.append("vflip")
|
|
151
|
+
|
|
113
152
|
# ---- duration
|
|
114
153
|
if args.duration:
|
|
115
154
|
target = parse_time(args.duration)
|
|
@@ -142,14 +181,22 @@ def main() -> int:
|
|
|
142
181
|
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
143
182
|
|
|
144
183
|
# ---- aspect / size
|
|
145
|
-
if args.aspect or args.width:
|
|
146
|
-
src_ratio = Fraction(sw, sh)
|
|
184
|
+
if args.aspect or args.width or args.height:
|
|
185
|
+
src_ratio = Fraction(sw, sh) if sh else None
|
|
147
186
|
ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
|
|
148
|
-
if args.width:
|
|
187
|
+
if args.width and args.height:
|
|
188
|
+
out_w, out_h = even(args.width), even(args.height)
|
|
189
|
+
elif args.width:
|
|
149
190
|
out_w = even(args.width)
|
|
150
|
-
|
|
191
|
+
out_h = even(out_w / ratio) if ratio else args.width
|
|
192
|
+
elif args.height:
|
|
193
|
+
out_h = even(args.height)
|
|
194
|
+
out_w = even(out_h * ratio) if ratio else args.height
|
|
195
|
+
elif ratio and src_ratio:
|
|
151
196
|
out_w = even(sw if ratio <= src_ratio else sh * ratio)
|
|
152
|
-
|
|
197
|
+
out_h = even(out_w / ratio)
|
|
198
|
+
else:
|
|
199
|
+
out_w, out_h = even(sw), even(sh)
|
|
153
200
|
if args.fit == "crop":
|
|
154
201
|
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
|
|
155
202
|
vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
|
|
@@ -169,6 +216,9 @@ def main() -> int:
|
|
|
169
216
|
cmd += ["-vf", ",".join(vf)]
|
|
170
217
|
if af:
|
|
171
218
|
cmd += ["-af", ",".join(af)]
|
|
219
|
+
cmd += ["-map", "0:v:0"]
|
|
220
|
+
if has_audio:
|
|
221
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
172
222
|
cmd += video_args(meta, args.crf, args.preset)
|
|
173
223
|
cmd += cfr_args(meta, args.fps) if not args.fps else []
|
|
174
224
|
if has_audio:
|
|
@@ -178,7 +228,7 @@ def main() -> int:
|
|
|
178
228
|
cmd += post + [output]
|
|
179
229
|
run(cmd)
|
|
180
230
|
|
|
181
|
-
result = probe(output)
|
|
231
|
+
result = probe(output, role="output")
|
|
182
232
|
msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
|
|
183
233
|
if abs(factor - 1.0) > 1e-4:
|
|
184
234
|
msg += f", speed {factor:.3f}x"
|
package/scripts/graphics.py
CHANGED
|
@@ -40,6 +40,10 @@ def main() -> int:
|
|
|
40
40
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
41
41
|
ap.add_argument("input")
|
|
42
42
|
ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
|
|
43
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
44
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
45
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
46
|
+
"the first track, same as leaving it unset always did")
|
|
43
47
|
ap.add_argument("--template", choices=TEMPLATES, required=True)
|
|
44
48
|
ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
|
|
45
49
|
ap.add_argument("--name", help="lower-third: name line")
|
|
@@ -70,6 +74,11 @@ def main() -> int:
|
|
|
70
74
|
meta = probe(args.input)
|
|
71
75
|
if not meta.get("video"):
|
|
72
76
|
die("input has no video stream")
|
|
77
|
+
audio_streams = meta.get("audio_streams") or []
|
|
78
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
79
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
80
|
+
if args.audio_stream and not audio_streams:
|
|
81
|
+
die("--audio-stream needs an input with audio streams")
|
|
73
82
|
W, H = meta["video"]["width"], meta["video"]["height"]
|
|
74
83
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
75
84
|
W, H = H, W
|
|
@@ -149,14 +158,14 @@ def main() -> int:
|
|
|
149
158
|
output = args.output or default_output(args.input, "gfx")
|
|
150
159
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
151
160
|
if fc:
|
|
152
|
-
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:
|
|
161
|
+
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
153
162
|
else:
|
|
154
|
-
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:
|
|
163
|
+
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
155
164
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
156
165
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
157
166
|
cmd.append(output)
|
|
158
167
|
run(cmd)
|
|
159
|
-
r = probe(output)
|
|
168
|
+
r = probe(output, role="output")
|
|
160
169
|
info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
|
|
161
170
|
emit(output, template=args.template)
|
|
162
171
|
return 0
|