ffmpeg-skill 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +291 -122
- package/SKILL.md +39 -7
- package/bin/install.js +1 -1
- package/package.json +15 -3
- package/scripts/_common.py +47 -3
- package/scripts/_contract.py +221 -41
- package/scripts/audio.py +100 -7
- package/scripts/caption.py +6 -0
- package/scripts/check.py +21 -7
- package/scripts/color.py +68 -4
- package/scripts/cut.py +83 -9
- package/scripts/export.py +15 -6
- package/scripts/fit.py +17 -3
- package/scripts/join.py +74 -3
- package/scripts/multicam.py +10 -0
- package/scripts/overlay.py +5 -0
- package/scripts/render.py +13 -2
- package/scripts/scenes.py +15 -3
- package/scripts/sync.py +8 -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
package/scripts/join.py
CHANGED
|
@@ -5,19 +5,81 @@ layout so mismatched sources (phone + camera + screen recording) cut together.
|
|
|
5
5
|
Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
|
|
6
6
|
slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
|
|
7
7
|
|
|
8
|
+
Audio-only inputs (WAV, FLAC, MP3, M4A, ...) are joined as audio: every clip is
|
|
9
|
+
resampled to one rate and channel layout (the first clip's rate, the widest
|
|
10
|
+
layout; --sample-rate / --channels override), crossfaded with acrossfade or
|
|
11
|
+
butted with concat, and written in the codec the output extension names. The
|
|
12
|
+
output of an audio join must be an audio extension; mixing audio and video
|
|
13
|
+
inputs is refused.
|
|
14
|
+
|
|
8
15
|
Examples:
|
|
9
16
|
python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
|
|
10
17
|
python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
|
|
11
18
|
python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
|
|
19
|
+
python3 join.py intro.wav talk.m4a outro.wav -o episode.flac # audio join, 0.5 s crossfade
|
|
20
|
+
python3 join.py part1.wav part2.wav --transition none -o full.wav # butt join, sample rate of part1
|
|
12
21
|
"""
|
|
13
22
|
import argparse
|
|
14
23
|
import sys
|
|
15
24
|
from typing import List
|
|
16
25
|
|
|
17
|
-
from _common import STATE, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, 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
|
|
18
27
|
|
|
19
28
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
20
29
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
30
|
+
LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
34
|
+
"""Concatenate audio-only inputs: one sample rate, one channel layout, acrossfade or concat."""
|
|
35
|
+
n = len(args.inputs)
|
|
36
|
+
durs = [m.get("duration") or 0.0 for m in metas]
|
|
37
|
+
d = args.duration if args.transition != "none" else 0.0
|
|
38
|
+
for p, dur in zip(args.inputs, durs):
|
|
39
|
+
if d and dur <= d * 2 and not STATE["dry_run"]:
|
|
40
|
+
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
|
|
41
|
+
rates = [m["audio"].get("sample_rate") or 48000 for m in metas]
|
|
42
|
+
chans = [m["audio"].get("channels") or 2 for m in metas]
|
|
43
|
+
rate = args.sample_rate or rates[0]
|
|
44
|
+
channels = args.channels or max(chans)
|
|
45
|
+
layout = LAYOUTS.get(channels)
|
|
46
|
+
if layout is None:
|
|
47
|
+
die(f"{channels}-channel output has no standard layout here (1, 2, 6 or 8); pass --channels")
|
|
48
|
+
if len(set(rates)) > 1:
|
|
49
|
+
info(f"sample rates differ ({', '.join(str(r) for r in rates)} Hz); resampling every clip to {rate} Hz")
|
|
50
|
+
if len(set(chans)) > 1:
|
|
51
|
+
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
52
|
+
output = args.output or default_output(args.inputs[0], "joined")
|
|
53
|
+
if not is_audio_output(output):
|
|
54
|
+
die(f"audio-only inputs cannot fill a video container: give -o an audio extension (.wav, .flac, .mp3, .m4a, .ogg, .opus), not {output}")
|
|
55
|
+
|
|
56
|
+
cmd = ffmpeg_base()
|
|
57
|
+
for p in args.inputs:
|
|
58
|
+
cmd += ["-i", p]
|
|
59
|
+
parts = [f"[{i}:a:0]aformat=sample_rates={rate}:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]" for i in range(n)]
|
|
60
|
+
if args.transition == "none":
|
|
61
|
+
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[aout]")
|
|
62
|
+
else:
|
|
63
|
+
prev = "a0"
|
|
64
|
+
for i in range(1, n):
|
|
65
|
+
out = f"ax{i}" if i < n - 1 else "aout"
|
|
66
|
+
parts.append(f"[{prev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{out}]")
|
|
67
|
+
prev = out
|
|
68
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
|
|
69
|
+
run(cmd)
|
|
70
|
+
expected = sum(durs) - d * (n - 1)
|
|
71
|
+
r = probe(output)
|
|
72
|
+
a = r.get("audio") or {}
|
|
73
|
+
if not STATE["dry_run"]:
|
|
74
|
+
if r.get("video"):
|
|
75
|
+
die(f"{output} unexpectedly contains a video stream")
|
|
76
|
+
if a.get("sample_rate") != rate or a.get("channels") != channels:
|
|
77
|
+
die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
|
|
78
|
+
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
|
|
79
|
+
+ ("crossfade" if d else "butt join") + ")")
|
|
80
|
+
emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
|
|
81
|
+
sample_rate=rate, channels=channels, video=False)
|
|
82
|
+
return 0
|
|
21
83
|
|
|
22
84
|
|
|
23
85
|
def main() -> int:
|
|
@@ -33,6 +95,9 @@ def main() -> int:
|
|
|
33
95
|
ap.add_argument("--pad-color", default="black")
|
|
34
96
|
ap.add_argument("--crf", type=int, default=18)
|
|
35
97
|
ap.add_argument("--preset", default="medium")
|
|
98
|
+
aud = ap.add_argument_group("audio-only inputs")
|
|
99
|
+
aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
|
|
100
|
+
aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
|
|
36
101
|
add_common(ap)
|
|
37
102
|
args = ap.parse_args()
|
|
38
103
|
apply_common(args)
|
|
@@ -40,9 +105,15 @@ def main() -> int:
|
|
|
40
105
|
if len(args.inputs) < 2:
|
|
41
106
|
die("give at least two clips")
|
|
42
107
|
metas = [probe(p) for p in args.inputs]
|
|
108
|
+
if all(not m.get("video") for m in metas):
|
|
109
|
+
for p, m in zip(args.inputs, metas):
|
|
110
|
+
if not m.get("audio"):
|
|
111
|
+
die(f"{p} has neither a video nor an audio stream")
|
|
112
|
+
return join_audio(args, metas)
|
|
43
113
|
for p, m in zip(args.inputs, metas):
|
|
44
114
|
if not m.get("video"):
|
|
45
|
-
|
|
115
|
+
others = [q for q, mm in zip(args.inputs, metas) if mm.get("video")]
|
|
116
|
+
die(f"{p} has no video stream" + (f" while {others[0]} has one; join audio with audio or give every clip a picture" if others else ""))
|
|
46
117
|
first = metas[0]["video"]
|
|
47
118
|
fw, fh = first["width"], first["height"]
|
|
48
119
|
if first.get("rotation") in (90, -90, 270, -270):
|
|
@@ -111,7 +182,7 @@ def main() -> int:
|
|
|
111
182
|
expected = sum(durs) - d * (n - 1)
|
|
112
183
|
r = probe(output)
|
|
113
184
|
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
114
|
-
emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
185
|
+
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
115
186
|
return 0
|
|
116
187
|
|
|
117
188
|
|
package/scripts/multicam.py
CHANGED
|
@@ -11,6 +11,14 @@ Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
|
|
|
11
11
|
reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
|
|
12
12
|
Gaps fall back to camera 0.
|
|
13
13
|
|
|
14
|
+
Each camera's `confidence` (in the report, and warned on stderr below 0.1)
|
|
15
|
+
is how well its audio matched the reference's, not a guarantee the cut lands
|
|
16
|
+
in sync: a source with no shared audio event (music-only vs. a silent room,
|
|
17
|
+
or two rooms recording different conversations) can score low and still get
|
|
18
|
+
an offset applied. Check it before trusting a low-confidence multicam edit.
|
|
19
|
+
This aligns audio tracks to each other, the same as sync.py, and does not
|
|
20
|
+
check lip sync (mouth movement vs. audio) at all -- see sync.py's docstring.
|
|
21
|
+
|
|
14
22
|
Examples:
|
|
15
23
|
python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
|
|
16
24
|
python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
|
|
@@ -108,6 +116,8 @@ def main() -> int:
|
|
|
108
116
|
ratios.append(ratio)
|
|
109
117
|
conf.append(score)
|
|
110
118
|
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
|
|
119
|
+
if score < 0.1:
|
|
120
|
+
info(f"warning: {p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before trusting this offset")
|
|
111
121
|
|
|
112
122
|
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
113
123
|
"confidence": [round(c, 3) for c in conf]}
|
package/scripts/overlay.py
CHANGED
|
@@ -165,6 +165,11 @@ def main() -> int:
|
|
|
165
165
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
166
166
|
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
167
167
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:0?", "-shortest"]
|
|
168
|
+
# -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
|
|
169
|
+
# of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
|
|
170
|
+
# The output must be as long as the main input, so say so explicitly.
|
|
171
|
+
if meta.get("duration"):
|
|
172
|
+
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
168
173
|
else:
|
|
169
174
|
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
170
175
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
package/scripts/render.py
CHANGED
|
@@ -36,6 +36,12 @@ graphics → overlays → audio → loudness → export → check. Missing stage
|
|
|
36
36
|
skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
37
37
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
38
38
|
|
|
39
|
+
"check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
|
|
40
|
+
itself failing to run) exits 1, same as running check.py directly would --
|
|
41
|
+
the render is not silently reported as successful just because every stage
|
|
42
|
+
up to it completed. The output file is still written and `--json`'s
|
|
43
|
+
`check` field still carries the full row-by-row result either way.
|
|
44
|
+
|
|
39
45
|
Examples:
|
|
40
46
|
python3 render.py --init project.json # write a commented starter project
|
|
41
47
|
python3 render.py project.json # render
|
|
@@ -338,14 +344,19 @@ def main() -> int:
|
|
|
338
344
|
# ---- check
|
|
339
345
|
ck = proj.get("check")
|
|
340
346
|
check_result = None
|
|
347
|
+
exit_code = 0
|
|
341
348
|
if ck and ck.get("platform") and not STATE["dry_run"]:
|
|
342
349
|
proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
343
350
|
try:
|
|
344
351
|
check_result = json.loads(proc.stdout)
|
|
345
352
|
except ValueError:
|
|
346
353
|
check_result = {"error": proc.stderr.strip()[-300:]}
|
|
347
|
-
if check_result.get("
|
|
354
|
+
if check_result.get("error"):
|
|
355
|
+
info(f"check: could not run check.py — {check_result['error']}")
|
|
356
|
+
exit_code = 1
|
|
357
|
+
elif check_result.get("failed"):
|
|
348
358
|
info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
|
|
359
|
+
exit_code = 1
|
|
349
360
|
else:
|
|
350
361
|
info(f"check: OK for {ck['platform']}")
|
|
351
362
|
stages_done.append("check")
|
|
@@ -355,7 +366,7 @@ def main() -> int:
|
|
|
355
366
|
shutil.rmtree(work, ignore_errors=True)
|
|
356
367
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
357
368
|
emit(output, stages=stages_done, check=check_result)
|
|
358
|
-
return
|
|
369
|
+
return exit_code
|
|
359
370
|
|
|
360
371
|
|
|
361
372
|
if __name__ == "__main__":
|
package/scripts/scenes.py
CHANGED
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
agent can plan an edit or a digest without watching the whole file.
|
|
4
4
|
|
|
5
5
|
Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
|
|
6
|
-
of the audio. Highlight candidates are
|
|
7
|
-
(
|
|
6
|
+
of the audio. Highlight candidates are scenes ranked by --rank-by: "audio"
|
|
7
|
+
(default, loudest first) or "duration" (longest first). Both are proxies,
|
|
8
|
+
not a judgement of what matters: "audio" misses a quiet but important
|
|
9
|
+
moment (a confession, a punchline landing in silence) and can surface pure
|
|
10
|
+
crowd noise; "duration" just finds long unbroken takes. Neither replaces
|
|
11
|
+
watching the contact sheet (--sheet) before committing to a cut.
|
|
8
12
|
|
|
9
13
|
Examples:
|
|
10
14
|
python3 scenes.py talk.mp4 # scenes + peaks, JSON
|
|
11
15
|
python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
|
|
12
16
|
python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
|
|
13
17
|
python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
|
|
18
|
+
python3 scenes.py talk.mp4 --highlights 5 --rank-by duration # longest unbroken scenes, not loudest
|
|
14
19
|
"""
|
|
15
20
|
import argparse
|
|
16
21
|
import math
|
|
@@ -96,6 +101,8 @@ def main() -> int:
|
|
|
96
101
|
ap.add_argument("--ratio", type=float, default=3.0, help="a cut must exceed this multiple of the neighbouring frames' median score (default 3; lower = more cuts)")
|
|
97
102
|
ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
|
|
98
103
|
ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
|
|
104
|
+
ap.add_argument("--rank-by", choices=["audio", "duration"], default="audio",
|
|
105
|
+
help="how to rank scenes for --highlights: audio energy (default) or scene duration")
|
|
99
106
|
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
100
107
|
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
101
108
|
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
@@ -136,7 +143,11 @@ def main() -> int:
|
|
|
136
143
|
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
137
144
|
|
|
138
145
|
if args.highlights:
|
|
139
|
-
|
|
146
|
+
if args.rank_by == "duration":
|
|
147
|
+
rank_key = lambda sc: (-sc["duration"], sc["start"])
|
|
148
|
+
else:
|
|
149
|
+
rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
|
|
150
|
+
ranked = sorted(scenes, key=rank_key)[: args.highlights]
|
|
140
151
|
picks: List[Tuple[float, float]] = []
|
|
141
152
|
budget = args.target if args.target else None
|
|
142
153
|
per = (budget / max(1, len(ranked))) if budget else args.max_scene
|
|
@@ -156,6 +167,7 @@ def main() -> int:
|
|
|
156
167
|
picks.sort()
|
|
157
168
|
result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
|
|
158
169
|
result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
|
|
170
|
+
result["highlights_rank_by"] = args.rank_by
|
|
159
171
|
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
160
172
|
if args.edl:
|
|
161
173
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
package/scripts/sync.py
CHANGED
|
@@ -9,6 +9,14 @@ Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
|
|
|
9
9
|
Offset semantics: a positive offset means the SECOND input starts LATER
|
|
10
10
|
than the reference, i.e. `second` must be shifted earlier by that amount.
|
|
11
11
|
|
|
12
|
+
This aligns two AUDIO tracks to each other; it does not check or guarantee
|
|
13
|
+
lip sync (mouth movement matching the audio). It assumes each recording's
|
|
14
|
+
own audio is already correctly timed against its own picture, which holds
|
|
15
|
+
for ordinary cameras and phones (same device, same clock) but not for a
|
|
16
|
+
capture device with its own internal audio/video offset. There is no
|
|
17
|
+
face or mouth detection anywhere in this codebase to verify that; the only
|
|
18
|
+
way to confirm the final result actually looks in sync is to watch it.
|
|
19
|
+
|
|
12
20
|
Examples:
|
|
13
21
|
python3 sync.py camera.mp4 lavmic.wav # print offset only
|
|
14
22
|
python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|