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.
@@ -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, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, 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}"),
@@ -144,6 +144,8 @@ def main() -> int:
144
144
  args.font = brand.get("font", args.font)
145
145
  if not args.font_file and brand.get("font_file"):
146
146
  args.font_file = brand["font_file"]
147
+ if not args.font_file:
148
+ args.font_file = default_font_file(args.font)
147
149
  meta = probe(args.input)
148
150
  if not meta.get("video"):
149
151
  die("input has no video stream")
@@ -161,6 +163,11 @@ def main() -> int:
161
163
  die("--opacity must be within 0..1")
162
164
  if args.chromakey and not args.video:
163
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")
164
171
  if not 0 < args.chromakey_similarity <= 1:
165
172
  die("--chromakey-similarity must be within (0, 1]")
166
173
  if not 0 <= args.chromakey_blend <= 1:
@@ -193,12 +200,19 @@ def main() -> int:
193
200
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
194
201
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
195
202
  fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
196
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?", "-shortest"]
197
- # -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
198
- # of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
199
- # The output must be as long as the main input, so say so explicitly.
203
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
200
204
  if meta.get("duration"):
205
+ # An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
206
+ # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
207
+ # must not be allowed to cut the whole output short via -shortest's "stop at whichever
208
+ # mapped stream finishes first" semantics.
201
209
  cmd += ["-t", f"{meta['duration']:.3f}"]
210
+ else:
211
+ # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
212
+ # the only thing stopping the looped still from running forever. FFmpeg 7+'s
213
+ # shortest_buf_duration slack (up to 10s) is an accepted imprecision here since there is
214
+ # no better bound available.
215
+ cmd += ["-shortest"]
202
216
  elif args.video:
203
217
  pip_meta = probe(args.video)
204
218
  if not pip_meta.get("video"):
@@ -219,9 +233,13 @@ def main() -> int:
219
233
  ov += f":enable='{enable}'"
220
234
  cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
221
235
  fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
222
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?", "-shortest"]
236
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
223
237
  if meta.get("duration"):
238
+ # See the --image branch above: -t (exact, bounds only the main input) instead of
239
+ # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
224
240
  cmd += ["-t", f"{meta['duration']:.3f}"]
241
+ else:
242
+ cmd += ["-shortest"]
225
243
  else:
226
244
  x, y = position_exprs(args.position, args.margin, text_mode=True)
227
245
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
@@ -229,7 +247,7 @@ def main() -> int:
229
247
  if args.font_file:
230
248
  opts.append(f"fontfile={escape_filter_path(args.font_file)}")
231
249
  else:
232
- opts.append(f"font='{args.font}'")
250
+ opts.append(f"font='{escape_drawtext(args.font)}'")
233
251
  alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
234
252
  end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
235
253
  opts.append(f"fontcolor={args.font_color}")
@@ -245,12 +263,11 @@ def main() -> int:
245
263
 
246
264
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
247
265
  cmd += aac_args() if meta.get("audio") else ["-an"]
248
- cmd.append(output)
249
- run(cmd)
266
+ dropped_streams = run_keeping_subtitles(cmd, output)
250
267
  if not STATE.dry_run:
251
268
  result = probe(output, role="output")
252
269
  info(f"wrote {output} ({result['duration']:.3f}s)")
253
- emit(output)
270
+ emit(output, dropped_non_av_streams=dropped_streams)
254
271
  return 0
255
272
 
256
273
 
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)}")
package/scripts/scenes.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import Dict, List, Tuple
28
28
 
29
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
29
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
30
30
 
31
31
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
32
32
 
@@ -107,6 +107,7 @@ def main() -> int:
107
107
  ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
108
108
  ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
109
109
  ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
110
+ ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
110
111
  add_common(ap)
111
112
  args = ap.parse_args()
112
113
  apply_common(args)
@@ -183,7 +184,12 @@ def main() -> int:
183
184
  # exactly one frame per scene: the frame index at the scene start
184
185
  fps = meta["video"].get("fps") or 30.0
185
186
  expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
186
- vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
187
+ stamp = ""
188
+ if not args.no_timecode:
189
+ default_font = default_font_file("DejaVu Sans")
190
+ font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
191
+ stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
192
+ vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
187
193
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
188
194
  run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
189
195
  info(f"wrote {args.sheet}")
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import video_args, aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
19
+ from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -109,9 +109,12 @@ def main() -> int:
109
109
  vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
110
110
  af = f"aselect='{expr}',asetpts=N/SR/TB"
111
111
  cmd = ffmpeg_base() + ["-i", args.input]
112
- if meta.get("video"):
112
+ audio_only = is_audio_output(output) or not meta.get("video")
113
+ if audio_only:
114
+ cmd += ["-vn"]
115
+ else:
113
116
  cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
114
- cmd += ["-af", af] + aac_args() + [output]
117
+ cmd += ["-af", af] + audio_codec_for(output) + [output]
115
118
  run(cmd)
116
119
  r = probe(output, role="output")
117
120
  info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Step through different constant speeds across a clip's timeline (a speed ramp).
3
+
4
+ Distinct from fit.py --duration --method speed, which applies one constant
5
+ factor to the whole clip. This tool takes a list of --segment START-END:FACTOR
6
+ pieces covering the clip start to end with no gaps or overlaps, each played
7
+ at its own constant speed (pitch-preserving audio, matching fit.py), then
8
+ concatenates them back together -- the classic "speed up, then slow way
9
+ down for the punch, then speed back up" edit, built from a few constant
10
+ segments rather than a continuous curve (which this tool does not attempt:
11
+ picking exactly where a ramp should ease in or out is a judgement call for
12
+ the calling agent, made concrete here as segment boundaries it supplies).
13
+
14
+ Examples:
15
+ python3 speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0
16
+ python3 speedramp.py clip.mp4 --segment 0-2:2.0 --segment 2-6:1.0
17
+ """
18
+ import argparse
19
+ import sys
20
+ from typing import List, Tuple
21
+
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
23
+
24
+ MAX_SPEED = 20.0
25
+ MIN_SPEED = 0.05
26
+
27
+
28
+ def atempo_chain(factor: float) -> str:
29
+ """atempo accepts 0.5..100 per instance; chain for factors outside that range."""
30
+ parts: List[str] = []
31
+ remaining = factor
32
+ while remaining < 0.5:
33
+ parts.append("atempo=0.5")
34
+ remaining /= 0.5
35
+ while remaining > 100.0:
36
+ parts.append("atempo=100.0")
37
+ remaining /= 100.0
38
+ parts.append(f"atempo={remaining:.6f}")
39
+ return ",".join(parts)
40
+
41
+
42
+ def parse_segment(raw: str) -> Tuple[float, float, float]:
43
+ try:
44
+ span, factor_s = raw.split(":")
45
+ start_s, end_s = span.split("-")
46
+ start, end, factor = float(start_s), float(end_s), float(factor_s)
47
+ except ValueError:
48
+ die(f"--segment must look like START-END:FACTOR, got '{raw}'")
49
+ if end <= start:
50
+ die(f"--segment {raw}: END must be after START")
51
+ if not MIN_SPEED <= factor <= MAX_SPEED:
52
+ die(f"--segment {raw}: FACTOR must be {MIN_SPEED}..{MAX_SPEED}")
53
+ return start, end, factor
54
+
55
+
56
+ def main() -> int:
57
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
58
+ ap.add_argument("input")
59
+ ap.add_argument("-o", "--output", help="output file (default: <name>_ramp.<ext>)")
60
+ ap.add_argument("--segment", action="append", required=True, dest="segments",
61
+ help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
62
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
63
+ ap.add_argument("--preset", default="medium", help="x264 preset")
64
+ add_common(ap)
65
+ args = ap.parse_args()
66
+ apply_common(args)
67
+
68
+ segments = [parse_segment(s) for s in args.segments]
69
+ segments.sort(key=lambda s: s[0])
70
+ meta = probe(args.input)
71
+ if not meta.get("video"):
72
+ die("input has no video stream")
73
+ dur = meta.get("duration") or 0.0
74
+ if abs(segments[0][0] - 0.0) > 0.01:
75
+ die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
76
+ if abs(segments[-1][1] - dur) > 0.5:
77
+ die(f"segments must cover the whole clip (0..{dur:.3f}), last segment ends at {segments[-1][1]:g}")
78
+ for i in range(len(segments) - 1):
79
+ if abs(segments[i][1] - segments[i + 1][0]) > 0.01:
80
+ die(f"segments must be contiguous with no gap/overlap: segment {i} ends at {segments[i][1]:g}, "
81
+ f"segment {i + 1} starts at {segments[i + 1][0]:g}")
82
+ has_audio = bool(meta.get("audio"))
83
+ output = args.output or default_output(args.input, "ramp")
84
+
85
+ vparts, aparts, labels = [], [], []
86
+ for i, (start, end, factor) in enumerate(segments):
87
+ vlabel, alabel = f"v{i}", f"a{i}"
88
+ end_expr = f"{end:.3f}" if i < len(segments) - 1 else None
89
+ trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
90
+ vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
91
+ labels.append(f"[{vlabel}]")
92
+ if has_audio:
93
+ atrim = f"atrim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
94
+ aparts.append(f"[0:a]{atrim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}[{alabel}]")
95
+
96
+ if has_audio:
97
+ concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
98
+ fc = ";".join(vparts + aparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=1[outv][outa]"
99
+ maps = ["-map", "[outv]", "-map", "[outa]"]
100
+ else:
101
+ concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
102
+ fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
103
+ maps = ["-map", "[outv]"]
104
+
105
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
106
+ cmd += video_args(meta, args.crf, args.preset)
107
+ cmd += ["-fps_mode", "cfr", "-r", f"{meta['video'].get('fps') or 30.0:g}"]
108
+ if has_audio:
109
+ cmd += aac_args()
110
+ else:
111
+ cmd += ["-an"]
112
+ cmd.append(output)
113
+ run(cmd)
114
+
115
+ result = probe(output, role="output")
116
+ v = result["video"]
117
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {len(segments)} speed segments)")
118
+ emit(output)
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a flat (rectilinear) viewport from a 360/spherical video.
3
+
4
+ This wraps FFmpeg's v360 filter for the one job it is most often needed for:
5
+ turning an equirectangular (or other spherical-projection) source into an
6
+ ordinary flat video pointed at a chosen direction -- the same "look this way"
7
+ operation a VR headset or a 360 video player's viewport does, baked into a
8
+ real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
9
+ the view is.
10
+
11
+ This tool does not decide WHERE to point the camera -- there is no subject
12
+ detection or tracking here, only the typed rotation/FOV you give it (see this
13
+ skill's design principles: it measures and transforms, it does not judge
14
+ "what's interesting" in a frame). For a shot that follows a moving subject,
15
+ call this once per keyframe viewpoint (or render a short segment per angle)
16
+ from outside this tool.
17
+
18
+ Nor does it detect whether an input actually IS a 360/spherical video --
19
+ probe.py's frame dimensions don't distinguish a 2:1 equirectangular capture
20
+ from an ordinary flat clip that happens to be that aspect ratio. Point
21
+ --input-projection at what the source actually is; wrong information here
22
+ produces a distorted or garbled output, not an error (a real limit of what a
23
+ frame's own pixels can prove about how they were projected -- ffmpeg's own
24
+ v360 filter has the same limit).
25
+
26
+ Examples:
27
+ python3 sphere.py insta360.mp4 --yaw 0 --pitch 0 -o front.mp4
28
+ python3 sphere.py insta360.mp4 --yaw 90 --h-fov 100 --v-fov 70 -o right_wide.mp4
29
+ python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
30
+ """
31
+ import argparse
32
+ import sys
33
+
34
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
35
+
36
+ # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
+ # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
38
+ # to have on hand, not the full list -- narrower, typed choices over an open string).
39
+ INPUT_PROJECTIONS = ["equirect", "fisheye", "dfisheye", "c3x2", "c6x1", "barrel", "cylindrical", "hequirect"]
40
+ INTERP_METHODS = ["nearest", "linear", "cubic", "lanczos", "spline16", "gaussian", "mitchell"]
41
+ STEREO_MODES = {"mono": "2d", "sbs": "sbs", "tb": "tb"}
42
+
43
+
44
+ def main() -> int:
45
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
46
+ ap.add_argument("input")
47
+ ap.add_argument("-o", "--output", help="output file (default: <name>_view.<ext>)")
48
+ ap.add_argument("--audio-stream", type=int, default=0,
49
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
50
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
51
+ "the first track")
52
+ ap.add_argument("--input-projection", choices=INPUT_PROJECTIONS, default="equirect",
53
+ help="the source's own 360 projection (default equirect, the most common capture/export format)")
54
+ ap.add_argument("--stereo", choices=list(STEREO_MODES), default="mono",
55
+ help="input stereo packing: mono (default), sbs (side-by-side), tb (top-bottom) -- always flattened to a mono output")
56
+ aim = ap.add_argument_group("camera aim")
57
+ aim.add_argument("--yaw", type=float, default=0.0, help="left/right rotation in degrees, -180..180 (default 0, straight ahead)")
58
+ aim.add_argument("--pitch", type=float, default=0.0, help="up/down rotation in degrees, -180..180 (default 0, level)")
59
+ aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
60
+ fov = ap.add_argument_group("field of view")
61
+ fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
62
+ fov.add_argument("--v-fov", type=float, default=60.0, help="output vertical field of view in degrees, 1..170 (default 60)")
63
+ out = ap.add_argument_group("output frame")
64
+ out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
65
+ out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
66
+ out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
67
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
68
+ ap.add_argument("--preset", default="medium", help="x264 preset")
69
+ ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
70
+ add_common(ap)
71
+ args = ap.parse_args()
72
+ apply_common(args)
73
+ if args.fps is not None and args.fps <= 0:
74
+ die(f"--fps must be positive, got {args.fps:g}")
75
+
76
+ if not -180 <= args.yaw <= 180:
77
+ die(f"--yaw must be -180..180, got {args.yaw}")
78
+ if not -180 <= args.pitch <= 180:
79
+ die(f"--pitch must be -180..180, got {args.pitch}")
80
+ if not -180 <= args.roll <= 180:
81
+ die(f"--roll must be -180..180, got {args.roll}")
82
+ if not 1 <= args.h_fov <= 170:
83
+ die(f"--h-fov must be 1..170, got {args.h_fov}")
84
+ if not 1 <= args.v_fov <= 170:
85
+ die(f"--v-fov must be 1..170, got {args.v_fov}")
86
+ if args.width <= 0 or args.height <= 0:
87
+ die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
88
+ if args.width % 2 or args.height % 2:
89
+ die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
90
+
91
+ meta = probe(args.input)
92
+ if not meta.get("video"):
93
+ die("input has no video stream")
94
+ has_audio = bool(meta.get("audio"))
95
+ audio_streams = meta.get("audio_streams") or []
96
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
97
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
98
+ if args.audio_stream and not audio_streams:
99
+ die("--audio-stream needs an input with audio streams")
100
+ output = args.output or default_output(args.input, "view")
101
+
102
+ v360 = (f"v360=input={args.input_projection}:output=rectilinear:"
103
+ f"in_stereo={STEREO_MODES[args.stereo]}:out_stereo=2d:"
104
+ f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
105
+ f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
106
+ f"w={args.width}:h={args.height}:interp={args.interp}")
107
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
108
+ if has_audio:
109
+ cmd += ["-map", f"0:a:{args.audio_stream}?"]
110
+ cmd += video_args(meta, args.crf, args.preset)
111
+ cmd += cfr_args(meta, args.fps)
112
+ if has_audio:
113
+ cmd += aac_args()
114
+ else:
115
+ cmd += ["-an"]
116
+ dropped_streams = run_keeping_subtitles(cmd, output)
117
+
118
+ result = probe(output, role="output")
119
+ v = result["video"]
120
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
121
+ emit(output, dropped_non_av_streams=dropped_streams)
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())