ffmpeg-skill 3.8.16 → 3.8.18
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/scripts.md +8 -3
- package/scripts/_common/alpha.py +1 -0
- package/scripts/_common/color.py +30 -1
- package/scripts/_common/decision.py +42 -1
- package/scripts/_common/emit.py +55 -6
- package/scripts/_common/fonts.py +31 -0
- package/scripts/_common/probe.py +28 -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 +13 -2
- package/scripts/caption.py +13 -5
- package/scripts/graphics.py +4 -1
- package/scripts/grid.py +14 -5
- package/scripts/metadata.py +5 -2
- package/scripts/overlay.py +10 -1
- package/scripts/render.py +11 -11
- package/scripts/report.py +26 -3
- package/scripts/silence.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.18`) | 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.18", "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.18",
|
|
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/scripts.md
CHANGED
|
@@ -656,6 +656,7 @@ ends -- a cutaway, not a splice, so the output is exactly as long as A. One
|
|
|
656
656
|
`--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
|
|
657
657
|
cutaway too, or given once for all; defaults 4 s and 0); windows may not
|
|
658
658
|
overlap or run past A's end, and B must have enough material from `--from`.
|
|
659
|
+
A still picture as B is shown for the whole window.
|
|
659
660
|
`--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
|
|
660
661
|
it inside each window with B's; `mix` plays both. The output's length is
|
|
661
662
|
verified against A's.
|
|
@@ -717,7 +718,8 @@ bottom-right corner; `--label none` skips it. No audio unless `--audio-from`
|
|
|
717
718
|
picks one input's track by index -- mixing every clip's audio together is
|
|
718
719
|
rarely useful for a comparison grid, so this tool never does that silently.
|
|
719
720
|
Runs only as long as the shortest clip by default; `--pad` instead holds
|
|
720
|
-
each shorter clip's last frame (with silence) out to the longest.
|
|
721
|
+
each shorter clip's last frame (with silence) out to the longest. A still
|
|
722
|
+
picture fills its cell for the grid's length; a grid of stills only is refused.
|
|
721
723
|
|
|
722
724
|
### silence.py — remove dead air / jump cuts
|
|
723
725
|
```
|
|
@@ -1192,7 +1194,8 @@ hand-built step:
|
|
|
1192
1194
|
and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
|
|
1193
1195
|
linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) into
|
|
1194
1196
|
`<stem>_<platform>.mp4`, runs each platform's check and writes `<stem>_pack.md` with one row per
|
|
1195
|
-
destination; `report.py --pack <stem>_pack.md` renders that table
|
|
1197
|
+
destination; `report.py --pack <stem>_pack.md` renders that table (and refuses a file whose first
|
|
1198
|
+
table has other columns) as a single HTML page. A pack
|
|
1196
1199
|
whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
|
|
1197
1200
|
|
|
1198
1201
|
`audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
|
|
@@ -1607,7 +1610,9 @@ like column; `--margin PX` sets all four edges and wins over `--platform`.
|
|
|
1607
1610
|
read by `caption.py` and, for `font` and `colour`, by `graphics.py`; it wins
|
|
1608
1611
|
over the older top-level `caption` block where both set the same key, and that
|
|
1609
1612
|
block still carries the burn-in-only defaults (`animate`, `karaoke`, `bold`,
|
|
1610
|
-
`outline`). `"lang"` is the script hint `--lang` would give.
|
|
1613
|
+
`outline`). `"lang"` is the script hint `--lang` would give. A key no tool
|
|
1614
|
+
reads is refused, naming it (`loudness`, read by none, gets a note), and a
|
|
1615
|
+
`font_file` that is not a font is refused where text is drawn with it. When a
|
|
1611
1616
|
user mentions brand guidelines, colours, "our font" or a logo, ask for or
|
|
1612
1617
|
write a brand.json once and reuse it across every output.
|
|
1613
1618
|
|
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
|
|
@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
16
16
|
from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
17
17
|
is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
18
18
|
from _common.alpha import rgb_to_bt709_line
|
|
19
|
-
from _common.emit import die, input_length, record_fallback, refuse, warn
|
|
19
|
+
from _common.emit import die, input_length, note, record_fallback, refuse, warn
|
|
20
20
|
from _common.runner import read_json
|
|
21
21
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
|
|
22
22
|
|
|
@@ -1295,6 +1295,46 @@ BRAND_DEFAULTS: Dict[str, Any] = {
|
|
|
1295
1295
|
}
|
|
1296
1296
|
|
|
1297
1297
|
|
|
1298
|
+
# Every key a tool reads from a brand file, per block (#510). `styles.caption` is merged over the
|
|
1299
|
+
# top-level `caption` block (brand_caption_style()), so the two take the same keys. `colors.accent`
|
|
1300
|
+
# is a palette entry BRAND_DEFAULTS and examples/brand.json carry; `loudness` is read by no tool
|
|
1301
|
+
# (A107) and is kept out of the refusal, since the example shipped it until 3.8.8: it gets a note.
|
|
1302
|
+
BRAND_KEYS: Dict[str, frozenset] = {
|
|
1303
|
+
"": frozenset({"font", "font_file", "colors", "logo", "logo_position", "logo_scale", "logo_opacity",
|
|
1304
|
+
"safe_margin", "caption", "styles", "lang", "emoji_assets", "loudness"}),
|
|
1305
|
+
"colors": frozenset({"primary", "text", "outline", "background", "accent", "upcoming"}),
|
|
1306
|
+
"caption": frozenset({"size", "position", "animate", "karaoke", "bold", "outline", "box", "color", "colour",
|
|
1307
|
+
"font", "emoji_assets"}),
|
|
1308
|
+
"styles": frozenset({"caption"}),
|
|
1309
|
+
}
|
|
1310
|
+
BRAND_KEYS["styles.caption"] = BRAND_KEYS["caption"]
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def check_brand_keys(data: Dict[str, Any], path: str) -> None:
|
|
1314
|
+
"""Refuse a brand file key no tool reads, as render.py refuses a project's (check_keys()): a
|
|
1315
|
+
misspelt `"safe_margn"` or `"colours"` was dropped without a word and the defaults drawn."""
|
|
1316
|
+
import difflib
|
|
1317
|
+
import json
|
|
1318
|
+
blocks = [("", data)]
|
|
1319
|
+
for name in ("colors", "caption", "styles"):
|
|
1320
|
+
if isinstance(data.get(name), dict):
|
|
1321
|
+
blocks.append((name, data[name]))
|
|
1322
|
+
if isinstance(data.get("styles"), dict) and isinstance(data["styles"].get("caption"), dict):
|
|
1323
|
+
blocks.append(("styles.caption", data["styles"]["caption"]))
|
|
1324
|
+
for block, obj in blocks:
|
|
1325
|
+
for key in obj:
|
|
1326
|
+
if key in BRAND_KEYS[block]:
|
|
1327
|
+
continue
|
|
1328
|
+
where = f"{block}.{key}" if block else str(key)
|
|
1329
|
+
near = difflib.get_close_matches(str(key), sorted(BRAND_KEYS[block]), n=1)
|
|
1330
|
+
# quoted as JSON: the key is the caller's text, and a quote or a line break in it stays one line
|
|
1331
|
+
die(f"brand file {path}: unknown key {json.dumps(where)}" + (f' (did you mean "{near[0]}"?)' if near else ""),
|
|
1332
|
+
kind="input", hint=f"the keys read{' in ' + block if block else ''}: " + ", ".join(sorted(BRAND_KEYS[block])))
|
|
1333
|
+
if "loudness" in data:
|
|
1334
|
+
note(f'brand file {path}: "loudness" is read by no tool and was ignored (a target is loudness.py -I, '
|
|
1335
|
+
'export.py\'s preset, or a render project\'s "loudness")')
|
|
1336
|
+
|
|
1337
|
+
|
|
1298
1338
|
def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
1299
1339
|
"""Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
|
|
1300
1340
|
import copy
|
|
@@ -1322,6 +1362,7 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
|
1322
1362
|
die(f'brand file {path}: "{key}" must be an object {{...}}, got {type(value).__name__}', kind="input")
|
|
1323
1363
|
if key in ("logo", "font_file", "font") and not isinstance(value, str):
|
|
1324
1364
|
die(f'brand file {path}: "{key}" must be a string, got {type(value).__name__}', kind="input")
|
|
1365
|
+
check_brand_keys(data, path)
|
|
1325
1366
|
base = Path(path).resolve().parent
|
|
1326
1367
|
for k, v in data.items():
|
|
1327
1368
|
if v is None and isinstance(brand.get(k), dict):
|
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/fonts.py
CHANGED
|
@@ -278,6 +278,37 @@ _SCRIPT_RANGES = (
|
|
|
278
278
|
)
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
# The first bytes of the font files FreeType opens (drawtext and libass both load through it):
|
|
282
|
+
# TrueType, OpenType/CFF, Apple 'true'/'typ1', a collection, WOFF/WOFF2, PostScript Type 1 (PFA
|
|
283
|
+
# text, PFB segment marker), BDF and PCF bitmap fonts.
|
|
284
|
+
_FONT_MAGIC = (b"\x00\x01\x00\x00", b"OTTO", b"true", b"typ1", b"ttcf", b"wOFF", b"wOF2",
|
|
285
|
+
b"%!PS-AdobeFont", b"%!FontType1", b"\x80\x01", b"STARTFONT", b"\x01fcp")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def require_font_file(path: str, label: str = "--font-file") -> str:
|
|
289
|
+
"""Refuse a font file that is not there or is not a font, before anything is drawn with it
|
|
290
|
+
(#510). drawtext that cannot load its `fontfile=` asks fontconfig for a default face instead
|
|
291
|
+
and exits 0, and libass given the file's directory finds no face in it and does the same, so a
|
|
292
|
+
text file named `brand.ttf` drew DejaVu Sans with `font_used.substituted: false`. Read by the
|
|
293
|
+
first bytes, not the extension; a gzipped PCF (`.pcf.gz`) and a Mac `.dfont` (no signature)
|
|
294
|
+
pass by name."""
|
|
295
|
+
shown_path = str(path)
|
|
296
|
+
if not os.path.isfile(shown_path):
|
|
297
|
+
die(f"{label} {shown_path}: no such file", kind="input")
|
|
298
|
+
try:
|
|
299
|
+
with open(shown_path, "rb") as fh:
|
|
300
|
+
head = fh.read(16)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
die(f"{label} {shown_path} cannot be read: {exc.strerror or exc}", kind="input")
|
|
303
|
+
lower = shown_path.lower()
|
|
304
|
+
if head.startswith(_FONT_MAGIC) or lower.endswith(".dfont") or (lower.endswith(".pcf.gz") and head.startswith(b"\x1f\x8b")):
|
|
305
|
+
return shown_path
|
|
306
|
+
die(f"{label} {shown_path} is not a font file (it starts with {head[:8]!r}, which no TrueType, OpenType, "
|
|
307
|
+
"WOFF, Type 1, BDF or PCF font does): drawing with it would fall back to another face without a word",
|
|
308
|
+
kind="input", hint="name a .ttf, .otf or .ttc file (fc-list lists the installed ones), or --font FAMILY")
|
|
309
|
+
return shown_path
|
|
310
|
+
|
|
311
|
+
|
|
281
312
|
def font_family_of_file(path: str) -> "Optional[str]":
|
|
282
313
|
"""The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
|
|
283
314
|
the file directly; without fontconfig the file stem is the honest best guess."""
|
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:
|
|
@@ -893,6 +905,20 @@ def refuse_not_a_still(path: str, meta: Dict[str, Any], flag: str, instead: str)
|
|
|
893
905
|
+ (f" ({length:.3f}s)" if length else "") + f", and {flag} is read as one picture", kind="input", hint=instead)
|
|
894
906
|
|
|
895
907
|
|
|
908
|
+
def is_still_input(meta: Dict[str, Any]) -> bool:
|
|
909
|
+
"""A probe document of one picture read by an image demuxer (_is_still()'s rule, on the
|
|
910
|
+
document): a still given where a clip is taken. Read with a plain `-i` it is one frame, so a
|
|
911
|
+
tool that shows it for a while opens it with still_loop_args() (#510)."""
|
|
912
|
+
fmt = str((meta or {}).get("format") or "")
|
|
913
|
+
return not (meta or {}).get("dry_run") and (fmt == "image2" or fmt.endswith("_pipe"))
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def still_loop_args(path: str, seconds: float) -> List[str]:
|
|
917
|
+
"""The input options that show a still picture for `seconds`: `-loop 1` repeats its one frame
|
|
918
|
+
(the image demuxers' option) and `-t` ends the repeat, so the input is bounded."""
|
|
919
|
+
return ["-loop", "1", *still_input_args(path), "-t", f"{max(seconds, 0.001):.3f}"]
|
|
920
|
+
|
|
921
|
+
|
|
896
922
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
897
923
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
898
924
|
|
|
@@ -1459,6 +1485,7 @@ def change_tiles(path: str, reference: str, floor: int, grid: "Tuple[int, int]"
|
|
|
1459
1485
|
"-filter_complex", graph, "-map", "[o]", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
|
|
1460
1486
|
# read frame by frame as ffmpeg writes them: 1728 bytes a frame is 187 MB for an hour at
|
|
1461
1487
|
# 30 fps, and only the frames with a changed tile are kept. Same wall-clock limit as run_analysis()
|
|
1488
|
+
cmd = _runner.guard_still_patterns(cmd) # a still named with a % is that one file (#465 A7)
|
|
1462
1489
|
_runner._refuse_url_media(cmd)
|
|
1463
1490
|
limit = _runner._limit_for(cmd)
|
|
1464
1491
|
size, tiles = gw * gh * 3, gw * gh
|