ffmpeg-skill 3.6.0 → 3.6.1

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
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.6.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.6.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.6.0", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.6.1", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -897,7 +897,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
897
897
  A refusal also carries `error.reason`, a stable snake_case code for *why*
898
898
  (`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
899
899
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
900
- `media_path_is_url`, `media_path_is_playlist` (an HLS or ffconcat playlist given as media),
900
+ `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),
901
901
  `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `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`,
902
902
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
903
903
  `encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
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": [
@@ -54,7 +54,7 @@ def ass_time(sec: float) -> str:
54
54
 
55
55
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
56
56
  h = str(hex_rgb).lstrip("#")
57
- if len(h) != 6:
57
+ if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
58
58
  raise ValueError(f"colour must be RRGGBB hex, got '{hex_rgb}'")
59
59
  return f"&H{alpha:02X}{h[4:6]}{h[2:4]}{h[0:2]}".upper()
60
60
 
@@ -312,13 +312,20 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
312
312
  return out[:at] + ["-vf", conv] + out[at:]
313
313
 
314
314
 
315
+ _HEX6 = re.compile(r"^[0-9A-Fa-f]{6}$")
316
+
317
+
315
318
  def color_hex(value: str) -> str:
316
- """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
319
+ """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'. Six hex digits only: the result is
320
+ written into libass styles and drawtext/color options, so a six-letter colour name ("yellow")
321
+ used to pass the length check and draw black, reported verified, and a quote or comma reached
322
+ the filter graph."""
317
323
  v = str(value).strip().lstrip("#")
318
324
  if v.lower().startswith("0x"):
319
325
  v = v[2:]
320
- if len(v) != 6:
321
- die(f"colour must be RRGGBB, got '{value}'")
326
+ if not _HEX6.match(v):
327
+ die(f"colour must be six hex digits RRGGBB (e.g. FFD200 or #ffd200), got '{value}'; colour names are not "
328
+ "accepted here")
322
329
  return v.upper()
323
330
 
324
331
 
@@ -148,7 +148,15 @@ def concat_list_line(path: str) -> str:
148
148
  """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
149
149
  inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
150
150
  with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
151
- name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
151
+ name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py.
152
+
153
+ A line break has no escape in this format: the demuxer reads each line as its own directive,
154
+ so a name containing one would add `file` entries to the list and pull in files the caller
155
+ never named. Such a path is refused."""
156
+ if "\n" in str(path) or "\r" in str(path):
157
+ refuse("input", "media_path_has_line_break",
158
+ f"{str(path)!r} contains a line break, which ffmpeg's concat list cannot hold (each line is a "
159
+ "directive of its own); rename the file", path=str(path))
152
160
  # only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
153
161
  escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
154
162
  return f"file '{escaped}'"
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import json
9
9
  import math
10
10
  import os
11
+ import stat
11
12
  import sys
12
13
  from typing import Any, Dict, List, Optional, Sequence, Tuple
13
14
  from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
@@ -319,7 +320,28 @@ def audit(event: str, details: Dict[str, Any], *, default_path: Optional[str] =
319
320
  rec["plan_sha256"] = plan_sha256
320
321
  rec["details"] = details
321
322
  try:
322
- with open(path, "a", encoding="utf-8") as f:
323
+ # a default path is a hidden name in someone's output directory: never appended to through a
324
+ # symlink planted there. A log the caller named (--audit-log) is opened as named.
325
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | (0 if ctx.audit_log else getattr(os, "O_NOFOLLOW", 0))
326
+ checked = not ctx.audit_log and not hasattr(os, "O_NOFOLLOW")
327
+ if checked and os.path.islink(path):
328
+ raise OSError(f"{path} is a symbolic link")
329
+ fd = os.open(path, flags, 0o666)
330
+ if checked:
331
+ # no O_NOFOLLOW (Windows): a link that appeared between the look above and the open is
332
+ # caught after it -- the descriptor must be the file now at that name, not a link's
333
+ # target -- and nothing is written through it
334
+ try:
335
+ st, at = os.fstat(fd), os.lstat(path)
336
+ # st_nlink: a link swapped for a hard link to its target before the lstat would
337
+ # otherwise pass the identity check; a log this tool created has one name
338
+ if (stat.S_ISLNK(at.st_mode) or (st.st_dev, st.st_ino) != (at.st_dev, at.st_ino)
339
+ or not stat.S_ISREG(st.st_mode) or st.st_nlink != 1):
340
+ raise OSError(f"{path} was replaced by a link while it was opened")
341
+ except BaseException:
342
+ os.close(fd)
343
+ raise
344
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
323
345
  f.write(json.dumps(json_safe(rec), ensure_ascii=False, sort_keys=True) + "\n")
324
346
  except OSError as exc:
325
347
  warn(f"could not append the {event} record to the audit log {path} ({exc}); the run is unaffected",
@@ -775,21 +775,69 @@ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
775
775
  _URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
776
776
 
777
777
 
778
+ def _is_subfile_url(value: str) -> bool:
779
+ """ffmpeg's url_find_protocol() also opens `subfile,<options>:<url>` through the subfile
780
+ protocol: the scheme stops at the comma, so the regexes above never see it."""
781
+ return value.startswith("subfile,") and ":" in value[len("subfile,"):]
782
+
783
+
778
784
  def looks_like_url(value: str) -> bool:
779
785
  """True for a value ffmpeg would open through a protocol rather than as a local file."""
786
+ if _is_subfile_url(value):
787
+ return True
780
788
  scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
781
789
  return bool(scheme.match(value)) and not value.startswith("pipe:")
782
790
 
783
791
 
784
- _PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
792
+ def url_scheme(value: str) -> str:
793
+ """The protocol name ffmpeg would open `value` through (for a looks_like_url() value)."""
794
+ return "subfile" if _is_subfile_url(value) else value.split(":", 1)[0].lower()
795
+
796
+
797
+ _PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat", ".mpd")
785
798
  _PLAYLIST_MAGIC = (b"#EXTM3U", b"ffconcat")
799
+ # ffmpeg probes a text format over the whole probe buffer (up to -probesize, 5,000,000 bytes), not
800
+ # over a fixed prefix, and reads that buffer as a C string, so the scan below stops at the first
801
+ # NUL byte: a real media file has one within its first few bytes, a manifest has none.
802
+ _MANIFEST_SCAN_LIMIT = 5_000_000
803
+
804
+
805
+ def _manifest_kind(path: str) -> Optional[str]:
806
+ """"DASH" or "IMF" when ffmpeg's own probe would read `path` as that manifest, whatever its
807
+ name: dash_probe() looks for `<MPD` and `dash:profile` or `3GPP:PSS:profile:DASH1`
808
+ (case-insensitively), imf_probe() for
809
+ `<CompositionPlaylist` and `ContentTitle>`. Either one names other files, local or remote, that
810
+ ffmpeg then opens (a DASH BaseURL is followed over HTTP even under -protocol_whitelist file)."""
811
+ buf = bytearray()
812
+ try:
813
+ with open(path, "rb") as fh:
814
+ while len(buf) < _MANIFEST_SCAN_LIMIT:
815
+ chunk = fh.read(65536)
816
+ if not chunk:
817
+ break
818
+ nul = chunk.find(b"\0")
819
+ if nul >= 0:
820
+ buf += chunk[:nul]
821
+ break
822
+ buf += chunk
823
+ except OSError:
824
+ return None
825
+ text = bytes(buf[:_MANIFEST_SCAN_LIMIT])
826
+ low = text.lower()
827
+ if b"<mpd" in low and (b"dash:profile" in low or b"3gpp:pss:profile:dash1" in low):
828
+ return "DASH"
829
+ if b"<CompositionPlaylist" in text and b"ContentTitle>" in text:
830
+ return "IMF"
831
+ return None
786
832
 
787
833
 
788
834
  def _refuse_playlist(value: str) -> None:
789
- """Refuse an input that ffmpeg would read as a playlist (HLS .m3u8, an ffconcat list) rather
790
- than as media. A playlist names other files by path, so a media file handed to the skill could
791
- pull in any file the user can read. The skill's own concat lists pass `-f concat` and are not
792
- checked here."""
835
+ """Refuse an input that ffmpeg would read as a playlist (HLS .m3u8, a DASH .mpd, an IMF
836
+ composition playlist, an ffconcat list) rather than as media. A playlist names other files by
837
+ path or URL, so a media file handed to the skill could pull in any file the user can read, or
838
+ make ffmpeg fetch a URL. HLS and ffconcat are told by name or by their first bytes; DASH and IMF
839
+ by name or by the content ffmpeg's own probe looks for, anywhere before the first NUL byte. The
840
+ skill's own concat lists pass `-f concat` and are not checked here."""
793
841
  if os.path.exists(value) and not os.path.isfile(value) and not os.path.isdir(value):
794
842
  # a FIFO or a device: ffmpeg would block on it or read whatever it streams
795
843
  refuse("input", "media_path_not_a_file",
@@ -801,9 +849,14 @@ def _refuse_playlist(value: str) -> None:
801
849
  head = fh.read(16).lstrip(b"\xef\xbb\xbf \t\r\n")
802
850
  except OSError:
803
851
  pass
852
+ kind = None
804
853
  if value.lower().endswith(_PLAYLIST_SUFFIXES) or head.startswith(_PLAYLIST_MAGIC):
854
+ kind = "HLS, DASH or ffconcat"
855
+ elif os.path.isfile(value):
856
+ kind = _manifest_kind(value)
857
+ if kind:
805
858
  refuse("input", "media_path_is_playlist",
806
- f"{value!r} is a playlist (HLS or ffconcat), which ffmpeg would follow to the files it names; "
859
+ f"{value!r} is a playlist ({kind}), which ffmpeg would follow to the files or URLs it names; "
807
860
  "this skill takes media files only. Pass the media file itself", path=value)
808
861
 
809
862
 
@@ -836,9 +889,9 @@ def _refuse_url_media(cmd: Sequence[str]) -> None:
836
889
  for value in values:
837
890
  if looks_like_url(value):
838
891
  refuse("input", "media_path_is_url",
839
- f"{value!r} would be opened by ffmpeg as a URL ({value.split(':', 1)[0]}:), not as a local file; "
892
+ f"{value!r} would be opened by ffmpeg as a URL ({url_scheme(value)}:), not as a local file; "
840
893
  "this skill reads and writes local files only. Pass a plain path (a local name containing "
841
- "a colon works as ./name)", path=value, scheme=value.split(":", 1)[0].lower())
894
+ "a colon works as ./name)", path=value, scheme=url_scheme(value))
842
895
 
843
896
 
844
897
  def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
@@ -1243,10 +1296,73 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
1243
1296
  return list(cmd), None, None
1244
1297
  d, base = os.path.split(output)
1245
1298
  stem, ext = os.path.splitext(base)
1246
- tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
1299
+ tmp = reserve_sibling(d, stem, ext)
1247
1300
  return list(cmd[:-1]) + [tmp], output, tmp
1248
1301
 
1249
1302
 
1303
+ def _reserve(d: str, stem: str, ext: str, tag: str) -> "Tuple[str, Optional[int]]":
1304
+ """(path, open descriptor) of a hidden sibling created exclusively; (path, None) when the
1305
+ directory refuses the create (the write that follows then fails there)."""
1306
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
1307
+ n = 1
1308
+ while True:
1309
+ path = os.path.join(d, f".{stem}.ffskill-" + (f"{tag}-" if tag else "") + f"{os.getpid()}"
1310
+ + (f"-{n}" if n > 1 else "") + ext)
1311
+ try:
1312
+ return path, os.open(path, flags, 0o666)
1313
+ except FileExistsError:
1314
+ n += 1
1315
+ except OSError:
1316
+ return path, None
1317
+
1318
+
1319
+ def reserve_sibling(d: str, stem: str, ext: str, tag: str = "") -> str:
1320
+ """Create the hidden sibling `.<stem>.ffskill-<pid><ext>` (or `...-<pid>-<n><ext>` when that
1321
+ name is taken) exclusively, and return its path, for ffmpeg to write with -y. O_EXCL never
1322
+ opens an existing entry, a symlink included, so a name planted in the output directory
1323
+ beforehand is never written through. What it cannot stop is someone who may delete files in
1324
+ that directory replacing this one between the create and ffmpeg's open: not possible in a
1325
+ sticky shared directory (/tmp), nor in one only the caller can write. A copy this process makes
1326
+ itself goes through copy_to_sibling(), which writes the descriptor it created."""
1327
+ path, fd = _reserve(d, stem, ext, tag)
1328
+ if fd is None:
1329
+ # fail closed: a name that was not created exclusively is never handed to ffmpeg
1330
+ refuse("input", "output_dir_not_writable", f"cannot create a temporary file in {d or '.'!r}",
1331
+ output=os.path.join(d, stem + ext), directory=d or ".")
1332
+ os.close(fd)
1333
+ return path
1334
+
1335
+
1336
+ def copy_to_sibling(src: str, dst: str) -> str:
1337
+ """Copy `src` into a hidden sibling of `dst` created exclusively, through the descriptor that
1338
+ created it (never by reopening the name), and return the sibling's path for os.replace()."""
1339
+ import shutil
1340
+ d, base = os.path.split(dst)
1341
+ stem, ext = os.path.splitext(base)
1342
+ path, fd = _reserve(d, stem, ext, "")
1343
+ if fd is None:
1344
+ raise OSError(f"cannot create a temporary file beside {dst}")
1345
+ try:
1346
+ fout = os.fdopen(fd, "wb")
1347
+ except BaseException:
1348
+ os.close(fd)
1349
+ try:
1350
+ os.remove(path)
1351
+ except OSError:
1352
+ pass
1353
+ raise
1354
+ try:
1355
+ with fout, open(src, "rb") as fin:
1356
+ shutil.copyfileobj(fin, fout, 1 << 20)
1357
+ except BaseException:
1358
+ try:
1359
+ os.remove(path)
1360
+ except OSError:
1361
+ pass
1362
+ raise
1363
+ return path
1364
+
1365
+
1250
1366
  HW_ENV = "FFMPEG_SKILL_HW"
1251
1367
  # set only by render.py/batch.py --hw/--no-hw for the stages they run: "the outer command chose
1252
1368
  # explicitly", so a child resolves $FFMPEG_SKILL_HW as a flag (export.py included), not a default
@@ -1846,7 +1962,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
1846
1962
  _check_existing_output(cmd)
1847
1963
  d, base = os.path.split(dst)
1848
1964
  stem, ext = os.path.splitext(base)
1849
- tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
1850
1965
  with _OutputLock(dst):
1851
1966
  if move:
1852
1967
  try:
@@ -1855,14 +1970,16 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
1855
1970
  return
1856
1971
  except OSError:
1857
1972
  pass
1973
+ tmp = None
1858
1974
  try:
1859
- shutil.copyfile(src, tmp)
1975
+ tmp = copy_to_sibling(src, dst)
1860
1976
  os.replace(tmp, dst)
1861
1977
  except OSError as e:
1862
- try:
1863
- os.remove(tmp)
1864
- except OSError:
1865
- pass
1978
+ if tmp:
1979
+ try:
1980
+ os.remove(tmp)
1981
+ except OSError:
1982
+ pass
1866
1983
  die(f"could not place {dst}: {e}", kind="output")
1867
1984
  _remember_output(cmd)
1868
1985
 
@@ -424,7 +424,8 @@ ERROR_REASONS = {
424
424
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
425
425
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
426
426
  "output_is_directory": "the output path is a directory; details: {output}",
427
- "media_path_is_playlist": "an input is a playlist (HLS .m3u8/.m3u or an ffconcat list, by name or by its first bytes) that ffmpeg would follow to the files it names; the skill takes media files only; details: {path}",
427
+ "media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
428
+ "media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
428
429
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
429
430
  "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
430
431
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
package/scripts/batch.py CHANGED
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
- from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, env_hw, stop_children
36
+ from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, stop_children
37
37
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
38
38
 
39
39
  HERE = Path(__file__).resolve().parent
@@ -265,16 +265,18 @@ def deliver(src: str, final: Path) -> None:
265
265
  except OSError:
266
266
  # another filesystem: copy beside the final path and rename, so a copy that fails part
267
267
  # way never leaves a truncated file where the caller's was
268
- import shutil
269
- tmp = final.with_name(f".{final.stem}.ffskill-{os.getpid()}-{threading.get_ident()}{final.suffix}")
268
+ # created exclusively and written through the descriptor that created it: never through
269
+ # an entry planted at the name, and two threads delivering side by side get two names
270
+ tmp = None
270
271
  try:
271
- shutil.copyfile(src, tmp)
272
+ tmp = copy_to_sibling(str(src), str(final))
272
273
  os.replace(tmp, final)
273
274
  except OSError as exc:
274
- try:
275
- os.remove(tmp)
276
- except OSError:
277
- pass
275
+ if tmp:
276
+ try:
277
+ os.remove(tmp)
278
+ except OSError:
279
+ pass
278
280
  raise ItemError({"step": None, "tool": None, "kind": "output",
279
281
  "message": f"could not deliver {src} to {final}: {exc}"})
280
282
  log(f" delivered the last written file to {final}")
@@ -926,8 +926,8 @@ def mux_subtitle_codec(output: str) -> str:
926
926
 
927
927
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
928
928
  h = hex_rgb.lstrip("#")
929
- if len(h) != 6:
930
- die(f"colour must be RRGGBB hex, got '{hex_rgb}'")
929
+ if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
930
+ die(f"colour must be six hex digits RRGGBB, got '{hex_rgb}'")
931
931
  r, g, b = h[0:2], h[2:4], h[4:6]
932
932
  return f"&H{alpha:02X}{b}{g}{r}".upper()
933
933
 
package/scripts/export.py CHANGED
@@ -51,7 +51,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import square_pixels, square_pixels_note
54
- from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
54
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
55
  from _common.emit import file_identity
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
@@ -213,7 +213,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
213
213
  if not STATE.dry_run:
214
214
  d, base = os.path.split(output)
215
215
  stem, ext = os.path.splitext(base)
216
- fill_tmp = os.path.join(d, f".{stem}.ffskill-fill-{os.getpid()}{ext}")
216
+ fill_tmp = reserve_sibling(d, stem, ext, "fill") # created exclusively: never through a planted link
217
217
  cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
218
218
  cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
219
219
  try:
package/scripts/render.py CHANGED
@@ -464,6 +464,64 @@ def check_overlay(ov: Any, label: str) -> None:
464
464
  die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
465
465
 
466
466
 
467
+ def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
468
+ """Every file a project names as an input: clip sources, music/replace/effects, the brand,
469
+ caption files (a --text value that names an existing file), overlay images, videos and fonts, the
470
+ audiogram image, a beat grid's source and the chapters file. Values of the wrong type are left
471
+ to the stage that reads them."""
472
+ found: List[str] = []
473
+
474
+ def add(value: Any, must_exist: bool = False) -> None:
475
+ if isinstance(value, str) and value:
476
+ p = rel(value)
477
+ if not must_exist or os.path.isfile(p):
478
+ found.append(p)
479
+
480
+ for c in proj.get("clips") or []:
481
+ if isinstance(c, dict):
482
+ add(c.get("src"))
483
+ if isinstance(c.get("snap"), dict):
484
+ add(c["snap"].get("source"))
485
+ for key in ("audio", "captions", "audiogram", "snap"):
486
+ if not isinstance(proj.get(key), dict):
487
+ continue
488
+ obj = proj[key]
489
+ if key == "audio":
490
+ for k in ("music", "replace", "effects"):
491
+ add(obj.get(k))
492
+ elif key == "captions":
493
+ add(obj.get("srt"))
494
+ add(obj.get("ass"))
495
+ add(obj.get("text"), must_exist=True) # inline cue text is not a path
496
+ elif key == "audiogram":
497
+ add(obj.get("image"))
498
+ else:
499
+ add(obj.get("source"))
500
+ add(proj.get("brand"))
501
+ brand = proj.get("brand")
502
+ if isinstance(brand, str) and brand:
503
+ # the brand's own logo and font_file are read by the overlay and caption stages, resolved
504
+ # against the brand file as load_brand() resolves them; an unreadable brand is left to them
505
+ try:
506
+ data = json.loads(Path(rel(brand)).read_text(encoding="utf-8"))
507
+ except (OSError, ValueError):
508
+ data = None
509
+ if isinstance(data, dict):
510
+ base = Path(rel(brand)).resolve().parent
511
+ for k in ("logo", "font_file"):
512
+ v = data.get(k)
513
+ if isinstance(v, str) and v and "://" not in v:
514
+ found.append(v if os.path.isabs(v) else str(base / v))
515
+ add(proj.get("chapters"), must_exist=True)
516
+ overlays = proj.get("overlays")
517
+ for ov in (overlays if isinstance(overlays, list) else []): # any other type is the overlay stage's to refuse
518
+ if isinstance(ov, dict):
519
+ add(ov.get("image"))
520
+ add(ov.get("video"))
521
+ add(ov.get("font_file"))
522
+ return found
523
+
524
+
467
525
  def require_object(obj: Any, label: str) -> None:
468
526
  """Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
469
527
  in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
@@ -1216,8 +1274,13 @@ def main() -> int:
1216
1274
  if not clips:
1217
1275
  die("project.clips is empty")
1218
1276
  output = rel(proj.get("output") or "final.mp4")
1219
- # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
1220
- refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
1277
+ # the final stage is a copy from the work dir, so run()'s own guard never sees the sources
1278
+ # (review 5): every file the project names is passed here, not only the clips, or --overwrite
1279
+ # replaced a music bed or a brand file named as the output
1280
+ if not args.stop_after or args.stop_after == "export":
1281
+ # only a run that ends in the final copy writes `output`; a --stop-after preview writes
1282
+ # its stage file inside the work directory and leaves `output` alone
1283
+ refuse_output_is_input(output, *([os.path.abspath(args.project)] if args.project else []), *project_input_files(proj, rel))
1221
1284
  if _reaches("overlays", args.stop_after):
1222
1285
  # #326: an overlay video is the caller's file, never an earlier stage's output, so a
1223
1286
  # missing one is refused here, under --dry-run too, not after every stage before it ran
@@ -1396,7 +1459,12 @@ def main() -> int:
1396
1459
  ("color", "--color"), ("background", "--background"),
1397
1460
  ("width", "--width"), ("height", "--height"), ("fps", "--fps")):
1398
1461
  if ag.get(key) is not None:
1399
- argv += [flag, str(ag[key])]
1462
+ value = ag[key]
1463
+ if key == "image" and isinstance(value, str) and "://" not in value:
1464
+ # against the project file, as every other path here and as the output guard
1465
+ # checks it; it used to reach waveform.py as written, against the cwd
1466
+ value = rel(value)
1467
+ argv += [flag, str(value)]
1400
1468
  argv += brand_args
1401
1469
  sh("waveform.py", *argv, stage="audiogram")
1402
1470
  current = nxt
@@ -1542,7 +1610,8 @@ def main() -> int:
1542
1610
  for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform"),
1543
1611
  ("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
1544
1612
  if ov.get(k) is not None:
1545
- argv += [flag, str(ov[k])]
1613
+ # font_file is a path: the same one project_input_files() guards
1614
+ argv += [flag, rel(ov[k]) if k == "font_file" and ov[k] else str(ov[k])]
1546
1615
  if ov.get("box"):
1547
1616
  argv.append("--box")
1548
1617
  # review 12: the overlay stage was the one stage that never heard which destination this