ffmpeg-skill 0.4.0 → 0.5.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 +7 -0
- package/SKILL.md +56 -11
- package/package.json +2 -2
- package/scripts/__pycache__/_common.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__/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__/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/_common.py +31 -5
- package/scripts/caption.py +2 -2
- package/scripts/check.py +163 -0
- package/scripts/color.py +2 -2
- package/scripts/cut.py +3 -3
- package/scripts/export.py +3 -1
- package/scripts/fit.py +4 -2
- package/scripts/join.py +16 -8
- package/scripts/look.py +7 -0
- package/scripts/loudness.py +3 -1
- package/scripts/multicam.py +4 -3
- package/scripts/overlay.py +3 -3
- package/scripts/render.py +333 -0
- package/scripts/scenes.py +155 -0
- package/scripts/silence.py +2 -2
- package/scripts/sync.py +4 -4
- package/scripts/verify.py +10 -1
package/scripts/loudness.py
CHANGED
|
@@ -18,11 +18,13 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
26
|
+
if STATE["dry_run"]:
|
|
27
|
+
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
|
|
26
28
|
ffmpeg = require_tool("ffmpeg")
|
|
27
29
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
28
30
|
proc = run(cmd, check=False)
|
package/scripts/multicam.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Tuple
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
24
|
+
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
25
25
|
from sync import measure_offset
|
|
26
26
|
|
|
27
27
|
|
|
@@ -154,7 +154,8 @@ def main() -> int:
|
|
|
154
154
|
w, h = h, w
|
|
155
155
|
fps = args.fps or v0.get("fps") or 30.0
|
|
156
156
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
157
|
-
|
|
157
|
+
pixfmt = "yuv420p10le" if v0.get("hdr") else "yuv420p"
|
|
158
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
|
|
158
159
|
|
|
159
160
|
cmd = ffmpeg_base()
|
|
160
161
|
for p in args.inputs:
|
|
@@ -184,7 +185,7 @@ def main() -> int:
|
|
|
184
185
|
|
|
185
186
|
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
186
187
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
187
|
-
cmd +=
|
|
188
|
+
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
|
|
188
189
|
run(cmd)
|
|
189
190
|
r = probe(output)
|
|
190
191
|
info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
|
package/scripts/overlay.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List, Optional
|
|
17
17
|
|
|
18
|
-
from _common import 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
|
|
18
|
+
from _common import 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
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -140,7 +140,7 @@ def main() -> int:
|
|
|
140
140
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
141
141
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
142
142
|
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
143
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a?", "-shortest"]
|
|
143
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:0?", "-shortest"]
|
|
144
144
|
else:
|
|
145
145
|
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
146
146
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
@@ -159,7 +159,7 @@ def main() -> int:
|
|
|
159
159
|
opts.append(f"enable='{enable}'")
|
|
160
160
|
cmd += ["-vf", "drawtext=" + ":".join(opts)]
|
|
161
161
|
|
|
162
|
-
cmd +=
|
|
162
|
+
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
163
163
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
164
164
|
cmd.append(output)
|
|
165
165
|
run(cmd)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Declarative edits: describe the whole edit in one project.json and render it
|
|
3
|
+
in one command. Change a number, re-render. Non-destructive: sources are never
|
|
4
|
+
touched, intermediates live in a work directory.
|
|
5
|
+
|
|
6
|
+
Project format (all keys optional except clips):
|
|
7
|
+
{
|
|
8
|
+
"output": "final.mp4",
|
|
9
|
+
"frame": {"aspect": "9:16", "width": 1080, "fps": 30},
|
|
10
|
+
"clips": [
|
|
11
|
+
{"src": "a.mp4", "in": "0:05", "out": "0:20"},
|
|
12
|
+
{"src": "b.mp4", "in": 3, "out": 12, "speed": 1.25},
|
|
13
|
+
{"src": "c.mp4"}
|
|
14
|
+
],
|
|
15
|
+
"transition": {"type": "fade", "duration": 0.5},
|
|
16
|
+
"silence": {"threshold": -38, "min_silence": 0.8},
|
|
17
|
+
"captions": {"text": "cues.txt", "srt": null, "animate": "pop", "karaoke": true, "font": "Noto Sans CJK JP", "size": 28, "position": "bottom"},
|
|
18
|
+
"overlays": [
|
|
19
|
+
{"image": "logo.png", "position": "top-right", "scale": 160, "opacity": 0.9},
|
|
20
|
+
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
21
|
+
],
|
|
22
|
+
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
|
|
23
|
+
"loudness": {"lufs": -14, "tp": -1},
|
|
24
|
+
"fit": {"duration": 60},
|
|
25
|
+
"export": {"preset": "reels"},
|
|
26
|
+
"check": {"platform": "reels"}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Stages run in this order: clips (cut) → join → silence → fit → captions →
|
|
30
|
+
overlays → audio → loudness → export → check. Missing stages are skipped.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
python3 render.py --init project.json # write a commented starter project
|
|
34
|
+
python3 render.py project.json # render
|
|
35
|
+
python3 render.py project.json --dry-run # show every command without rendering
|
|
36
|
+
python3 render.py project.json --fast # preview quality
|
|
37
|
+
"""
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Dict, List
|
|
45
|
+
|
|
46
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
47
|
+
|
|
48
|
+
HERE = Path(__file__).resolve().parent
|
|
49
|
+
|
|
50
|
+
TEMPLATE = {
|
|
51
|
+
"output": "final.mp4",
|
|
52
|
+
"frame": {"aspect": "16:9", "width": 1920, "fps": 30},
|
|
53
|
+
"clips": [{"src": "REPLACE_ME.mp4", "in": "0:00", "out": "0:30"}],
|
|
54
|
+
"transition": {"type": "fade", "duration": 0.5},
|
|
55
|
+
"silence": None,
|
|
56
|
+
"captions": None,
|
|
57
|
+
"overlays": [],
|
|
58
|
+
"audio": None,
|
|
59
|
+
"loudness": {"lufs": -14, "tp": -1},
|
|
60
|
+
"fit": None,
|
|
61
|
+
"export": {"preset": "youtube"},
|
|
62
|
+
"check": {"platform": "youtube"},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
67
|
+
"""Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
|
|
68
|
+
cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or [])
|
|
69
|
+
if STATE["fast"]:
|
|
70
|
+
cmd.append("--fast")
|
|
71
|
+
if STATE["dry_run"]:
|
|
72
|
+
cmd.append("--dry-run")
|
|
73
|
+
info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
74
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
75
|
+
for line in proc.stderr.splitlines():
|
|
76
|
+
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
77
|
+
STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
|
|
78
|
+
elif line.strip():
|
|
79
|
+
info(" " + line)
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
die(f"{script} failed")
|
|
82
|
+
out = proc.stdout.strip().splitlines()
|
|
83
|
+
return out[-1] if out else ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
88
|
+
ap.add_argument("project", nargs="?", help="project.json")
|
|
89
|
+
ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
|
|
90
|
+
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
91
|
+
ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
|
|
92
|
+
ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
|
|
93
|
+
add_common(ap)
|
|
94
|
+
args = ap.parse_args()
|
|
95
|
+
apply_common(args)
|
|
96
|
+
|
|
97
|
+
if args.init:
|
|
98
|
+
Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
99
|
+
info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
|
|
100
|
+
print(args.init)
|
|
101
|
+
return 0
|
|
102
|
+
if not args.project:
|
|
103
|
+
die("give a project.json (or --init FILE)")
|
|
104
|
+
try:
|
|
105
|
+
proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, ValueError) as exc:
|
|
107
|
+
die(f"cannot read project: {exc}")
|
|
108
|
+
base = Path(args.project).resolve().parent
|
|
109
|
+
|
|
110
|
+
def rel(p: Any) -> str:
|
|
111
|
+
p = str(p)
|
|
112
|
+
return p if os.path.isabs(p) else str(base / p)
|
|
113
|
+
|
|
114
|
+
clips = proj.get("clips") or []
|
|
115
|
+
if not clips:
|
|
116
|
+
die("project.clips is empty")
|
|
117
|
+
output = rel(proj.get("output") or "final.mp4")
|
|
118
|
+
work = Path(args.work) if args.work else Path(str(Path(output).with_suffix("")) + "_work")
|
|
119
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
frame = proj.get("frame") or {}
|
|
121
|
+
trans = proj.get("transition") or {}
|
|
122
|
+
stages_done: List[str] = []
|
|
123
|
+
|
|
124
|
+
# ---- clips
|
|
125
|
+
parts: List[str] = []
|
|
126
|
+
for i, c in enumerate(clips):
|
|
127
|
+
src = rel(c["src"])
|
|
128
|
+
if not STATE["dry_run"]:
|
|
129
|
+
probe(src)
|
|
130
|
+
needs_cut = c.get("in") is not None or c.get("out") is not None
|
|
131
|
+
part = str(work / f"clip{i:02d}.mp4")
|
|
132
|
+
if needs_cut:
|
|
133
|
+
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
134
|
+
if c.get("in") is not None:
|
|
135
|
+
argv += ["--start", c["in"]]
|
|
136
|
+
if c.get("out") is not None:
|
|
137
|
+
argv += ["--end", c["out"]]
|
|
138
|
+
sh("cut.py", *argv)
|
|
139
|
+
else:
|
|
140
|
+
part = src
|
|
141
|
+
if c.get("speed"):
|
|
142
|
+
spd = float(c["speed"])
|
|
143
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
|
|
144
|
+
fitted = str(work / f"clip{i:02d}_speed.mp4")
|
|
145
|
+
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
146
|
+
part = fitted
|
|
147
|
+
parts.append(part)
|
|
148
|
+
stages_done.append("clips")
|
|
149
|
+
current = parts[0]
|
|
150
|
+
if args.stop_after == "clips":
|
|
151
|
+
emit(current, stages=stages_done)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# ---- join
|
|
155
|
+
if len(parts) > 1:
|
|
156
|
+
current = str(work / "joined.mp4")
|
|
157
|
+
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
158
|
+
if frame.get("width"):
|
|
159
|
+
argv += ["--width", str(frame["width"])]
|
|
160
|
+
if frame.get("height"):
|
|
161
|
+
argv += ["--height", str(frame["height"])]
|
|
162
|
+
if frame.get("fps"):
|
|
163
|
+
argv += ["--fps", str(frame["fps"])]
|
|
164
|
+
sh("join.py", *argv)
|
|
165
|
+
stages_done.append("join")
|
|
166
|
+
if args.stop_after == "join":
|
|
167
|
+
emit(current, stages=stages_done)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
# ---- silence
|
|
171
|
+
sil = proj.get("silence")
|
|
172
|
+
if sil:
|
|
173
|
+
nxt = str(work / "tight.mp4")
|
|
174
|
+
argv = [current, "-o", nxt]
|
|
175
|
+
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
|
|
176
|
+
if sil.get(k) is not None:
|
|
177
|
+
argv += [flag, str(sil[k])]
|
|
178
|
+
sh("silence.py", *argv)
|
|
179
|
+
current = nxt
|
|
180
|
+
stages_done.append("silence")
|
|
181
|
+
if args.stop_after == "silence":
|
|
182
|
+
emit(current, stages=stages_done)
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
# ---- fit (duration and/or frame)
|
|
186
|
+
fit = dict(proj.get("fit") or {})
|
|
187
|
+
if frame.get("aspect"):
|
|
188
|
+
fit.setdefault("aspect", frame["aspect"])
|
|
189
|
+
if frame.get("width") and len(parts) == 1:
|
|
190
|
+
fit.setdefault("width", frame["width"])
|
|
191
|
+
if frame.get("fps") and len(parts) == 1:
|
|
192
|
+
fit.setdefault("fps", frame["fps"])
|
|
193
|
+
if fit:
|
|
194
|
+
nxt = str(work / "fit.mp4")
|
|
195
|
+
argv = [current, "-o", nxt]
|
|
196
|
+
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
197
|
+
if fit.get(k) is not None:
|
|
198
|
+
argv += [flag, str(fit[k])]
|
|
199
|
+
sh("fit.py", *argv)
|
|
200
|
+
current = nxt
|
|
201
|
+
stages_done.append("fit")
|
|
202
|
+
if args.stop_after == "fit":
|
|
203
|
+
emit(current, stages=stages_done)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
# ---- captions
|
|
207
|
+
cap = proj.get("captions")
|
|
208
|
+
if cap:
|
|
209
|
+
nxt = str(work / "captioned.mp4")
|
|
210
|
+
argv = [current, "-o", nxt]
|
|
211
|
+
if cap.get("text"):
|
|
212
|
+
argv += ["--text", rel(cap["text"])]
|
|
213
|
+
elif cap.get("srt"):
|
|
214
|
+
argv += ["--srt", rel(cap["srt"])]
|
|
215
|
+
elif cap.get("ass"):
|
|
216
|
+
argv += ["--ass", rel(cap["ass"])]
|
|
217
|
+
else:
|
|
218
|
+
die("captions needs text, srt or ass")
|
|
219
|
+
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline")):
|
|
220
|
+
if cap.get(k) is not None:
|
|
221
|
+
argv += [flag, str(cap[k])]
|
|
222
|
+
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
223
|
+
if cap.get(k):
|
|
224
|
+
argv.append(flag)
|
|
225
|
+
sh("caption.py", *argv)
|
|
226
|
+
current = nxt
|
|
227
|
+
stages_done.append("captions")
|
|
228
|
+
if args.stop_after == "captions":
|
|
229
|
+
emit(current, stages=stages_done)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
# ---- overlays
|
|
233
|
+
for i, ov in enumerate(proj.get("overlays") or []):
|
|
234
|
+
nxt = str(work / f"overlay{i:02d}.mp4")
|
|
235
|
+
argv = [current, "-o", nxt]
|
|
236
|
+
if ov.get("image"):
|
|
237
|
+
argv += ["--image", rel(ov["image"])]
|
|
238
|
+
elif ov.get("text"):
|
|
239
|
+
argv += ["--text", ov["text"]]
|
|
240
|
+
else:
|
|
241
|
+
die(f"overlays[{i}] needs image or text")
|
|
242
|
+
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin")):
|
|
243
|
+
if ov.get(k) is not None:
|
|
244
|
+
argv += [flag, str(ov[k])]
|
|
245
|
+
if ov.get("box"):
|
|
246
|
+
argv.append("--box")
|
|
247
|
+
sh("overlay.py", *argv)
|
|
248
|
+
current = nxt
|
|
249
|
+
if "overlays" not in stages_done:
|
|
250
|
+
stages_done.append("overlays")
|
|
251
|
+
if args.stop_after == "overlays":
|
|
252
|
+
emit(current, stages=stages_done)
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
# ---- audio
|
|
256
|
+
au = proj.get("audio")
|
|
257
|
+
if au:
|
|
258
|
+
nxt = str(work / "audio.mp4")
|
|
259
|
+
argv = [current, "-o", nxt]
|
|
260
|
+
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
261
|
+
if au.get(k):
|
|
262
|
+
argv += [flag, rel(au[k])]
|
|
263
|
+
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
264
|
+
if au.get(k) is not None:
|
|
265
|
+
argv += [flag, str(au[k])]
|
|
266
|
+
for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
267
|
+
if au.get(k):
|
|
268
|
+
argv.append(flag)
|
|
269
|
+
sh("audio.py", *argv)
|
|
270
|
+
current = nxt
|
|
271
|
+
stages_done.append("audio")
|
|
272
|
+
if args.stop_after == "audio":
|
|
273
|
+
emit(current, stages=stages_done)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
# ---- loudness
|
|
277
|
+
ld = proj.get("loudness")
|
|
278
|
+
if ld:
|
|
279
|
+
nxt = str(work / "loudnorm.mp4")
|
|
280
|
+
argv = [current, "-o", nxt]
|
|
281
|
+
if ld.get("lufs") is not None:
|
|
282
|
+
argv += ["-I", str(ld["lufs"])]
|
|
283
|
+
if ld.get("tp") is not None:
|
|
284
|
+
argv += ["--tp", str(ld["tp"])]
|
|
285
|
+
sh("loudness.py", *argv)
|
|
286
|
+
current = nxt
|
|
287
|
+
stages_done.append("loudness")
|
|
288
|
+
if args.stop_after == "loudness":
|
|
289
|
+
emit(current, stages=stages_done)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
# ---- export
|
|
293
|
+
ex = proj.get("export")
|
|
294
|
+
if ex and ex.get("preset"):
|
|
295
|
+
argv = [current, "--preset", ex["preset"], "-o", output]
|
|
296
|
+
if ex.get("fit"):
|
|
297
|
+
argv += ["--fit", ex["fit"]]
|
|
298
|
+
if ex.get("crf") is not None:
|
|
299
|
+
argv += ["--crf", str(ex["crf"])]
|
|
300
|
+
sh("export.py", *argv)
|
|
301
|
+
stages_done.append("export")
|
|
302
|
+
else:
|
|
303
|
+
if not STATE["dry_run"]:
|
|
304
|
+
import shutil
|
|
305
|
+
shutil.copyfile(current, output)
|
|
306
|
+
info(f"copied final stage to {output}")
|
|
307
|
+
current = output
|
|
308
|
+
|
|
309
|
+
# ---- check
|
|
310
|
+
ck = proj.get("check")
|
|
311
|
+
check_result = None
|
|
312
|
+
if ck and ck.get("platform") and not STATE["dry_run"]:
|
|
313
|
+
proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
314
|
+
try:
|
|
315
|
+
check_result = json.loads(proc.stdout)
|
|
316
|
+
except ValueError:
|
|
317
|
+
check_result = {"error": proc.stderr.strip()[-300:]}
|
|
318
|
+
if check_result.get("failed"):
|
|
319
|
+
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"))
|
|
320
|
+
else:
|
|
321
|
+
info(f"check: OK for {ck['platform']}")
|
|
322
|
+
stages_done.append("check")
|
|
323
|
+
|
|
324
|
+
if not args.keep and not args.work and not STATE["dry_run"]:
|
|
325
|
+
import shutil
|
|
326
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
327
|
+
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
328
|
+
emit(output, stages=stages_done, check=check_result)
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
sys.exit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find scene changes and loud moments, and propose highlight candidates so the
|
|
3
|
+
agent can plan an edit or a digest without watching the whole file.
|
|
4
|
+
|
|
5
|
+
Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
|
|
6
|
+
of the audio. Highlight candidates are the scenes ranked by audio energy
|
|
7
|
+
(and, optionally, by motion).
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
python3 scenes.py talk.mp4 # scenes + peaks, JSON
|
|
11
|
+
python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
|
|
12
|
+
python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
|
|
13
|
+
python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import struct
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Dict, List, Tuple
|
|
23
|
+
|
|
24
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
25
|
+
|
|
26
|
+
SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
|
|
30
|
+
ffmpeg = require_tool("ffmpeg")
|
|
31
|
+
proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
32
|
+
f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
|
|
33
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
34
|
+
times = [float(t) for t in SCENE_RE.findall(proc.stdout)]
|
|
35
|
+
cuts = [0.0]
|
|
36
|
+
for t in times:
|
|
37
|
+
if t - cuts[-1] >= min_len:
|
|
38
|
+
cuts.append(t)
|
|
39
|
+
if duration - cuts[-1] < min_len and len(cuts) > 1:
|
|
40
|
+
cuts.pop()
|
|
41
|
+
return cuts
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def audio_envelope(path: str, step_s: float) -> List[float]:
|
|
45
|
+
ffmpeg = require_tool("ffmpeg")
|
|
46
|
+
proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
|
|
47
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
48
|
+
n = len(proc.stdout) // 2
|
|
49
|
+
if n == 0:
|
|
50
|
+
return []
|
|
51
|
+
samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
|
|
52
|
+
step = max(1, int(8000 * step_s))
|
|
53
|
+
env = []
|
|
54
|
+
for i in range(0, n, step):
|
|
55
|
+
block = samples[i:i + step]
|
|
56
|
+
env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
|
|
57
|
+
return env
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> int:
|
|
61
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
62
|
+
ap.add_argument("input")
|
|
63
|
+
ap.add_argument("--threshold", type=float, default=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
|
|
64
|
+
ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
|
|
65
|
+
ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
|
|
66
|
+
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
67
|
+
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
68
|
+
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
69
|
+
ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
|
|
70
|
+
add_common(ap)
|
|
71
|
+
args = ap.parse_args()
|
|
72
|
+
apply_common(args)
|
|
73
|
+
|
|
74
|
+
meta = probe(args.input)
|
|
75
|
+
if not meta.get("video"):
|
|
76
|
+
die("input has no video stream")
|
|
77
|
+
dur = meta.get("duration") or 0.0
|
|
78
|
+
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
|
|
79
|
+
bounds = cuts + [dur]
|
|
80
|
+
step_s = 0.5
|
|
81
|
+
env = audio_envelope(args.input, step_s) if meta.get("audio") else []
|
|
82
|
+
|
|
83
|
+
scenes = []
|
|
84
|
+
for i in range(len(bounds) - 1):
|
|
85
|
+
s, e = bounds[i], bounds[i + 1]
|
|
86
|
+
if e - s <= 0.05:
|
|
87
|
+
continue
|
|
88
|
+
seg = env[int(s / step_s): max(int(s / step_s) + 1, int(e / step_s))] if env else []
|
|
89
|
+
energy = (sum(seg) / len(seg)) if seg else 0.0
|
|
90
|
+
peak = max(seg) if seg else 0.0
|
|
91
|
+
scenes.append({"index": len(scenes), "start": round(s, 3), "end": round(e, 3), "duration": round(e - s, 3),
|
|
92
|
+
"audio_rms": round(energy, 4), "audio_peak": round(peak, 4)})
|
|
93
|
+
peaks = []
|
|
94
|
+
if env:
|
|
95
|
+
thr = sorted(env)[int(len(env) * 0.9)] if len(env) > 10 else max(env)
|
|
96
|
+
for i, val in enumerate(env):
|
|
97
|
+
if val >= thr and val > 0.02 and (i == 0 or env[i - 1] < val) and (i == len(env) - 1 or env[i + 1] <= val):
|
|
98
|
+
peaks.append({"time": round(i * step_s, 2), "rms": round(val, 4)})
|
|
99
|
+
peaks = sorted(peaks, key=lambda p: -p["rms"])[:20]
|
|
100
|
+
peaks.sort(key=lambda p: p["time"])
|
|
101
|
+
|
|
102
|
+
result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
|
|
103
|
+
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
104
|
+
|
|
105
|
+
if args.highlights:
|
|
106
|
+
ranked = sorted(scenes, key=lambda sc: (-sc["audio_rms"], sc["start"]))[: args.highlights]
|
|
107
|
+
picks: List[Tuple[float, float]] = []
|
|
108
|
+
budget = args.target if args.target else None
|
|
109
|
+
per = (budget / max(1, len(ranked))) if budget else args.max_scene
|
|
110
|
+
for sc in ranked:
|
|
111
|
+
length = min(sc["duration"], per, args.max_scene)
|
|
112
|
+
# take the loudest window inside the scene
|
|
113
|
+
best_s = sc["start"]
|
|
114
|
+
if env and length < sc["duration"]:
|
|
115
|
+
best, best_s = -1.0, sc["start"]
|
|
116
|
+
win = max(1, int(length / step_s))
|
|
117
|
+
lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
|
|
118
|
+
for i in range(lo, hi + 1):
|
|
119
|
+
val = sum(env[i:i + win])
|
|
120
|
+
if val > best:
|
|
121
|
+
best, best_s = val, i * step_s
|
|
122
|
+
picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
|
|
123
|
+
picks.sort()
|
|
124
|
+
result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
|
|
125
|
+
result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
|
|
126
|
+
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
127
|
+
if args.edl:
|
|
128
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
129
|
+
for s, e in picks:
|
|
130
|
+
fh.write(f"{s:.2f}-{e:.2f}\n")
|
|
131
|
+
info(f"wrote {args.edl}")
|
|
132
|
+
|
|
133
|
+
if args.sheet:
|
|
134
|
+
n = len(scenes)
|
|
135
|
+
cols = min(4, max(1, n))
|
|
136
|
+
rows = max(1, math.ceil(n / cols))
|
|
137
|
+
tile_w = 1280 // cols // 2 * 2
|
|
138
|
+
# exactly one frame per scene: the frame index at the scene start
|
|
139
|
+
fps = meta["video"].get("fps") or 30.0
|
|
140
|
+
expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
|
|
141
|
+
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,"
|
|
142
|
+
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
143
|
+
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
144
|
+
info(f"wrote {args.sheet}")
|
|
145
|
+
result["sheet"] = args.sheet
|
|
146
|
+
|
|
147
|
+
if args.json:
|
|
148
|
+
emit(None, **result)
|
|
149
|
+
else:
|
|
150
|
+
print_json(result)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
package/scripts/silence.py
CHANGED
|
@@ -16,7 +16,7 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import 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, aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
|
|
20
20
|
|
|
21
21
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
22
|
|
|
@@ -110,7 +110,7 @@ def main() -> int:
|
|
|
110
110
|
af = f"aselect='{expr}',asetpts=N/SR/TB"
|
|
111
111
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
112
112
|
if meta.get("video"):
|
|
113
|
-
cmd += ["-vf", vf] +
|
|
113
|
+
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
114
114
|
cmd += ["-af", af] + aac_args() + [output]
|
|
115
115
|
run(cmd)
|
|
116
116
|
r = probe(output)
|
package/scripts/sync.py
CHANGED
|
@@ -26,7 +26,7 @@ import subprocess
|
|
|
26
26
|
import sys
|
|
27
27
|
from typing import List
|
|
28
28
|
|
|
29
|
-
from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
29
|
+
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
30
30
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
@@ -253,14 +253,14 @@ def main() -> int:
|
|
|
253
253
|
if proc.returncode != 0:
|
|
254
254
|
cmd = [c for c in cmd if c != "copy"]
|
|
255
255
|
idx = cmd.index("-c:v"); del cmd[idx]
|
|
256
|
-
cmd = cmd[:-1] +
|
|
256
|
+
cmd = cmd[:-1] + video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) + [output]
|
|
257
257
|
run(cmd)
|
|
258
258
|
else:
|
|
259
259
|
if head_trim > 0 and not drift_af:
|
|
260
260
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
|
|
261
261
|
proc = run(cmd, check=False)
|
|
262
262
|
if proc.returncode != 0:
|
|
263
|
-
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (
|
|
263
|
+
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
264
264
|
run(cmd)
|
|
265
265
|
else:
|
|
266
266
|
cmd = ffmpeg_base()
|
|
@@ -278,7 +278,7 @@ def main() -> int:
|
|
|
278
278
|
vf.append(f"setpts=PTS/{drift_ratio:.9f}")
|
|
279
279
|
if vf:
|
|
280
280
|
cmd += ["-vf", ",".join(vf)]
|
|
281
|
-
cmd +=
|
|
281
|
+
cmd += video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf)
|
|
282
282
|
if af_parts:
|
|
283
283
|
cmd += ["-af", ",".join(af_parts)]
|
|
284
284
|
cmd += audio_codec_for(output) + [output]
|
package/scripts/verify.py
CHANGED
|
@@ -47,6 +47,14 @@ def collect(paths: List[str]) -> List[Path]:
|
|
|
47
47
|
|
|
48
48
|
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
49
49
|
t0 = time.time()
|
|
50
|
+
if argv[0] == "__check_hdr__":
|
|
51
|
+
try:
|
|
52
|
+
v = probe(argv[1]).get("video") or {}
|
|
53
|
+
ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
|
|
54
|
+
err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
|
|
55
|
+
except SystemExit:
|
|
56
|
+
ok, err = False, "output missing"
|
|
57
|
+
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
50
58
|
try:
|
|
51
59
|
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
52
60
|
ok = proc.returncode == 0
|
|
@@ -110,7 +118,8 @@ def main() -> int:
|
|
|
110
118
|
plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
|
|
111
119
|
plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
|
|
112
120
|
if (meta.get("video") or {}).get("hdr"):
|
|
113
|
-
plan.append(("color to-sdr", ["color.py",
|
|
121
|
+
plan.append(("color to-sdr", ["color.py", str(f), "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
|
|
122
|
+
plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
|
|
114
123
|
plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
|
|
115
124
|
plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
|
|
116
125
|
if has_a and not args.quick:
|