ffmpeg-skill 0.12.5 → 0.16.13

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.
Files changed (60) hide show
  1. package/README.md +19 -6
  2. package/SKILL.md +14 -2
  3. package/bin/install.js +12 -3
  4. package/mcp/server.py +8 -0
  5. package/package.json +2 -2
  6. package/references/scripts.md +160 -1
  7. package/scripts/_common.py +57 -3
  8. package/scripts/_contract.py +56 -5
  9. package/scripts/background.py +11 -2
  10. package/scripts/batch.py +58 -5
  11. package/scripts/caption.py +37 -3
  12. package/scripts/check.py +8 -0
  13. package/scripts/color.py +9 -1
  14. package/scripts/crop.py +2 -0
  15. package/scripts/cropdetect.py +106 -0
  16. package/scripts/cut.py +4 -0
  17. package/scripts/deinterlace.py +85 -0
  18. package/scripts/denoise.py +94 -0
  19. package/scripts/export.py +2 -1
  20. package/scripts/fit.py +14 -3
  21. package/scripts/freeze.py +108 -0
  22. package/scripts/graphics.py +1 -1
  23. package/scripts/grid.py +142 -0
  24. package/scripts/join.py +4 -1
  25. package/scripts/loop.py +80 -0
  26. package/scripts/multicam.py +10 -2
  27. package/scripts/overlay.py +7 -2
  28. package/scripts/pad.py +68 -0
  29. package/scripts/redact.py +100 -0
  30. package/scripts/render.py +17 -3
  31. package/scripts/silence.py +6 -3
  32. package/scripts/speedramp.py +123 -0
  33. package/scripts/sphere.py +126 -0
  34. package/scripts/straighten.py +97 -0
  35. package/scripts/verify.py +25 -2
  36. package/scripts/waveform.py +92 -0
  37. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  59. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  60. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Freeze on one frame for a number of seconds -- an end-card hold, a comedic beat, a pause.
3
+
4
+ --at is the timestamp to freeze (default: the last frame). --hold is how
5
+ long the freeze lasts. --mode insert (default) inserts the hold into the
6
+ clip at --at, pushing everything after it later by --hold seconds; --mode
7
+ extend only works with --at at (or past) the end of the clip and simply
8
+ makes the last frame last --hold seconds longer, without touching anything
9
+ earlier. Audio is silent during the held frame in --mode insert (there is
10
+ no source audio for a frozen moment that didn't exist before); --mode
11
+ extend has no audio to extend either, since it only makes sense at the
12
+ clip's end.
13
+
14
+ Examples:
15
+ python3 freeze.py interview.mp4 --hold 2 # hold the last frame 2s longer
16
+ python3 freeze.py sketch.mp4 --at 12.5 --hold 1.5 # 1.5s freeze inserted at 12.5s
17
+ python3 freeze.py outro.mp4 --hold 3 --mode extend # extend only the very end by 3s
18
+ """
19
+ import argparse
20
+ import sys
21
+
22
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
23
+
24
+
25
+ def main() -> int:
26
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
27
+ ap.add_argument("input")
28
+ ap.add_argument("-o", "--output", help="output file (default: <name>_freeze.<ext>)")
29
+ ap.add_argument("--at", type=float, help="timestamp to freeze, in seconds (default: the last frame)")
30
+ ap.add_argument("--hold", type=float, required=True, help="how long the freeze lasts, in seconds")
31
+ ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
32
+ help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
33
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
34
+ ap.add_argument("--preset", default="medium", help="x264 preset")
35
+ add_common(ap)
36
+ args = ap.parse_args()
37
+ apply_common(args)
38
+
39
+ if args.hold <= 0:
40
+ die(f"--hold must be > 0, got {args.hold:g}")
41
+
42
+ meta = probe(args.input)
43
+ if not meta.get("video"):
44
+ die("input has no video stream")
45
+ dur = meta.get("duration") or 0.0
46
+ fps = meta["video"].get("fps") or 30.0
47
+ at = args.at if args.at is not None else dur
48
+ if at < 0 or at > dur:
49
+ die(f"--at {at:g} is outside the clip (0..{dur:.3f})")
50
+ if args.mode == "extend" and at < dur - 0.01:
51
+ die(f"--mode extend needs --at at or after the clip's end ({dur:.3f}), got {at:g}")
52
+ has_audio = bool(meta.get("audio"))
53
+ output = args.output or default_output(args.input, "freeze")
54
+
55
+ if args.mode == "extend":
56
+ # tpad's stop_duration clones the last frame for the given duration; no PTS surgery
57
+ # needed since it only ever appends past the real end.
58
+ vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
59
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
60
+ if has_audio:
61
+ cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
62
+ elif at == 0:
63
+ # A freeze at the very start has no preceding "head" segment to hold on to (the
64
+ # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
65
+ # it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
66
+ # end: tpad's start_duration clones the *first* frame backwards instead.
67
+ vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
68
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
69
+ if has_audio:
70
+ cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
71
+ else:
72
+ # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
73
+ # resuming the rest of the clip: split the timeline at `at`, freeze-frame the first
74
+ # part's last frame for --hold seconds via tpad, concat with the remainder.
75
+ n = max(1, round(args.hold * fps))
76
+ # The video hold length is rounded to a whole number of frames (n / fps), but the audio
77
+ # side used to pad by the raw --hold value -- up to half a frame duration off from what
78
+ # the video actually holds for, a permanent A/V drift from this point on. Pad audio by
79
+ # the same, frame-rounded duration the video actually gets.
80
+ actual_hold = n / fps
81
+ vf = (f"[0:v]split[a][b];[a]trim=end={at:.3f},setpts=PTS-STARTPTS[head];"
82
+ f"[b]trim=start={at:.3f},setpts=PTS-STARTPTS[tail];"
83
+ f"[head]tpad=stop_mode=clone:stop={n}[frozen];"
84
+ f"[frozen][tail]concat=n=2:v=1:a=0[outv]")
85
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
86
+ if has_audio:
87
+ af = (f"[0:a]asplit[aa][ab];[aa]atrim=end={at:.3f},asetpts=PTS-STARTPTS[ahead];"
88
+ f"[ab]atrim=start={at:.3f},asetpts=PTS-STARTPTS[atail];"
89
+ f"[ahead]apad=pad_dur={actual_hold:.6f}[afrozen];"
90
+ f"[afrozen][atail]concat=n=2:v=0:a=1[outa]")
91
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", f"{vf};{af}", "-map", "[outv]", "-map", "[outa]"]
92
+ cmd += video_args(meta, args.crf, args.preset)
93
+ cmd += cfr_args(meta)
94
+ if has_audio:
95
+ cmd += aac_args()
96
+ else:
97
+ cmd += ["-an"]
98
+ dropped_streams = run_keeping_subtitles(cmd, output)
99
+
100
+ result = probe(output, role="output")
101
+ v = result["video"]
102
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, froze {args.hold:g}s at {at:g}s, mode={args.mode})")
103
+ emit(output, dropped_non_av_streams=dropped_streams)
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
@@ -36,7 +36,7 @@ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str
36
36
  resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
37
37
  if resolved:
38
38
  return f"fontfile={escape_filter_path(resolved)}"
39
- return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
39
+ return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'"
40
40
 
41
41
 
42
42
  def main() -> int:
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Composite several video clips into one COLSxROWS grid, e.g. a 4x2 wall of
3
+ takes, angles, or A/B renders side by side.
4
+
5
+ Every cell is letterboxed (not stretched) to a common --cell-width/--cell-height
6
+ so clips of different aspect ratios and resolutions line up cleanly. By default
7
+ each cell gets its source filename (extension stripped) burnt into the bottom
8
+ right corner -- --label none turns that off. The grid has no audio unless
9
+ --audio-from picks one input's track to carry through; mixing every clip's
10
+ audio together is rarely what a comparison grid is for, so this tool never
11
+ does it silently.
12
+
13
+ The grid runs only as long as its shortest clip by default, or is padded to
14
+ the longest clip's duration with --pad (each shorter cell holds its last
15
+ frame, and --audio-from's track is padded with silence, out to that length);
16
+ a mismatched frame rate across sources is conformed to --fps first so cells
17
+ stay in sync.
18
+
19
+ Examples:
20
+ python3 grid.py take1.mp4 take2.mp4 take3.mp4 take4.mp4 take5.mp4 take6.mp4 take7.mp4 take8.mp4 --cols 4 --rows 2
21
+ python3 grid.py a.mp4 b.mp4 c.mp4 d.mp4 --cols 2 --rows 2 --label none -o compare.mp4
22
+ python3 grid.py cam1.mp4 cam2.mp4 --cols 2 --rows 1 --audio-from 0 --pad
23
+ """
24
+ import argparse
25
+ import os
26
+ import sys
27
+
28
+ from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, \
29
+ escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
30
+
31
+ LABEL_MARGIN = 10
32
+
33
+
34
+ def main() -> int:
35
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
36
+ ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
37
+ ap.add_argument("-o", "--output", help="output file (default: <first name>_grid.<ext>)")
38
+ ap.add_argument("--cols", type=int, required=True, help="grid columns")
39
+ ap.add_argument("--rows", type=int, required=True, help="grid rows")
40
+ ap.add_argument("--cell-width", type=int, default=480, help="each cell's width in px, must be even (default 480)")
41
+ ap.add_argument("--cell-height", type=int, default=270, help="each cell's height in px, must be even (default 270)")
42
+ ap.add_argument("--fps", type=float, default=30.0, help="output frame rate every cell is conformed to (default 30)")
43
+ ap.add_argument("--label", choices=["auto", "none"], default="auto",
44
+ help="auto (default): burn each cell's filename (extension stripped) into its bottom-right corner; none: no label")
45
+ ap.add_argument("--font", default="DejaVu Sans", help="label font (fontconfig family name, default DejaVu Sans)")
46
+ ap.add_argument("--font-size", type=int, default=16, help="label font size (default 16)")
47
+ ap.add_argument("--font-color", default="white", help="label text colour (default white)")
48
+ ap.add_argument("--pad", action="store_true", help="hold each shorter cell's last frame (and pad --audio-from's track with silence) out to the longest clip's duration, instead of stopping at the shortest")
49
+ ap.add_argument("--audio-from", type=int, help="0-based index into inputs to take audio from (default: no audio)")
50
+ ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
51
+ ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
52
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
53
+ ap.add_argument("--preset", default="medium", help="x264 preset")
54
+ add_common(ap)
55
+ args = ap.parse_args()
56
+ apply_common(args)
57
+
58
+ n = args.cols * args.rows
59
+ if args.cols <= 0 or args.rows <= 0:
60
+ die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
61
+ if len(args.inputs) != n:
62
+ die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
63
+ if args.cell_width <= 0 or args.cell_height <= 0:
64
+ die(f"--cell-width/--cell-height must be > 0, got width={args.cell_width} height={args.cell_height}")
65
+ if args.cell_width % 2 or args.cell_height % 2:
66
+ die(f"--cell-width/--cell-height must be even (4:2:0 chroma), got width={args.cell_width} height={args.cell_height}")
67
+ if args.fps <= 0:
68
+ die(f"--fps must be > 0, got {args.fps:g}")
69
+ if args.gap < 0 or args.gap % 2:
70
+ die(f"--gap must be >= 0 and even, got {args.gap}")
71
+ validate_color(args.background, "--background")
72
+ if args.font_color:
73
+ validate_color(args.font_color, "--font-color")
74
+ if args.audio_from is not None and not 0 <= args.audio_from < n:
75
+ die(f"--audio-from {args.audio_from}: must be an input index 0..{n - 1}")
76
+
77
+ metas = [probe(p) for p in args.inputs]
78
+ for p, m in zip(args.inputs, metas):
79
+ if not m.get("video"):
80
+ die(f"{p}: input has no video stream")
81
+ if args.audio_from is not None and not metas[args.audio_from].get("audio"):
82
+ die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
83
+
84
+ durations = [m.get("duration") or 0.0 for m in metas]
85
+ target_duration = max(durations) if args.pad else min(durations)
86
+
87
+ font_file = default_font_file(args.font)
88
+ output = args.output or default_output(args.inputs[0], "grid")
89
+
90
+ cmd = ffmpeg_base()
91
+ for p in args.inputs:
92
+ cmd += ["-i", p]
93
+
94
+ parts = []
95
+ for i, p in enumerate(args.inputs):
96
+ chain = [
97
+ f"fps={args.fps:g}",
98
+ f"scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease",
99
+ f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color={args.background}",
100
+ "setsar=1",
101
+ ]
102
+ if args.pad and durations[i] < target_duration:
103
+ chain.append(f"tpad=stop_mode=clone:stop_duration={target_duration - durations[i]:.3f}")
104
+ elif not args.pad:
105
+ chain.append(f"trim=duration={target_duration:.3f}")
106
+ if args.label == "auto":
107
+ stem = os.path.splitext(os.path.basename(p))[0]
108
+ font_opt = f"fontfile={escape_filter_path(font_file)}" if font_file else f"font='{escape_drawtext(args.font)}'"
109
+ chain.append(f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
110
+ f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
111
+ f"box=1:boxcolor=black@0.5:boxborderw=4")
112
+ parts.append(f"[{i}:v]{','.join(chain)}[v{i}]")
113
+
114
+ cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
115
+ layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
116
+ parts.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}:fill={args.background}[out]")
117
+ if args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
118
+ parts.append(f"[{args.audio_from}:a:0]apad,atrim=duration={target_duration:.3f}[aout]")
119
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[out]"]
120
+
121
+ if args.audio_from is not None:
122
+ audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
123
+ cmd += ["-map", audio_source]
124
+ cmd += video_args(None, args.crf, args.preset)
125
+ cmd += cfr_args(None, args.fps)
126
+ if args.audio_from is not None:
127
+ cmd += aac_args()
128
+ else:
129
+ cmd += ["-an"]
130
+ cmd += ["-t", f"{target_duration:.3f}", output]
131
+ run(cmd)
132
+
133
+ result = probe(output, role="output")
134
+ v = result["video"]
135
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
136
+ f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
137
+ emit(output, cols=args.cols, rows=args.rows, clips=n)
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
package/scripts/join.py CHANGED
@@ -23,7 +23,7 @@ import argparse
23
23
  import sys
24
24
  from typing import List
25
25
 
26
- from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run
26
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color
27
27
 
28
28
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
29
29
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -101,9 +101,12 @@ def main() -> int:
101
101
  add_common(ap)
102
102
  args = ap.parse_args()
103
103
  apply_common(args)
104
+ if args.fps is not None and args.fps <= 0:
105
+ die(f"--fps must be positive, got {args.fps:g}")
104
106
 
105
107
  if len(args.inputs) < 2:
106
108
  die("give at least two clips")
109
+ validate_color(args.pad_color, "--pad-color")
107
110
  metas = [probe(p) for p in args.inputs]
108
111
  if all(not m.get("video") for m in metas):
109
112
  for p, m in zip(args.inputs, metas):
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Loop a clip a number of times, or to a target duration.
3
+
4
+ For a background loop, an ambient bed, or filling a fixed slot length with
5
+ a short clip. --times repeats the whole clip that many times back to back;
6
+ --duration instead loops (and, on the last repeat, trims) to hit an exact
7
+ target length. Audio loops along with the video when present. This tool
8
+ does not smooth the loop point (no crossfade at the seam) -- a clip that
9
+ doesn't already loop cleanly will show a visible cut/pop at each repeat;
10
+ that's a judgement call about the source material, not something a --times
11
+ or --duration flag can fix.
12
+
13
+ Examples:
14
+ python3 loop.py bg_loop.mp4 --times 3
15
+ python3 loop.py texture.mp4 --duration 30
16
+ """
17
+ import argparse
18
+ import math
19
+ import sys
20
+
21
+ from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
22
+
23
+
24
+ def main() -> int:
25
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
26
+ ap.add_argument("input")
27
+ ap.add_argument("-o", "--output", help="output file (default: <name>_loop.<ext>)")
28
+ group = ap.add_mutually_exclusive_group(required=True)
29
+ group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
30
+ group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
31
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
+ ap.add_argument("--preset", default="medium", help="x264 preset")
33
+ add_common(ap)
34
+ args = ap.parse_args()
35
+ apply_common(args)
36
+
37
+ meta = probe(args.input)
38
+ if not meta.get("video"):
39
+ die("input has no video stream")
40
+ src_dur = meta.get("duration") or 0.0
41
+ if src_dur <= 0:
42
+ die("input has no measurable duration to loop")
43
+ has_audio = bool(meta.get("audio"))
44
+ output = args.output or default_output(args.input, "loop")
45
+
46
+ if args.times is not None:
47
+ if args.times < 2:
48
+ die(f"--times must be >= 2 (1 is just the original clip), got {args.times}")
49
+ target = None
50
+ stream_loop = args.times - 1
51
+ else:
52
+ target = parse_time(args.duration)
53
+ if target <= src_dur:
54
+ die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
55
+ stream_loop = math.ceil(target / src_dur) - 1
56
+
57
+ # -stream_loop repeats the whole input read (video and audio together) at the demuxer level
58
+ # -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
59
+ # would need separate video/audio filters kept in lockstep by hand.
60
+ cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
61
+ if target is not None:
62
+ cmd += ["-t", f"{target:.3f}"]
63
+ cmd += video_args(meta, args.crf, args.preset)
64
+ cmd += cfr_args(meta)
65
+ if has_audio:
66
+ cmd += aac_args()
67
+ else:
68
+ cmd += ["-an"]
69
+ cmd.append(output)
70
+ run(cmd)
71
+
72
+ result = probe(output, role="output")
73
+ v = result["video"]
74
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
75
+ emit(output)
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
@@ -73,6 +73,8 @@ def main() -> int:
73
73
  add_common(ap)
74
74
  args = ap.parse_args()
75
75
  apply_common(args)
76
+ if args.fps is not None and args.fps <= 0:
77
+ die(f"--fps must be positive, got {args.fps:g}")
76
78
 
77
79
  n = len(args.inputs)
78
80
  if n < 2:
@@ -134,6 +136,8 @@ def main() -> int:
134
136
  if args.switch:
135
137
  cuts = parse_switch(args.switch, n)
136
138
  elif args.auto:
139
+ if args.auto <= 0:
140
+ die(f"--auto must be a positive number of seconds, got {args.auto:g}")
137
141
  cams = [i for i, m in enumerate(metas) if m.get("video")]
138
142
  cuts, t, k = [], 0.0, 0
139
143
  while t < ref_dur:
@@ -184,13 +188,17 @@ def main() -> int:
184
188
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
185
189
  a = args.audio
186
190
  a_start = -offsets[a] if offsets[a] < 0 else 0.0
187
- afx = []
191
+ # a_start is a trim point in the source's own, pre-drift-correction time axis, so it must be
192
+ # applied before asetrate/aresample rescale that axis -- otherwise the trim lands at the wrong
193
+ # point once the stream's timebase has already been stretched/compressed by the drift ratio
194
+ # (mirrors sync.py, which seeks with -ss, an input-level operation, before its drift_af filters).
195
+ afx = [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS"]
188
196
  if abs(ratios[a] - 1.0) > 1e-7:
189
197
  sr = metas[a]["audio"].get("sample_rate") or 48000
190
198
  afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
191
199
  if offsets[a] > 0:
192
200
  afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
193
- afx += [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS", f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
201
+ afx += [f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
194
202
  parts.append(f"[{a}:a]{','.join(afx)}[aout]")
195
203
 
196
204
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -163,6 +163,11 @@ def main() -> int:
163
163
  die("--opacity must be within 0..1")
164
164
  if args.chromakey and not args.video:
165
165
  die("--chromakey needs --video")
166
+ if args.chromakey:
167
+ validate_color(args.chromakey, "--chromakey")
168
+ validate_color(args.font_color, "--font-color")
169
+ validate_color(args.border_color, "--border-color")
170
+ validate_color(args.box_color, "--box-color")
166
171
  if not 0 < args.chromakey_similarity <= 1:
167
172
  die("--chromakey-similarity must be within (0, 1]")
168
173
  if not 0 <= args.chromakey_blend <= 1:
@@ -242,7 +247,7 @@ def main() -> int:
242
247
  if args.font_file:
243
248
  opts.append(f"fontfile={escape_filter_path(args.font_file)}")
244
249
  else:
245
- opts.append(f"font='{args.font}'")
250
+ opts.append(f"font='{escape_drawtext(args.font)}'")
246
251
  alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
247
252
  end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
248
253
  opts.append(f"fontcolor={args.font_color}")
package/scripts/pad.py ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ """Add black video / silent audio at the start and/or end of a clip.
3
+
4
+ Distinct from fit.py --fit pad, which pads the FRAME (letterbox/pillarbox
5
+ bars around each existing frame to reach a target aspect ratio) -- this
6
+ tool pads the TIMELINE (extra seconds of solid colour and silence before
7
+ and/or after the clip's existing content). Common uses: a beat of black
8
+ before a title card starts, room for a fade-in, aligning a clip to a fixed
9
+ slot length.
10
+
11
+ Examples:
12
+ python3 pad.py clip.mp4 --start 1.5 # 1.5s of black+silence before the clip
13
+ python3 pad.py clip.mp4 --end 2 # 2s of black+silence after the clip
14
+ python3 pad.py clip.mp4 --start 1 --end 1 --color 0x101010
15
+ """
16
+ import argparse
17
+ import sys
18
+
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
20
+
21
+
22
+ def main() -> int:
23
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
24
+ ap.add_argument("input")
25
+ ap.add_argument("-o", "--output", help="output file (default: <name>_pad.<ext>)")
26
+ ap.add_argument("--start", type=float, default=0.0, help="seconds of padding to add before the clip (default 0)")
27
+ ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
28
+ ap.add_argument("--color", default="black", help="padding colour (default black)")
29
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
30
+ ap.add_argument("--preset", default="medium", help="x264 preset")
31
+ add_common(ap)
32
+ args = ap.parse_args()
33
+ apply_common(args)
34
+
35
+ if args.start < 0 or args.end < 0:
36
+ die(f"--start/--end must be >= 0, got start={args.start:g} end={args.end:g}")
37
+ if args.start == 0 and args.end == 0:
38
+ die("--start and/or --end must be > 0 (nothing to pad)")
39
+ validate_color(args.color, "--color")
40
+
41
+ meta = probe(args.input)
42
+ if not meta.get("video"):
43
+ die("input has no video stream")
44
+ has_audio = bool(meta.get("audio"))
45
+ output = args.output or default_output(args.input, "pad")
46
+
47
+ vf = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color={args.color}"
48
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
49
+ if has_audio:
50
+ af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
51
+ cmd += ["-map", "0:a:0?", "-af", af]
52
+ cmd += video_args(meta, args.crf, args.preset)
53
+ cmd += cfr_args(meta)
54
+ if has_audio:
55
+ cmd += aac_args()
56
+ else:
57
+ cmd += ["-an"]
58
+ dropped_streams = run_keeping_subtitles(cmd, output)
59
+
60
+ result = probe(output, role="output")
61
+ v = result["video"]
62
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, +{args.start:g}s start / +{args.end:g}s end)")
63
+ emit(output, dropped_non_av_streams=dropped_streams)
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ sys.exit(main())
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """Blur or pixelate an exact pixel rectangle for the whole clip (privacy redaction, license plates, faces).
3
+
4
+ {x, y, width, height} are literal pixel offsets and dimensions in the SOURCE
5
+ frame, the same convention as crop.py -- this tool needs the rectangle
6
+ already known (a saved detection box, a hand-picked region); it does not
7
+ locate faces or plates itself. The rest of the frame is untouched.
8
+
9
+ --mode blur (default) applies a strong box blur inside the rectangle;
10
+ --mode pixelate mosaics it into large blocks -- the more recognisable,
11
+ unmistakably-redacted look often wanted for compliance/legal footage.
12
+ The region stays --mode blur/pixelate for the whole clip; for a region that
13
+ only needs covering part of the timeline, cut the clip into segments first
14
+ (cut.py) and redact only the relevant one.
15
+
16
+ Examples:
17
+ python3 redact.py interview.mp4 --x 820 --y 140 --width 240 --height 240
18
+ python3 redact.py dashcam.mp4 --x 0 --y 900 --width 400 --height 120 --mode pixelate --block-size 16
19
+ """
20
+ import argparse
21
+ import sys
22
+
23
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
24
+
25
+
26
+ def main() -> int:
27
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
28
+ ap.add_argument("input")
29
+ ap.add_argument("-o", "--output", help="output file (default: <name>_redact.<ext>)")
30
+ ap.add_argument("--x", type=int, required=True, help="left edge of the rectangle, in source pixels")
31
+ ap.add_argument("--y", type=int, required=True, help="top edge of the rectangle, in source pixels")
32
+ ap.add_argument("--width", type=int, required=True, help="rectangle width in px (must be even)")
33
+ ap.add_argument("--height", type=int, required=True, help="rectangle height in px (must be even)")
34
+ ap.add_argument("--mode", choices=["blur", "pixelate"], default="blur", help="blur (default) or pixelate the rectangle")
35
+ ap.add_argument("--blur-strength", type=int, default=20, help="box-blur radius in px, --mode blur only (default 20)")
36
+ ap.add_argument("--block-size", type=int, default=12, help="mosaic block size in px, --mode pixelate only (default 12)")
37
+ ap.add_argument("--audio-stream", type=int, default=0,
38
+ help="which audio stream of the input to keep, 0-based in file order (default 0)")
39
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
40
+ ap.add_argument("--preset", default="medium", help="x264 preset")
41
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
42
+ add_common(ap)
43
+ args = ap.parse_args()
44
+ apply_common(args)
45
+ if args.fps is not None and args.fps <= 0:
46
+ die(f"--fps must be positive, got {args.fps:g}")
47
+
48
+ if args.x < 0 or args.y < 0:
49
+ die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
50
+ if args.width <= 0 or args.height <= 0:
51
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
52
+ if args.width % 2 or args.height % 2:
53
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
54
+ if args.blur_strength <= 0:
55
+ die(f"--blur-strength must be > 0, got {args.blur_strength}")
56
+ if args.block_size <= 1:
57
+ die(f"--block-size must be > 1, got {args.block_size}")
58
+
59
+ meta = probe(args.input)
60
+ if not meta.get("video"):
61
+ die("input has no video stream")
62
+ sw, sh = meta["video"]["width"], meta["video"]["height"]
63
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
64
+ sw, sh = sh, sw
65
+ if args.x + args.width > sw or args.y + args.height > sh:
66
+ die(f"redaction rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
67
+ has_audio = bool(meta.get("audio"))
68
+ audio_streams = meta.get("audio_streams") or []
69
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
70
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
71
+ if args.audio_stream and not audio_streams:
72
+ die("--audio-stream needs an input with audio streams")
73
+
74
+ output = args.output or default_output(args.input, "redact")
75
+ crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
76
+ if args.mode == "blur":
77
+ region = f"{crop},boxblur={args.blur_strength}:{args.blur_strength}"
78
+ else:
79
+ region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
80
+ fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
81
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
82
+ if has_audio:
83
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
84
+ cmd += video_args(meta, args.crf, args.preset)
85
+ cmd += cfr_args(meta, args.fps)
86
+ if has_audio:
87
+ cmd += aac_args()
88
+ else:
89
+ cmd += ["-an"]
90
+ dropped_streams = run_keeping_subtitles(cmd, output)
91
+
92
+ result = probe(output, role="output")
93
+ v = result["video"]
94
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.mode} at x={args.x} y={args.y} {args.width}x{args.height})")
95
+ emit(output, dropped_non_av_streams=dropped_streams)
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
package/scripts/render.py CHANGED
@@ -130,7 +130,14 @@ def main() -> int:
130
130
  if not clips:
131
131
  die("project.clips is empty")
132
132
  output = rel(proj.get("output") or "final.mp4")
133
- work = Path(args.work) if args.work else Path(str(Path(output).with_suffix("")) + "_work")
133
+ # The default work dir name comes only from the output path, with no PID or timestamp --
134
+ # two concurrent render.py runs targeting the same output (a batch.py "project" recipe
135
+ # processing several files in parallel, or simply running render.py twice by mistake) shared
136
+ # the same work directory and clobbered each other's same-named intermediates (clip00.mp4,
137
+ # fit.mp4, ...) mid-run. An explicit --work is left as given (the caller asked for that exact,
138
+ # shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
139
+ # made unique per process, since it's the one that's also auto-deleted at the end.
140
+ work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
134
141
  work.mkdir(parents=True, exist_ok=True)
135
142
  frame = proj.get("frame") or {}
136
143
  trans = proj.get("transition") or {}
@@ -204,12 +211,14 @@ def main() -> int:
204
211
  fit.setdefault("aspect", frame["aspect"])
205
212
  if frame.get("width") and len(parts) == 1:
206
213
  fit.setdefault("width", frame["width"])
214
+ if frame.get("height") and len(parts) == 1:
215
+ fit.setdefault("height", frame["height"])
207
216
  if frame.get("fps") and len(parts) == 1:
208
217
  fit.setdefault("fps", frame["fps"])
209
218
  if fit:
210
219
  nxt = str(work / "fit.mp4")
211
220
  argv = [current, "-o", nxt]
212
- for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
221
+ for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
213
222
  if fit.get(k) is not None:
214
223
  argv += [flag, str(fit[k])]
215
224
  sh("fit.py", *argv)
@@ -361,7 +370,12 @@ def main() -> int:
361
370
  info(f"check: OK for {ck['platform']}")
362
371
  stages_done.append("check")
363
372
 
364
- if not args.keep and not args.work and not STATE["dry_run"]:
373
+ if not args.keep and not args.work:
374
+ # Also clean up on --dry-run: a dry run still creates this directory (and some steps,
375
+ # e.g. caption.py's .ass sidecar, write into it even under --dry-run), and now that the
376
+ # default name carries this process's PID, nothing else will ever reuse -- and so
377
+ # implicitly clean up -- a leftover dry-run directory the way a same-named real run used
378
+ # to before the PID suffix was added.
365
379
  import shutil
366
380
  shutil.rmtree(work, ignore_errors=True)
367
381
  info(f"rendered {output} via {' → '.join(stages_done)}")