ffmpeg-skill 0.12.5 → 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/README.md +19 -6
- package/SKILL.md +14 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +160 -1
- package/scripts/_common.py +57 -3
- package/scripts/_contract.py +56 -5
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +37 -3
- package/scripts/check.py +8 -0
- package/scripts/color.py +9 -1
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +14 -3
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +7 -2
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- 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())
|
package/scripts/graphics.py
CHANGED
|
@@ -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:
|
package/scripts/grid.py
ADDED
|
@@ -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/loop.py
ADDED
|
@@ -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())
|
package/scripts/multicam.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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")
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)}")
|