ffmpeg-skill 3.8.15 → 3.8.17
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/docs/contract.md +3 -3
- package/mcp/server.py +143 -21
- package/package.json +1 -1
- package/references/gotchas.md +12 -2
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/alpha.py +1 -0
- package/scripts/_common/color.py +30 -1
- package/scripts/_common/decision.py +57 -0
- package/scripts/_common/emit.py +55 -6
- package/scripts/_common/probe.py +14 -1
- package/scripts/_common/runner.py +452 -80
- package/scripts/_common/wrap.py +24 -8
- package/scripts/_contract.py +18 -2
- package/scripts/batch.py +7 -1
- package/scripts/broll.py +5 -2
- package/scripts/caption.py +11 -7
- package/scripts/color.py +3 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +9 -3
- package/scripts/deinterlace.py +12 -7
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +23 -4
- package/scripts/fit.py +45 -7
- package/scripts/freeze.py +9 -7
- package/scripts/graphics.py +7 -4
- package/scripts/loop.py +7 -2
- package/scripts/metadata.py +5 -2
- package/scripts/overlay.py +92 -4
- package/scripts/pad.py +7 -2
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +5 -2
- package/scripts/render.py +24 -12
- package/scripts/silence.py +2 -2
- package/scripts/speedramp.py +49 -14
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +2 -2
- package/scripts/straighten.py +2 -2
- package/scripts/verify.py +32 -2
package/docs/contract.md
CHANGED
|
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
26
26
|
| Field | Meaning | Changes when |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
29
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.17`) | any release |
|
|
30
30
|
|
|
31
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
32
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
154
154
|
"deprecated": [],
|
|
155
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
156
156
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
157
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.17", "execution_mode": "local", "kind": "execution",
|
|
158
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
159
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
160
160
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -956,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
956
956
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
957
957
|
`output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
|
|
958
958
|
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
959
|
-
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
959
|
+
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `still_unreadable` (a file with an image name that ffmpeg cannot decode as one), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
960
960
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
961
961
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
962
962
|
and `english_only`), and `error.details`, the facts the message is written from, so a
|
package/mcp/server.py
CHANGED
|
@@ -19,6 +19,8 @@ Claude Desktop / Claude Code config example:
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
21
|
import sys
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
22
24
|
from pathlib import Path
|
|
23
25
|
from typing import Any, Dict, List, Optional
|
|
24
26
|
|
|
@@ -31,13 +33,22 @@ import _contract # noqa: E402 (the contract is the only source of tool names,
|
|
|
31
33
|
import _common # noqa: E402 (run_tool: the outer wall-clock ceiling on a dispatched tool)
|
|
32
34
|
|
|
33
35
|
_SPECS: Dict[str, Dict[str, Any]] = {}
|
|
36
|
+
_SPECS_LOCK = threading.Lock()
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def specs() -> Dict[str, Dict[str, Any]]:
|
|
37
|
-
"""ToolSpecs from the contract, generated once per process (argparse -> ToolSpec -> here).
|
|
40
|
+
"""ToolSpecs from the contract, generated once per process (argparse -> ToolSpec -> here).
|
|
41
|
+
|
|
42
|
+
Built under a lock and published whole: _contract.build() swaps the process-wide argparse
|
|
43
|
+
parse_args and sys.argv while it reads each tool's parser, so two first uses at once (a
|
|
44
|
+
tools/call on the worker thread and a tools/list on the reading loop) could each restore the
|
|
45
|
+
other's swap and let a tool's main() run for real (review of #530). main() also builds it
|
|
46
|
+
before the worker starts."""
|
|
38
47
|
if not _SPECS:
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
with _SPECS_LOCK:
|
|
49
|
+
if not _SPECS:
|
|
50
|
+
built = {spec["name"]: spec for spec in _contract.build(detect=False)["tools"]}
|
|
51
|
+
_SPECS.update(built)
|
|
41
52
|
return _SPECS
|
|
42
53
|
|
|
43
54
|
|
|
@@ -149,7 +160,9 @@ def timeout_from_call(args: Dict[str, Any]) -> float:
|
|
|
149
160
|
return _common._env_timeout()
|
|
150
161
|
|
|
151
162
|
|
|
152
|
-
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
163
|
+
def call_tool(name: str, args: Dict[str, Any], on_start: Any = None) -> Dict[str, Any]:
|
|
164
|
+
"""tools/call. `on_start(child)` is handed the tool's process once it runs (the stdio loop
|
|
165
|
+
keeps it, to stop the call if the client cancels it)."""
|
|
153
166
|
script = SCRIPTS / f"{name}.py"
|
|
154
167
|
if name not in specs() or not script.exists():
|
|
155
168
|
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
@@ -165,7 +178,8 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
165
178
|
env = {k: v for k, v in os.environ.items() if k != _contract.READ_ONLY_ENV}
|
|
166
179
|
if _contract.mcp_read_only(specs()[name]):
|
|
167
180
|
env[_contract.READ_ONLY_ENV] = "1"
|
|
168
|
-
|
|
181
|
+
started = {"on_start": on_start} if on_start is not None else {}
|
|
182
|
+
proc = _common.run_tool([str(script)] + argv, per_call=per_call, env=env, **started)
|
|
169
183
|
stdout = proc.stdout.strip()
|
|
170
184
|
text = stdout
|
|
171
185
|
structured = None
|
|
@@ -179,9 +193,13 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
179
193
|
failed: Dict[str, Any] = {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
|
|
180
194
|
# The child's own failure document (status, error.kind/code/hint, commands) is the
|
|
181
195
|
# machine-readable half of the contract; dropping it here left an MCP caller regex-
|
|
182
|
-
# parsing prose to tell a timeout from a missing binary.
|
|
196
|
+
# parsing prose to tell a timeout from a missing binary. structuredContent is a field of
|
|
197
|
+
# protocol revisions after the 2024-11-05 this server negotiates, so a client of that
|
|
198
|
+
# revision reads only `content`: the document is also a second text block there (A22,
|
|
199
|
+
# #465), as a success's document is the text block.
|
|
183
200
|
if isinstance(structured, dict):
|
|
184
201
|
failed["structuredContent"] = structured
|
|
202
|
+
failed["content"].append({"type": "text", "text": stdout})
|
|
185
203
|
return failed
|
|
186
204
|
if structured is None:
|
|
187
205
|
text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
|
|
@@ -206,7 +224,7 @@ def tool_list() -> List[Dict[str, Any]]:
|
|
|
206
224
|
return [_contract.mcp_tool(all_specs[name]) for name in names]
|
|
207
225
|
|
|
208
226
|
|
|
209
|
-
def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
227
|
+
def handle(req: Dict[str, Any], on_start: Any = None) -> Dict[str, Any]:
|
|
210
228
|
method = req.get("method")
|
|
211
229
|
params = req.get("params") or {}
|
|
212
230
|
if method == "initialize":
|
|
@@ -219,7 +237,7 @@ def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
219
237
|
if method == "tools/list":
|
|
220
238
|
return {"tools": tool_list()}
|
|
221
239
|
if method == "tools/call":
|
|
222
|
-
return call_tool(params.get("name", ""), params.get("arguments") or {})
|
|
240
|
+
return call_tool(params.get("name", ""), params.get("arguments") or {}, on_start=on_start)
|
|
223
241
|
if method == "prompts/list":
|
|
224
242
|
# roadmap 1.20.0: the five workflow recipes (reel, podcast, multicam, delivery_check, hdr)
|
|
225
243
|
return {"prompts": _contract.mcp_prompt_list()}
|
|
@@ -236,6 +254,101 @@ def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
236
254
|
raise KeyError(method)
|
|
237
255
|
|
|
238
256
|
|
|
257
|
+
# A24 (#465): tools/call runs on a worker thread, so the loop that reads requests keeps reading
|
|
258
|
+
# while a tool runs -- a ping is answered at once, and `notifications/cancelled` stops the call it
|
|
259
|
+
# names (its tool gets SIGTERM, stops its ffmpeg and removes its partial output, as on any SIGTERM)
|
|
260
|
+
# and no response is sent for it, as the protocol asks. The tools still run one at a time, in the
|
|
261
|
+
# order they were called, and answer in that order (one encode at a time, as before); a call
|
|
262
|
+
# cancelled while it waits for its turn never starts.
|
|
263
|
+
_QUEUE: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue()
|
|
264
|
+
_CALLS: Dict[str, Dict[str, Any]] = {} # request id (as JSON) -> {"proc": Popen or None, "cancelled": bool}
|
|
265
|
+
_CALLS_LOCK = threading.Lock()
|
|
266
|
+
_WRITE_LOCK = threading.Lock()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _key(request_id: Any) -> str:
|
|
270
|
+
return json.dumps(request_id, sort_keys=True)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _write(out: Any, resp: Dict[str, Any]) -> None:
|
|
274
|
+
with _WRITE_LOCK:
|
|
275
|
+
out.write((json.dumps(resp) + "\n").encode("utf-8"))
|
|
276
|
+
out.flush()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _stop(proc: Any) -> None:
|
|
280
|
+
"""Stop a cancelled call's tool: on POSIX SIGTERM, whose handler stops its ffmpeg and removes
|
|
281
|
+
the partial output and the lock; on Windows, where no handler runs, its whole process tree
|
|
282
|
+
(taskkill /T /F), so its ffmpeg does not go on encoding beside the next call. Returns once that
|
|
283
|
+
has been done (runner.terminate_tree())."""
|
|
284
|
+
try:
|
|
285
|
+
_common.runner.terminate_tree(proc)
|
|
286
|
+
except OSError:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cancel(params: Any) -> None:
|
|
291
|
+
"""notifications/cancelled: stop the named call if it is running, or keep it from starting."""
|
|
292
|
+
if not isinstance(params, dict) or "requestId" not in params:
|
|
293
|
+
return
|
|
294
|
+
with _CALLS_LOCK:
|
|
295
|
+
entry = _CALLS.get(_key(params["requestId"]))
|
|
296
|
+
if entry is None:
|
|
297
|
+
return # finished already, or never seen: nothing to stop
|
|
298
|
+
entry["cancelled"] = True
|
|
299
|
+
proc = entry.get("proc")
|
|
300
|
+
if proc is None:
|
|
301
|
+
return # not started: the worker never starts it
|
|
302
|
+
stopped = entry["stopped"] = threading.Event()
|
|
303
|
+
try:
|
|
304
|
+
_stop(proc)
|
|
305
|
+
finally:
|
|
306
|
+
stopped.set() # the worker waits for this before it starts the next call
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _respond(req: Dict[str, Any], on_start: Any = None) -> Dict[str, Any]:
|
|
310
|
+
try:
|
|
311
|
+
return {"jsonrpc": "2.0", "id": req["id"], "result": handle(req, on_start=on_start)}
|
|
312
|
+
except KeyError as exc:
|
|
313
|
+
return {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": f"method not found: {exc}"}}
|
|
314
|
+
except Exception as exc: # noqa: BLE001
|
|
315
|
+
return {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32000, "message": str(exc)}}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _call_worker(out: Any) -> None:
|
|
319
|
+
"""Run the queued tools/call requests one at a time until the None that ends the input."""
|
|
320
|
+
while True:
|
|
321
|
+
item = _QUEUE.get()
|
|
322
|
+
if item is None:
|
|
323
|
+
return
|
|
324
|
+
req, entry = item["req"], item["entry"]
|
|
325
|
+
|
|
326
|
+
def started(proc: Any, entry: Dict[str, Any] = entry) -> None:
|
|
327
|
+
with _CALLS_LOCK:
|
|
328
|
+
entry["proc"] = proc
|
|
329
|
+
cancelled = entry["cancelled"]
|
|
330
|
+
if cancelled:
|
|
331
|
+
_stop(proc)
|
|
332
|
+
|
|
333
|
+
resp = None
|
|
334
|
+
try:
|
|
335
|
+
if not entry["cancelled"]:
|
|
336
|
+
resp = _respond(req, on_start=started)
|
|
337
|
+
# a call cancelled while it ran: the next one starts only once the stop is done (on
|
|
338
|
+
# Windows the tree kill may still be ending the tool's ffmpeg after the tool exited)
|
|
339
|
+
with _CALLS_LOCK:
|
|
340
|
+
stopped = entry.get("stopped")
|
|
341
|
+
if stopped is not None:
|
|
342
|
+
stopped.wait(_common.runner.TREE_KILL_TIMEOUT + 5)
|
|
343
|
+
finally:
|
|
344
|
+
with _CALLS_LOCK:
|
|
345
|
+
key = _key(req["id"])
|
|
346
|
+
if _CALLS.get(key) is entry:
|
|
347
|
+
del _CALLS[key]
|
|
348
|
+
if resp is not None and not entry["cancelled"]:
|
|
349
|
+
_write(out, resp)
|
|
350
|
+
|
|
351
|
+
|
|
239
352
|
def version() -> str:
|
|
240
353
|
try:
|
|
241
354
|
return json.loads((HERE.parent / "package.json").read_text())["version"]
|
|
@@ -256,6 +369,12 @@ def main() -> int:
|
|
|
256
369
|
return 0
|
|
257
370
|
stdin = sys.stdin.buffer
|
|
258
371
|
stdout = sys.stdout.buffer
|
|
372
|
+
try:
|
|
373
|
+
specs() # built here, once, before the worker thread can need it too (review of #530)
|
|
374
|
+
except Exception: # noqa: BLE001
|
|
375
|
+
pass # a contract that cannot be built fails each request that needs it (under the lock), not the server
|
|
376
|
+
worker = threading.Thread(target=_call_worker, args=(stdout,), daemon=True)
|
|
377
|
+
worker.start()
|
|
259
378
|
for raw in stdin:
|
|
260
379
|
line = raw.decode("utf-8", errors="replace").strip()
|
|
261
380
|
if not line:
|
|
@@ -265,10 +384,9 @@ def main() -> int:
|
|
|
265
384
|
except ValueError as exc:
|
|
266
385
|
# JSON-RPC 2.0: a line that is not JSON is answered with -32700 and id null, not
|
|
267
386
|
# dropped -- a client whose request was mangled in transit otherwise waits for an
|
|
268
|
-
# answer that never comes, with nothing to say why
|
|
269
|
-
|
|
270
|
-
stdout.
|
|
271
|
-
stdout.flush()
|
|
387
|
+
# answer that never comes, with nothing to say why. Through _write(), as every
|
|
388
|
+
# response: the worker writes too, and a line written beside its own would split it
|
|
389
|
+
_write(stdout, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"parse error: {exc}"}})
|
|
272
390
|
continue
|
|
273
391
|
# json.loads accepts any valid JSON value, not just an object -- a bare `42`, `null`,
|
|
274
392
|
# `true` or `[1,2]` line parses fine but isn't a JSON-RPC request. Without this guard,
|
|
@@ -279,16 +397,20 @@ def main() -> int:
|
|
|
279
397
|
if not isinstance(req, dict):
|
|
280
398
|
continue
|
|
281
399
|
if "id" not in req: # notification
|
|
400
|
+
# only a JSON-RPC 2.0 notification cancels a call (review of #530: a "jsonrpc": "1.0"
|
|
401
|
+
# line stopped the running tool); anything else is ignored, as before
|
|
402
|
+
if req.get("jsonrpc") == "2.0" and req.get("method") == "notifications/cancelled":
|
|
403
|
+
cancel(req.get("params"))
|
|
282
404
|
continue
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
405
|
+
if req.get("method") == "tools/call":
|
|
406
|
+
entry = {"proc": None, "cancelled": False}
|
|
407
|
+
with _CALLS_LOCK:
|
|
408
|
+
_CALLS[_key(req["id"])] = entry
|
|
409
|
+
_QUEUE.put({"req": req, "entry": entry})
|
|
410
|
+
continue
|
|
411
|
+
_write(stdout, _respond(req))
|
|
412
|
+
_QUEUE.put(None) # end of input: the calls already queued run and answer before the server exits
|
|
413
|
+
worker.join()
|
|
292
414
|
return 0
|
|
293
415
|
|
|
294
416
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.17",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/gotchas.md
CHANGED
|
@@ -117,8 +117,18 @@ wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
|
117
117
|
|
|
118
118
|
### Variable frame rate
|
|
119
119
|
`probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
|
|
120
|
-
`avg_frame_rate` disagree (phone and screen recordings).
|
|
121
|
-
|
|
120
|
+
`avg_frame_rate` disagree (phone and screen recordings). On that flag `crop`, `denoise`,
|
|
121
|
+
`deinterlace`, `proxy`, `sphere`, `stabilize`, `straighten`, `redact`, `overlay`, `caption`,
|
|
122
|
+
`color`, `graphics`, `pad`, `freeze`, `loop`, `broll`, `cut` (a re-encode, `--segments`),
|
|
123
|
+
`export`, `fit` and `speedramp` conform the picture with the `fps` filter (the nearest frame
|
|
124
|
+
for each slot; `-fps_mode cfr` alone let a frame run up to two frames late against the sound,
|
|
125
|
+
#512); `join`, `multicam` and `grid` put each input on the output's rate with their own `fps`
|
|
126
|
+
filter. `reverse.py` is not changed: its `fps` filter comes after `reverse`, which hands the frames
|
|
127
|
+
the forward timestamps in order, so a reversed VFR picture keeps its gaps where they were in
|
|
128
|
+
forward time (each flash 0.19 s late on
|
|
129
|
+
a 27 fps average clip; see docs/design-decisions.md). The rate is `cfr_args()`'s: the probed
|
|
130
|
+
average, written as the nearest whole number when it is within 0.02 of one (29.98 is
|
|
131
|
+
conformed to 30, 29.97 stays 29.97), and 30 when no average was probed. That flag is a
|
|
122
132
|
whole-file average, and a phone clip at 29.98 against a nominal 30 trips it, so
|
|
123
133
|
`cut.py` decides on packet timestamps sampled in up to five windows (`vfr_check`)
|
|
124
134
|
instead: by default (`--vfr-guard sampled`, since 3.0.0) it switches to
|
|
@@ -83,7 +83,7 @@ from _common.decision import (
|
|
|
83
83
|
BEAT_REFRACTORY_S, BEAT_WINDOW_S, BEAT_SUPPORT_DIVISOR, BEAT_ALIGN_DIVISOR,
|
|
84
84
|
BEAT_Z_FLOOR, BEAT_Z_SPAN,
|
|
85
85
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
86
|
-
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
86
|
+
brand_states_font, cfr_args, cfr_at_input, cfr_filter, graph_with_cfr, with_cfr, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
87
87
|
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
88
88
|
signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
89
89
|
)
|
|
@@ -182,7 +182,7 @@ __all__ = [
|
|
|
182
182
|
"platform", "re", "shutil", "subprocess", "sys", "unicodedata",
|
|
183
183
|
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "LEGAL_RANGE_CHOICES", "LEGAL_RANGES", "legal_limits", "measure_signal_range", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
184
184
|
"audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
|
|
185
|
-
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
|
|
185
|
+
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "cfr_at_input", "cfr_filter", "graph_with_cfr", "with_cfr", "_char_em",
|
|
186
186
|
"char_script", "_check_existing_output", "refuse_existing_outputs", "guard_sidecar", "_check_no_overwrite_input", "_check_output_path", "child_args", "option_args",
|
|
187
187
|
"child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
|
|
188
188
|
"concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
|
package/scripts/_common/alpha.py
CHANGED
|
@@ -122,6 +122,7 @@ def frame_means(cmd_head: List[str], path: str, chain: str) -> "Dict[str, object
|
|
|
122
122
|
# Read frame by frame as ffmpeg writes them, not the whole pipe at once: the raw copy is 2304
|
|
123
123
|
# bytes a frame (~250 MB for an hour at 30 fps), the per-frame means a float each. The same
|
|
124
124
|
# wall-clock limit as run_analysis(), which this replaces here.
|
|
125
|
+
cmd = _runner.guard_still_patterns(cmd) # a still named with a % is that one file (#465 A7)
|
|
125
126
|
_runner._refuse_url_media(cmd)
|
|
126
127
|
limit = _runner._limit_for(cmd)
|
|
127
128
|
size = w * h
|
package/scripts/_common/color.py
CHANGED
|
@@ -372,11 +372,40 @@ def validate_color(value: str, flag: str = "--color") -> str:
|
|
|
372
372
|
demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
|
|
373
373
|
text), not just an odd colour. This is the same "no filter graph accepted from the caller"
|
|
374
374
|
invariant every other typed flag in this codebase already holds to."""
|
|
375
|
-
|
|
375
|
+
m = _COLOR_TOKEN_RE.match(value)
|
|
376
|
+
if not m:
|
|
376
377
|
die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
|
|
378
|
+
word = m.group(1)
|
|
379
|
+
if word[0] not in "#0" and word.lower() not in FFMPEG_COLOR_NAMES and not _BARE_HEX.match(word):
|
|
380
|
+
# A189 (#465): any word passed, and an unknown name failed inside ffmpeg ("Cannot find
|
|
381
|
+
# color") as kind ffmpeg, after a dry run that had said completed. ffmpeg reads a word as
|
|
382
|
+
# one of its names, or else as bare hex digits.
|
|
383
|
+
die(f"{flag} {value!r}: {word!r} is not a colour name ffmpeg knows (`ffmpeg -colors` lists them) "
|
|
384
|
+
"nor six or eight hex digits; use a name such as white, or 0xRRGGBB")
|
|
377
385
|
return value
|
|
378
386
|
|
|
379
387
|
|
|
388
|
+
# The names av_parse_color() looks up, case-insensitively (`ffmpeg -colors`, the same list in 6.1
|
|
389
|
+
# through 8), plus the two it treats as a random colour. A word that is none of them is read as
|
|
390
|
+
# hex digits without a prefix, which works for six or eight of them.
|
|
391
|
+
FFMPEG_COLOR_NAMES = frozenset((
|
|
392
|
+
"aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown "
|
|
393
|
+
"burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan "
|
|
394
|
+
"darkgoldenrod darkgray darkgreen darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred "
|
|
395
|
+
"darksalmon darkseagreen darkslateblue darkslategray darkturquoise darkviolet deeppink deepskyblue dimgray "
|
|
396
|
+
"dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green "
|
|
397
|
+
"greenyellow honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon "
|
|
398
|
+
"lightblue lightcoral lightcyan lightgoldenrodyellow lightgreen lightgrey lightpink lightsalmon "
|
|
399
|
+
"lightseagreen lightskyblue lightslategray lightsteelblue lightyellow lime limegreen linen magenta maroon "
|
|
400
|
+
"mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen "
|
|
401
|
+
"mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive "
|
|
402
|
+
"olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff "
|
|
403
|
+
"peru pink plum powderblue purple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell "
|
|
404
|
+
"sienna silver skyblue slateblue slategray snow springgreen steelblue tan teal thistle tomato turquoise "
|
|
405
|
+
"violet wheat white whitesmoke yellow yellowgreen random bikeshed").split())
|
|
406
|
+
_BARE_HEX = re.compile(r"^(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
|
407
|
+
|
|
408
|
+
|
|
380
409
|
# The tonemap filter reads linear light in units of 100 nits (1.0 = SDR white; its default peak,
|
|
381
410
|
# 10, is 1000 nits), so the linearising zscale uses npl=100. Through 3.6.0 it used npl=--peak:
|
|
382
411
|
# a 1000-nit pixel came out as 1.0, which tonemap took as 100 nits, so nothing reached SDR white
|
|
@@ -655,6 +655,63 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
|
|
|
655
655
|
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
656
656
|
|
|
657
657
|
|
|
658
|
+
def cfr_filter(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> str:
|
|
659
|
+
"""The `fps` filter that puts the picture on the grid cfr_args() delivers, as the last filter of
|
|
660
|
+
the video chain; "" when cfr_args() forces no rate. The encoder line's `-fps_mode cfr -r` alone
|
|
661
|
+
let a frame fall up to about two frames behind its slot before it dropped one, so a VFR clip
|
|
662
|
+
conformed at its average rate showed its picture up to 56 ms after its sound (#512); the
|
|
663
|
+
filter takes the nearest frame for every slot. The encoder line keeps cfr_args(), which then
|
|
664
|
+
moves no frame."""
|
|
665
|
+
args = cfr_args(meta, fps)
|
|
666
|
+
return f"fps={args[-1]}" if args else ""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def with_cfr(chain: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> str:
|
|
670
|
+
"""`chain` (a -vf value or one filtergraph chain) with cfr_filter() appended when there is one.
|
|
671
|
+
A -vf value that is a graph of its own (drawn_chain()'s split/maskedmerge) gets it first
|
|
672
|
+
instead: maskedmerge, like overlay, ends its output at its last frame's start, and an fps
|
|
673
|
+
filter after it lost the last frame (graphics.py --template chapter: 3.963 s of 4)."""
|
|
674
|
+
return chain_with_rate(chain, cfr_filter(meta, fps))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def chain_with_rate(chain: str, rate_filter: str) -> str:
|
|
678
|
+
"""`chain` with an `fps` filter `rate_filter` placed as with_cfr() places cfr_filter(): last in
|
|
679
|
+
a plain chain, first in a -vf that is a graph of its own (fit/export's blur pad, a drawn pad
|
|
680
|
+
with chroma), whose overlay or maskedmerge ends its output at its last frame's start on FFmpeg
|
|
681
|
+
6.1 and lost the last frame to an fps filter after it. For a rate that is not cfr_args()'s
|
|
682
|
+
own (export.py's preset and platform rates)."""
|
|
683
|
+
if not rate_filter:
|
|
684
|
+
return chain
|
|
685
|
+
if ";" in chain:
|
|
686
|
+
return f"{rate_filter},{chain}"
|
|
687
|
+
return f"{chain},{rate_filter}" if chain else rate_filter
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def graph_with_cfr(graph: str, label: str, meta: Optional[Dict[str, Any]],
|
|
691
|
+
fps: Optional[float] = None) -> Tuple[str, str]:
|
|
692
|
+
"""A -filter_complex graph and the video output label to map, with cfr_filter() after that
|
|
693
|
+
label when there is one: (graph + ";[v]fps=R[vcfr]", "[vcfr]"), else both unchanged."""
|
|
694
|
+
f = cfr_filter(meta, fps)
|
|
695
|
+
if not f:
|
|
696
|
+
return graph, label
|
|
697
|
+
bare = label.strip("[]")
|
|
698
|
+
return f"{graph};[{bare}]{f}[{bare}cfr]", f"[{bare}cfr]"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]") -> str:
|
|
702
|
+
"""A -filter_complex graph with cfr_filter() on its main picture as it is decoded (`src`, which
|
|
703
|
+
the graph reads once), for a graph that composites onto that picture and moves no frame in
|
|
704
|
+
time. After an overlay the conform comes too late: FFmpeg 6.1's overlay ends its output at its
|
|
705
|
+
last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture)."""
|
|
706
|
+
f = cfr_filter(meta, fps)
|
|
707
|
+
if not f:
|
|
708
|
+
return graph
|
|
709
|
+
if graph.count(src) != 1:
|
|
710
|
+
# a graph that read the picture twice, or not at all, would go unconformed or not parse
|
|
711
|
+
raise ValueError(f"cfr_at_input: the graph reads {src} {graph.count(src)} times, not once")
|
|
712
|
+
return f"{src}{f}[cfrin];" + graph.replace(src, "[cfrin]")
|
|
713
|
+
|
|
714
|
+
|
|
658
715
|
def delivered_rate(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Optional[float]:
|
|
659
716
|
"""The constant rate cfr_args() forces on the output (its -r), or None when it forces none and
|
|
660
717
|
the output keeps the source's rate -- what a start timecode is counted at
|
package/scripts/_common/emit.py
CHANGED
|
@@ -8,10 +8,11 @@ from __future__ import annotations
|
|
|
8
8
|
import json
|
|
9
9
|
import math
|
|
10
10
|
import os
|
|
11
|
+
import shlex
|
|
11
12
|
import stat
|
|
12
13
|
import sys
|
|
13
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
14
|
-
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
|
|
15
|
+
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE, same_file
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
# Characters that never belong in a message a calling agent reads (#400): C0 controls (a line
|
|
@@ -977,7 +978,56 @@ def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
977
978
|
PLAN_VERSION = 1
|
|
978
979
|
|
|
979
980
|
|
|
980
|
-
_PLAN_STRIP = ("--plan", "--dry-run", "--json")
|
|
981
|
+
_PLAN_STRIP = ("--plan", "--dry-run", "--json", "--json-brief")
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def is_output_shape_flag(token: str) -> bool:
|
|
985
|
+
"""`--json-brief`, or an abbreviation argparse reads as it (`--json-b`): the flag that changes
|
|
986
|
+
the shape of the document a tool prints. A plan's argv and a plan's execution leave it out
|
|
987
|
+
(A181): render.py runs the planned tool with --json and reads its full document, whose
|
|
988
|
+
`commands` the brief form replaces with a count, and executing a plan recorded with it crashed
|
|
989
|
+
on that count."""
|
|
990
|
+
return token.startswith("--json-") and "--json-brief".startswith(token)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _command_complete(line: str) -> bool:
|
|
994
|
+
"""Whether a `$ CMD` / `[dry-run] $ CMD` stderr line holds the whole command: run() prints it
|
|
995
|
+
shell-quoted, so a quote still open at the end of the line is an argument with a line break in
|
|
996
|
+
it (a `-metadata title=` with two lines), continued on the next stderr line."""
|
|
997
|
+
body = line[2:] if line.startswith("$ ") else line[len("[dry-run] $ "):]
|
|
998
|
+
try:
|
|
999
|
+
shlex.split(body)
|
|
1000
|
+
except ValueError:
|
|
1001
|
+
return False
|
|
1002
|
+
return True
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def stderr_records(stderr: str) -> "List[Tuple[bool, str]]":
|
|
1006
|
+
"""A sibling tool's stderr as (is_command, text) records, in order, for a tool that ran it
|
|
1007
|
+
(render.py's stages and plans): `$ CMD` is the command CMD, a `[dry-run]` line is kept whole as
|
|
1008
|
+
a command record (what render.py has always listed for a dry run), anything else is a line to
|
|
1009
|
+
pass on. A command whose quoted argument holds a line break spans several stderr lines; they
|
|
1010
|
+
are one record again, joined with the break they had (A4: one ffmpeg command became two
|
|
1011
|
+
records, the second passed on as prose). A quote that never closes leaves the lines as they
|
|
1012
|
+
were."""
|
|
1013
|
+
lines = stderr.splitlines(keepends=True)
|
|
1014
|
+
out: "List[Tuple[bool, str]]" = []
|
|
1015
|
+
i = 0
|
|
1016
|
+
while i < len(lines):
|
|
1017
|
+
line = lines[i].rstrip("\r\n")
|
|
1018
|
+
i += 1
|
|
1019
|
+
if not (line.startswith("$ ") or line.startswith("[dry-run]")):
|
|
1020
|
+
out.append((False, line))
|
|
1021
|
+
continue
|
|
1022
|
+
if line.startswith("$ ") or line.startswith("[dry-run] $ "):
|
|
1023
|
+
text, j = line, i
|
|
1024
|
+
while not _command_complete(text) and j < len(lines):
|
|
1025
|
+
text = text + lines[j - 1][len(lines[j - 1].rstrip("\r\n")):] + lines[j].rstrip("\r\n")
|
|
1026
|
+
j += 1
|
|
1027
|
+
if _command_complete(text):
|
|
1028
|
+
line, i = text, j
|
|
1029
|
+
out.append((True, line[2:] if line.startswith("$ ") else line))
|
|
1030
|
+
return out
|
|
981
1031
|
|
|
982
1032
|
|
|
983
1033
|
def _plan_at_exit() -> None:
|
|
@@ -1074,7 +1124,7 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
1074
1124
|
if skip:
|
|
1075
1125
|
skip = False
|
|
1076
1126
|
continue
|
|
1077
|
-
if a in _PLAN_STRIP:
|
|
1127
|
+
if a in _PLAN_STRIP or is_output_shape_flag(a):
|
|
1078
1128
|
skip = a == "--plan"
|
|
1079
1129
|
continue
|
|
1080
1130
|
if a.startswith("--plan="):
|
|
@@ -1090,11 +1140,10 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
1090
1140
|
if platform and output and tool != "check":
|
|
1091
1141
|
verify.append({"tool": "check", "platform": platform})
|
|
1092
1142
|
plan_inputs = _plan_inputs(ctx.commands, cleaned, ctx)
|
|
1093
|
-
real = os.path.realpath(path)
|
|
1094
1143
|
# every file the plan names (#413 review): its inputs, side inputs included (an SRT, a still),
|
|
1095
1144
|
# and the output it promises, a default name too, which apply_common could not know
|
|
1096
1145
|
for named in plan_inputs + ([output] if output else []):
|
|
1097
|
-
if
|
|
1146
|
+
if same_file(named, path):
|
|
1098
1147
|
refuse("input", "output_is_input" if named != output else "output_path_taken",
|
|
1099
1148
|
f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
|
|
1100
1149
|
f"({named!r}); give the plan its own path", output=path, other=named)
|
|
@@ -1102,7 +1151,7 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
1102
1151
|
# (loudness.py --versions, whose `output` is null; export.py's key file) names them only there,
|
|
1103
1152
|
# and a plan written over one of them is refused as an existing output when it is executed
|
|
1104
1153
|
for written in _planned_outputs(ctx.commands):
|
|
1105
|
-
if
|
|
1154
|
+
if same_file(written, path):
|
|
1106
1155
|
refuse("input", "output_path_taken", f"--plan {path!r} is also a file this run writes ({written!r}); "
|
|
1107
1156
|
"give the plan its own path", output=path, other=written)
|
|
1108
1157
|
doc = {
|
package/scripts/_common/probe.py
CHANGED
|
@@ -14,7 +14,7 @@ import sys
|
|
|
14
14
|
from array import array
|
|
15
15
|
from fractions import Fraction
|
|
16
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
-
from _common.emit import die, warn
|
|
17
|
+
from _common.emit import die, refuse, warn
|
|
18
18
|
from _common.runner import ffmpeg_failure_extra, STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
|
|
19
19
|
|
|
20
20
|
# The streams probe() describes, as stream specifiers: its `video` is the first picture that is not
|
|
@@ -348,6 +348,18 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
348
348
|
fps = float(avg_rate) if avg_rate else (float(r_rate) if r_rate else None)
|
|
349
349
|
vfr = bool(r_rate and avg_rate and abs(float(r_rate) - float(avg_rate)) > 0.01)
|
|
350
350
|
w, h = _to_int(video.get("width")), _to_int(video.get("height"))
|
|
351
|
+
if not (w and h) and _is_still(fmt, video):
|
|
352
|
+
# audit A184 (#465): a text file named .png reaches the image demuxer, which lists a
|
|
353
|
+
# png stream it could not decode as 0x0. That was reported as a measured picture
|
|
354
|
+
# (probe.py exit 0) and divided by in insert.py and check.py (tracebacks, no JSON).
|
|
355
|
+
# A video container's stream with no size is left to the tool that reads it.
|
|
356
|
+
msg = (f"{path} is not a picture ffmpeg can decode: its image demuxer ({fmt.get('format_name')}) found a "
|
|
357
|
+
f"{video.get('codec_name') or 'picture'} stream with no frame size"
|
|
358
|
+
+ (f"\n{proc.stderr.strip()}" if proc.stderr.strip() else ""))
|
|
359
|
+
if role == "output":
|
|
360
|
+
_output_failed(path, msg)
|
|
361
|
+
refuse("input", "still_unreadable", msg, path=path, format=fmt.get("format_name"),
|
|
362
|
+
codec=video.get("codec_name"))
|
|
351
363
|
rotation = 0
|
|
352
364
|
for sd in video.get("side_data_list", []) or []:
|
|
353
365
|
if "rotation" in sd:
|
|
@@ -1459,6 +1471,7 @@ def change_tiles(path: str, reference: str, floor: int, grid: "Tuple[int, int]"
|
|
|
1459
1471
|
"-filter_complex", graph, "-map", "[o]", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
|
|
1460
1472
|
# read frame by frame as ffmpeg writes them: 1728 bytes a frame is 187 MB for an hour at
|
|
1461
1473
|
# 30 fps, and only the frames with a changed tile are kept. Same wall-clock limit as run_analysis()
|
|
1474
|
+
cmd = _runner.guard_still_patterns(cmd) # a still named with a % is that one file (#465 A7)
|
|
1462
1475
|
_runner._refuse_url_media(cmd)
|
|
1463
1476
|
limit = _runner._limit_for(cmd)
|
|
1464
1477
|
size, tiles = gw * gh * 3, gw * gh
|