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.
@@ -1,18 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Burn SRT/ASS subtitles into a video, or generate an SRT from plain text.
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
- This only burns subtitles into the picture. There is no soft-subtitle (mux a
8
- subtitle stream, toggleable by the player) path -- every call re-encodes the
9
- whole video and its audio (see contract --json: reencodes_video/reencodes_audio
10
- are both "always" for this tool), even for a source that only needed the
11
- subtitle track added.
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, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
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); default auto")
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
- info(f"wrote {srt_path} ({len(cues)} cues)")
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, "-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
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
- # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
153
- info("stream copy could not rewrite tags, re-encoding")
154
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a:0?"] + x264_args(args.crf, args.preset, keep_bt709=False)
155
- cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
156
- run(cmd)
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:0?"]
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:
@@ -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 sets the output width; height follows the aspect.
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 not args.duration and not args.aspect and not args.width and not args.fps:
92
- die("nothing to do: give --duration, --aspect, --width and/or --fps")
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
- else:
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
- out_h = even(out_w / ratio)
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"
@@ -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:0?"]
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:0?"]
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