ffmpeg-skill 0.12.0 → 0.16.12

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/scripts/export.py CHANGED
@@ -26,7 +26,7 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
- from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
29
+ from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
30
30
 
31
31
  PRESETS: Dict[str, Dict] = {
32
32
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -63,6 +63,7 @@ def main() -> int:
63
63
  return 0
64
64
  if not args.input or not args.preset:
65
65
  die("input and --preset are required (or use --list)")
66
+ validate_color(args.pad_color, "--pad-color")
66
67
 
67
68
  p = PRESETS[args.preset]
68
69
  meta = probe(args.input)
package/scripts/fit.py CHANGED
@@ -37,7 +37,7 @@ import sys
37
37
  from fractions import Fraction
38
38
  from typing import List
39
39
 
40
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
40
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
41
41
 
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
@@ -114,6 +114,7 @@ def main() -> int:
114
114
  die(f"--crop-x must be 0..1, got {args.crop_x}")
115
115
  if not 0.0 <= args.crop_y <= 1.0:
116
116
  die(f"--crop-y must be 0..1, got {args.crop_y}")
117
+ validate_color(args.pad_color, "--pad-color")
117
118
 
118
119
  meta = probe(args.input)
119
120
  if not meta.get("video"):
@@ -193,8 +194,18 @@ def main() -> int:
193
194
  out_h = even(args.height)
194
195
  out_w = even(out_h * ratio) if ratio else args.height
195
196
  elif ratio and src_ratio:
196
- out_w = even(sw if ratio <= src_ratio else sh * ratio)
197
- out_h = even(out_w / ratio)
197
+ # No explicit --width/--height: size the canvas to the new aspect without exceeding
198
+ # the source's own resolution in either dimension. A narrower/taller target than the
199
+ # source (e.g. 9:16 from a 16:9 source) must be bounded by the source's HEIGHT, not
200
+ # its width -- bounding by width there multiplies the height by src_ratio/ratio (a
201
+ # 1920x1080 source asked for 9:16 used to come out 1920x3414, a ~3.16x upscale in
202
+ # both fit=pad and fit=crop, entirely unrequested).
203
+ if ratio <= src_ratio:
204
+ out_h = even(sh)
205
+ out_w = even(out_h * ratio)
206
+ else:
207
+ out_w = even(sw)
208
+ out_h = even(out_w / ratio)
198
209
  else:
199
210
  out_w, out_h = even(sw), even(sh)
200
211
  if args.fit == "crop":
@@ -225,15 +236,24 @@ def main() -> int:
225
236
  cmd += aac_args()
226
237
  else:
227
238
  cmd += ["-an"]
228
- cmd += post + [output]
229
- run(cmd)
239
+ cmd += post
240
+ if abs(factor - 1.0) > 1e-4:
241
+ # A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
242
+ # original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
243
+ # no equivalent way to retime a copied subtitle track, so it would desync from the
244
+ # now-faster/slower picture. Drop them here rather than ship a captions track that lies
245
+ # about when a line is spoken.
246
+ run(cmd + [output])
247
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
248
+ else:
249
+ dropped_streams = run_keeping_subtitles(cmd, output)
230
250
 
231
251
  result = probe(output, role="output")
232
252
  msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
233
253
  if abs(factor - 1.0) > 1e-4:
234
254
  msg += f", speed {factor:.3f}x"
235
255
  info(msg)
236
- emit(output)
256
+ emit(output, dropped_non_av_streams=dropped_streams)
237
257
  return 0
238
258
 
239
259
 
@@ -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())
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -33,7 +33,10 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
33
33
  def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
34
  if font_file or brand.get("font_file"):
35
35
  return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
36
- return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
36
+ resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
37
+ if resolved:
38
+ return f"fontfile={escape_filter_path(resolved)}"
39
+ return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'"
37
40
 
38
41
 
39
42
  def main() -> int:
@@ -163,11 +166,10 @@ def main() -> int:
163
166
  cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
164
167
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
165
168
  cmd += aac_args() if meta.get("audio") else ["-an"]
166
- cmd.append(output)
167
- run(cmd)
169
+ dropped_streams = run_keeping_subtitles(cmd, output)
168
170
  r = probe(output, role="output")
169
171
  info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
170
- emit(output, template=args.template)
172
+ emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
171
173
  return 0
172
174
 
173
175
 
@@ -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):
package/scripts/look.py CHANGED
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import List
17
17
 
18
- from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
18
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -26,8 +26,8 @@ def fmt_hms(sec: float) -> str:
26
26
  return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
27
27
 
28
28
 
29
- def timecode_filter() -> str:
30
- return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
29
+ def timecode_filter(font_prefix: str) -> str:
30
+ return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -49,7 +49,12 @@ def main() -> int:
49
49
  dur = meta.get("duration") or 0.0
50
50
  stem = Path(args.input).stem
51
51
  outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
52
- tc = "" if args.no_timecode else "," + timecode_filter()
52
+ # a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
53
+ # own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
54
+ # nothing can be resolved, unchanged from before this existed.
55
+ default_font = default_font_file("DejaVu Sans")
56
+ font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
57
+ tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
53
58
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
54
59
  if meta["video"].get("hdr"):
55
60
  v = meta["video"]
@@ -67,8 +72,8 @@ def main() -> int:
67
72
  sec = parse_time(t)
68
73
  out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
69
74
  half = args.width // 2
70
- stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
71
- tcs = tc.replace("," + timecode_filter(), "") + stamp
75
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
76
+ tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
72
77
  fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
73
78
  f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
74
79
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
@@ -81,8 +86,8 @@ def main() -> int:
81
86
  if dur and sec > dur:
82
87
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
83
88
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
84
- stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
85
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(), '')}{stamp}", "-frames:v", "1", out]
89
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
90
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
86
91
  run(cmd)
87
92
  outputs.append(out)
88
93
  else:
@@ -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")