ffmpeg-skill 0.8.2 → 0.8.4
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -37,6 +37,9 @@ def die(msg: str, code: int = 1) -> "None":
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def info(msg: str) -> None:
|
|
40
|
+
# under --dry-run nothing is written; do not let scripts claim otherwise
|
|
41
|
+
if msg.startswith("wrote ") and STATE.dry_run:
|
|
42
|
+
msg = "[dry-run] would write " + msg[len("wrote "):]
|
|
40
43
|
sys.stderr.write(f"{msg}\n")
|
|
41
44
|
|
|
42
45
|
|
|
@@ -56,7 +59,46 @@ def require_tool(name: str) -> str:
|
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
59
|
-
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Context:
|
|
65
|
+
"""Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
|
|
66
|
+
|
|
67
|
+
Scripts read it as attributes (``STATE.dry_run``) or, for older call sites, like a dict
|
|
68
|
+
(``STATE["dry_run"]``). Keeping it a single explicit object rather than module globals makes
|
|
69
|
+
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
73
|
+
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self.reset()
|
|
77
|
+
|
|
78
|
+
def reset(self) -> None:
|
|
79
|
+
self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
|
|
80
|
+
self.json = False # emit() prints a JSON document instead of the output path
|
|
81
|
+
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
82
|
+
self.fast = False # x264 preset forced to veryfast
|
|
83
|
+
self.duration_hint: Optional[float] = None # expected output length, for the progress percent
|
|
84
|
+
self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
|
|
85
|
+
|
|
86
|
+
# mapping-style access kept for backwards compatibility
|
|
87
|
+
def __getitem__(self, key: str) -> Any:
|
|
88
|
+
if key not in self._KEYS:
|
|
89
|
+
raise KeyError(key)
|
|
90
|
+
return getattr(self, key)
|
|
91
|
+
|
|
92
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
93
|
+
if key not in self._KEYS:
|
|
94
|
+
raise KeyError(key)
|
|
95
|
+
setattr(self, key, value)
|
|
96
|
+
|
|
97
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
98
|
+
return getattr(self, key, default) if key in self._KEYS else default
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
STATE = Context()
|
|
60
102
|
|
|
61
103
|
|
|
62
104
|
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
@@ -69,19 +111,19 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
69
111
|
|
|
70
112
|
|
|
71
113
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
72
|
-
STATE
|
|
73
|
-
STATE
|
|
74
|
-
STATE
|
|
75
|
-
STATE
|
|
76
|
-
if STATE
|
|
114
|
+
STATE.dry_run = bool(getattr(args, "dry_run", False))
|
|
115
|
+
STATE.json = bool(getattr(args, "json", False))
|
|
116
|
+
STATE.progress = bool(getattr(args, "progress", False))
|
|
117
|
+
STATE.fast = bool(getattr(args, "fast", False))
|
|
118
|
+
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
77
119
|
args.preset = "veryfast"
|
|
78
120
|
|
|
79
121
|
|
|
80
122
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
81
123
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
82
|
-
if STATE
|
|
83
|
-
doc: Dict[str, Any] = {"output": output, "dry_run": STATE
|
|
84
|
-
if output and not STATE
|
|
124
|
+
if STATE.json:
|
|
125
|
+
doc: Dict[str, Any] = {"output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
126
|
+
if output and not STATE.dry_run and os.path.exists(output):
|
|
85
127
|
doc["probe"] = probe(output)
|
|
86
128
|
doc.update(extra)
|
|
87
129
|
print_json(doc)
|
|
@@ -89,32 +131,58 @@ def emit(output: Optional[str], **extra: Any) -> None:
|
|
|
89
131
|
print(output)
|
|
90
132
|
|
|
91
133
|
|
|
134
|
+
def _cmdline(cmd: Sequence[str]) -> str:
|
|
135
|
+
return " ".join(shell_quote(c) for c in cmd)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
139
|
+
return os.path.basename(cmd[0]).startswith("ffmpeg")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
143
|
+
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
144
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1)
|
|
145
|
+
|
|
146
|
+
|
|
92
147
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
93
148
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
94
149
|
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
|
|
151
|
+
(a fake successful CompletedProcess is returned so scripts can keep planning), and run
|
|
152
|
+
with a progress readout under --progress. ffprobe and other tools always run.
|
|
97
153
|
"""
|
|
98
|
-
is_ffmpeg =
|
|
154
|
+
is_ffmpeg = _is_ffmpeg(cmd)
|
|
99
155
|
if is_ffmpeg:
|
|
100
|
-
STATE
|
|
156
|
+
STATE.commands.append(_cmdline(cmd))
|
|
101
157
|
if not quiet:
|
|
102
|
-
info(("[dry-run] $ " if STATE
|
|
103
|
-
if STATE
|
|
158
|
+
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
159
|
+
if STATE.dry_run and is_ffmpeg:
|
|
104
160
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
105
|
-
if STATE
|
|
161
|
+
if STATE.progress and is_ffmpeg and cmd[-1] != "-":
|
|
106
162
|
return _run_with_progress(list(cmd), check)
|
|
163
|
+
return _run_captured(list(cmd), check)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
167
|
+
"""Plain run with stdout/stderr captured."""
|
|
107
168
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
108
169
|
if check and proc.returncode != 0:
|
|
109
|
-
|
|
110
|
-
die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
|
|
170
|
+
_fail(cmd, proc.returncode, proc.stderr)
|
|
111
171
|
return proc
|
|
112
172
|
|
|
113
173
|
|
|
174
|
+
def _progress_line(done: float, total: float, elapsed: float) -> str:
|
|
175
|
+
if total > 0:
|
|
176
|
+
pct = min(99.9, done / total * 100)
|
|
177
|
+
eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
|
|
178
|
+
return f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
|
|
179
|
+
return f"\r {done:7.1f}s encoded"
|
|
180
|
+
|
|
181
|
+
|
|
114
182
|
def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
115
183
|
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
|
|
116
184
|
import time
|
|
117
|
-
total = STATE.
|
|
185
|
+
total = STATE.duration_hint or 0.0
|
|
118
186
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
119
187
|
t0 = time.time()
|
|
120
188
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -126,13 +194,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
126
194
|
done = int(line.split("=")[1]) / 1_000_000
|
|
127
195
|
except ValueError:
|
|
128
196
|
continue
|
|
129
|
-
|
|
130
|
-
pct = min(99.9, done / total * 100)
|
|
131
|
-
elapsed = time.time() - t0
|
|
132
|
-
eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
|
|
133
|
-
msg = f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
|
|
134
|
-
else:
|
|
135
|
-
msg = f"\r {done:7.1f}s encoded"
|
|
197
|
+
msg = _progress_line(done, total, time.time() - t0)
|
|
136
198
|
if msg != last:
|
|
137
199
|
sys.stderr.write(msg)
|
|
138
200
|
sys.stderr.flush()
|
|
@@ -140,11 +202,9 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
140
202
|
_, err = proc.communicate()
|
|
141
203
|
if last:
|
|
142
204
|
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
143
|
-
result = subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
144
205
|
if check and proc.returncode != 0:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return result
|
|
206
|
+
_fail(cmd, proc.returncode, err)
|
|
207
|
+
return subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
148
208
|
|
|
149
209
|
|
|
150
210
|
def shell_quote(s: str) -> str:
|
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 load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
18
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -150,11 +150,13 @@ def main() -> int:
|
|
|
150
150
|
chain.append(f"scale={args.scale}:-1")
|
|
151
151
|
if args.opacity < 1:
|
|
152
152
|
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
153
|
-
if args.fade > 0
|
|
153
|
+
if args.fade > 0:
|
|
154
|
+
# no --start/--end: fade in at 0 and out at the end of the video
|
|
154
155
|
s = start if start is not None else 0.0
|
|
156
|
+
e = end if end is not None else (meta.get("duration") or 0.0)
|
|
155
157
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
156
|
-
if
|
|
157
|
-
chain.append(f"fade=t=out:st={
|
|
158
|
+
if e > args.fade:
|
|
159
|
+
chain.append(f"fade=t=out:st={e - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
158
160
|
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
159
161
|
ov = f"overlay={x}:{y}:format=auto"
|
|
160
162
|
if enable:
|
|
@@ -171,7 +173,8 @@ def main() -> int:
|
|
|
171
173
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
172
174
|
else:
|
|
173
175
|
opts.append(f"font='{args.font}'")
|
|
174
|
-
alpha = alpha_expr(args.opacity, start
|
|
176
|
+
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
|
|
177
|
+
end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
|
|
175
178
|
opts.append(f"fontcolor={args.font_color}")
|
|
176
179
|
if alpha != "1":
|
|
177
180
|
opts.append(f"alpha='{alpha}'")
|
|
@@ -185,8 +188,9 @@ def main() -> int:
|
|
|
185
188
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
186
189
|
cmd.append(output)
|
|
187
190
|
run(cmd)
|
|
188
|
-
|
|
189
|
-
|
|
191
|
+
if not STATE.dry_run:
|
|
192
|
+
result = probe(output)
|
|
193
|
+
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
190
194
|
emit(output)
|
|
191
195
|
return 0
|
|
192
196
|
|
package/scripts/sync.py
CHANGED
|
@@ -30,6 +30,22 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
30
30
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
33
|
+
# Scoring constants for coarse alignment. Both come from tests/bench_sync.py on real dialogue and
|
|
34
|
+
# music with +/-30 s offsets, gain, noise and EQ (see evals/results and CHANGELOG 0.8.0):
|
|
35
|
+
#
|
|
36
|
+
# MIN_OVERLAP_FRACTION: lags whose overlap with the other track is shorter than this share of the
|
|
37
|
+
# shorter track are never candidates. With the documented rule "analysis window >= 4x the largest
|
|
38
|
+
# expected offset" a true offset keeps >= 75 % overlap, so 0.35 costs nothing there, while the
|
|
39
|
+
# coincidental peaks on quasi-periodic material (music, tone beds) live below it. Raising it to
|
|
40
|
+
# 0.5 started rejecting true 28 s offsets in 60 s windows; lowering it to 0.2 let the partial
|
|
41
|
+
# matches back in (86 % -> 95 % of 60 s stress cases fixed by this alone).
|
|
42
|
+
# OVERLAP_WEIGHT_EXP: normalised similarity is multiplied by (overlap fraction) ** exponent so a
|
|
43
|
+
# perfect match over 55 % of the window cannot tie a perfect match over 100 %. 0.5 keeps a true
|
|
44
|
+
# 75 % overlap at x0.87 and a 53 % one (28 s in 60 s) at x0.73 while a coincidental 40 % match
|
|
45
|
+
# drops to x0.63; exponent 1.0 over-penalised large true offsets, 0.25 left exact ties.
|
|
46
|
+
MIN_OVERLAP_FRACTION = 0.35
|
|
47
|
+
OVERLAP_WEIGHT_EXP = 0.5
|
|
48
|
+
|
|
33
49
|
|
|
34
50
|
def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
35
51
|
ffmpeg = require_tool("ffmpeg")
|
|
@@ -119,10 +135,7 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
119
135
|
lr, lo = len(ref), len(other)
|
|
120
136
|
max_lag = min(max_lag, n // 2 - 1)
|
|
121
137
|
best_lag, best_val, second = 0, -float("inf"), -float("inf")
|
|
122
|
-
|
|
123
|
-
# largest expected offset) true offsets always keep >= 75 % overlap, while short-overlap lags are
|
|
124
|
-
# where coincidental matches on quasi-periodic material (music, tone beds) live
|
|
125
|
-
min_overlap = max(10, int(0.35 * min(lr, lo)))
|
|
138
|
+
min_overlap = max(10, int(MIN_OVERLAP_FRACTION * min(lr, lo))) # see constants above
|
|
126
139
|
scores = []
|
|
127
140
|
for lag in range(-max_lag, max_lag + 1):
|
|
128
141
|
# corr[lag] = sum_i ref[i] * other[i - lag] -> ref index range and other index range overlap:
|
|
@@ -135,10 +148,8 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
135
148
|
if denom <= 0:
|
|
136
149
|
continue
|
|
137
150
|
val = corr[lag % n].real / denom
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# offsets (28 s in 60 s = 53 % overlap -> x0.94) competitive while still breaking exact ties.
|
|
141
|
-
val *= ((r1 - r0) / min(lr, lo)) ** 0.5
|
|
151
|
+
val *= ((r1 - r0) / min(lr, lo)) ** OVERLAP_WEIGHT_EXP # longer overlap wins ties
|
|
152
|
+
|
|
142
153
|
scores.append((val, lag))
|
|
143
154
|
if val > best_val:
|
|
144
155
|
second = best_val
|