ffmpeg-skill 3.8.16 → 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 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.16`) | any release |
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.16", "execution_mode": "local", "kind": "execution",
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
- for spec in _contract.build(detect=False)["tools"]:
40
- _SPECS[spec["name"]] = spec
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
- proc = _common.run_tool([str(script)] + argv, per_call=per_call, env=env)
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
- resp = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"parse error: {exc}"}}
270
- stdout.write((json.dumps(resp) + "\n").encode("utf-8"))
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
- try:
284
- result = handle(req)
285
- resp = {"jsonrpc": "2.0", "id": req["id"], "result": result}
286
- except KeyError as exc:
287
- resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": f"method not found: {exc}"}}
288
- except Exception as exc: # noqa: BLE001
289
- resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32000, "message": str(exc)}}
290
- stdout.write((json.dumps(resp) + "\n").encode("utf-8"))
291
- stdout.flush()
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.16",
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": [
@@ -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
@@ -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
- if not _COLOR_TOKEN_RE.match(value):
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
@@ -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 os.path.realpath(named) == real:
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 os.path.realpath(written) == real:
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 = {
@@ -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