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.2",
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",
@@ -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
- STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
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["dry_run"] = bool(getattr(args, "dry_run", False))
73
- STATE["json"] = bool(getattr(args, "json", False))
74
- STATE["progress"] = bool(getattr(args, "progress", False))
75
- STATE["fast"] = bool(getattr(args, "fast", False))
76
- if STATE["fast"] and getattr(args, "preset", None) in X264_PRESETS:
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["json"]:
83
- doc: Dict[str, Any] = {"output": output, "dry_run": STATE["dry_run"], "commands": list(STATE["commands"])}
84
- if output and not STATE["dry_run"] and os.path.exists(output):
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
- With --dry-run, ffmpeg invocations are printed and skipped (ffprobe still runs so
96
- scripts can plan); a fake successful CompletedProcess is returned.
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 = os.path.basename(cmd[0]).startswith("ffmpeg")
154
+ is_ffmpeg = _is_ffmpeg(cmd)
99
155
  if is_ffmpeg:
100
- STATE["commands"].append(" ".join(shell_quote(c) for c in cmd))
156
+ STATE.commands.append(_cmdline(cmd))
101
157
  if not quiet:
102
- info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
103
- if STATE["dry_run"] and is_ffmpeg:
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["progress"] and is_ffmpeg and cmd[-1] != "-":
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
- tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
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.get("duration_hint") or 0.0
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
- if total > 0:
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
- tail = "\n".join(err.strip().splitlines()[-15:])
146
- die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
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:
@@ -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 and (start is not None or end is not None):
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 end is not None:
157
- chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
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, end, args.fade)
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
- result = probe(output)
189
- info(f"wrote {output} ({result['duration']:.3f}s)")
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
- # ignore lags with less than 35 % overlap: with the documented rule (analysis window >= 4x the
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
- # mild preference for longer overlaps: a perfect match over 55 % of the window must not tie
139
- # with a perfect match over 100 % (quasi-periodic material). Exponent 0.5: with the window rule (>= 4x offset) a true match keeps >= 75 % overlap (x0.87) while a coincidental 55 % match drops to x0.74; keeps large true
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