ffmpeg-skill 3.8.7 → 3.8.8

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.8.7`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.8`) | 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.8.7", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.8", "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"},
@@ -726,7 +726,8 @@ Per-tool keys added in 1.17, all additive:
726
726
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
727
727
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
728
728
  | `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
729
- | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
729
+ | `templates` | `render.py --list-templates --json` | one row per shipped template: `{template, destination, frame: {width, height} or null for an audio destination, fit, max_duration, lufs, safe: {top, bottom, left, right}}` (unreleased: before, the table was printed under `--json` too). `render.py --init FILE --json` prints the plain success document with `output` the file it wrote |
730
+ | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, `destination_not_owned` (a file already at the stage's path in `--work` is not one this render wrote). The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
730
731
  | `intermediate_preset` | `render.py` | The x264/x265 preset the stages before an encoding export ran at: `--intermediate-preset`, default `superfast` since 2.16 (`veryfast` in 2.15). `null` when no stage received it: no encoding export was reached, `--fast`, `--stop-after` before the export, a `copy` export, or nothing but the export encoded. Present on every render result, early stops included |
731
732
 
732
733
  Per-tool keys added in 1.17.1, all additive:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.7",
3
+ "version": "3.8.8",
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": [
@@ -977,8 +977,9 @@ rows, youtube's spec assumed, as `check.py --content` without `--platform`); und
977
977
 
978
978
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
979
979
  so the rendered file meets the platform's loudness without a separate pass. Since
980
- 1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
981
- and the project has no `loudness` stage; `"normalize": false` opts out.
980
+ 1.9.0 it is on by default when the preset is a platform's (`youtube`, `youtube4k`,
981
+ `youtube-hdr`, `youtube-av1`, `reels`, `tiktok`, `shorts`, `x`, `linkedin`, `facebook`:
982
+ `export.PLATFORM_OF`) and the project has no `loudness` stage; `"normalize": false` opts out.
982
983
 
983
984
  `"audio": {"stems": {"dialogue": -2, "music": -18, "effects": -24}}` names one
984
985
  level per element of the mix: `dialogue` is the main track's gain, `music` the
@@ -1015,7 +1016,29 @@ relative to the project file; `--export-timeline` lists the layer under
1015
1016
  before `check`, so the markers are in the file that ships (streams copied). The
1016
1017
  entries and the file path are validated before the first stage runs, and the
1017
1018
  stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
1018
- other stage, so the plan lists `chapters` and the run does the same work.
1019
+ other stage, so the plan lists `chapters` and the run does the same work. An
1020
+ inline title is one line: a line feed, carriage return or NUL in it is refused
1021
+ (`kind: input`), since the chapters file holds one `TIME TITLE` line per chapter
1022
+ and FFmpeg ends a title at NUL. Under `--dry-run` the list is written to a
1023
+ temporary file, not into the work directory.
1024
+
1025
+ **`--stop-after export`** stops once the delivery is written: the chapters and
1026
+ check stages do not run (and their sections are not read). **Without an export
1027
+ stage** the delivery is the last stage's file copied as it is (`.mp4`, or the
1028
+ audio container of an audio-only output), so an output named for another
1029
+ container (`.webm`, `.mkv`) is refused, `kind: input`, before the first stage
1030
+ when the copy is certainly an intermediate and at the copy otherwise (a single
1031
+ uncut clip that no stage touches is its source, copied as it is); `.mov`,
1032
+ `.m4v`, `.m4a` and `.3gp` are the MP4 family and are accepted.
1033
+
1034
+ **The work directory.** Without `--work`, each run makes a new
1035
+ `<output>_work_<pid>_<random>/` beside the output and removes it at exit (kept
1036
+ with `--keep`, or by a `--stop-after` preview whose output is in it). `--work
1037
+ DIR` is used as given and never emptied; a stage file an earlier render wrote
1038
+ there, still unchanged (size, mtime, inode: `DIR/.ffskill-render-stages.json`
1039
+ records them), is replaced without `--overwrite`, while any other existing file
1040
+ is refused as before. A `--dry-run` writes nothing there and removes a `--work`
1041
+ directory it had to create.
1019
1042
 
1020
1043
  **`--cache DIR` (1.17)** reuses the artifact of a stage that already ran with
1021
1044
  exactly the same arguments and the same inputs. Opt-in only: **there is no
@@ -1023,7 +1046,8 @@ default cache directory** — one appearing on someone's disk unasked would
1023
1046
  contradict this tool's "a plan leaves nothing behind" posture. The key is a
1024
1047
  sha1 over the stage name, the tool, its arguments (every existing path replaced
1025
1048
  by its content hash), the input hashes, the **forwarded** flags (`--fast`,
1026
- `--overwrite`, `--timeout`, `--codec`), the output's extension, and **the ffmpeg
1049
+ `--overwrite`, `--timeout`; render.py has no `--codec`), the GPU setting (`--hw`/`--no-hw`,
1050
+ `FFMPEG_SKILL_HW`), the output's extension, and **the ffmpeg
1027
1051
  build banner, the skill version and the contract version**. `--fast` matters as
1028
1052
  much as any of them: it rewrites every child's preset to `veryfast`, so without
1029
1053
  it in the key a `--cache --fast` draft would be served back to a later run that
@@ -1037,8 +1061,10 @@ hardlinks the artifact into the work directory, or copies it where the
1037
1061
  filesystem will not link — never moves it, since the cache has to outlive the
1038
1062
  run's own cleanup. A miss is work to do, not an error, but never a silent one:
1039
1063
  `cache.miss_reasons` says why each stage missed (`not_cached`, `sidecar_missing`,
1040
- `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`), and a
1041
- damaged entry (the last two of those that name a file this tool wrote) is also a
1064
+ `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, and
1065
+ `destination_not_owned` when the file already at a stage's path in `--work` is not one this
1066
+ render wrote: the stage runs and refuses it by its own overwrite rule), and a
1067
+ damaged entry (`sidecar_corrupt`, `artifact_truncated`) is also a
1042
1068
  `cache_entry_damaged` warning and a line in `DIR/.ffskill-audit.jsonl`. A sidecar keeps
1043
1069
  the stage's `verified`, `verification`, `notes` and `diagnostics`, and a hit replays
1044
1070
  them, so a cached stage counts toward the render's `verified` as a live one does. A
@@ -1046,7 +1072,14 @@ store that fails is a `cache_store_failed` warning (the run is unaffected).
1046
1072
 
1047
1073
  A stage whose flags changed misses the cache, so editing the captions block (for
1048
1074
  example to `"fit_size": "off"`) re-runs captions and everything after it and
1049
- serves the earlier stages from the cache.
1075
+ serves the earlier stages from the cache. A file is keyed by its name, size,
1076
+ modification time (in nanoseconds; in whole seconds up to 3.8.4) and
1077
+ first and last MiB, the batch cache's key (`batch.file_key()`); a file whose
1078
+ mtime was set back to the old value keeps its key. The chapters file an inline
1079
+ `chapters` list is written to is new every run, so it is keyed by its content
1080
+ instead, and the chapters stage hits when the delivery under it is the cached
1081
+ one (an export served from the cache; a delivery copied without an export stage
1082
+ is a new file, and misses).
1050
1083
 
1051
1084
  `stages_done` is unchanged: a cached stage is still a stage that happened.
1052
1085
  Nothing is written under `--dry-run`, which instead reports `cache.would_hit`.
@@ -1086,7 +1119,10 @@ no overlay stage at all, not an overlay of nothing. The filled project then rend
1086
1119
  normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
1087
1120
  unknown name is refused (`kind: input`) with the list. Output defaults to
1088
1121
  `<input>_<template>.mp4` **next to the input** (`.m4a` for an audio-only destination such as
1089
- `podcast`) — the same rule for one template and for a pack, whose `-o` names the directory.
1122
+ `podcast`) — the same rule for one template and for a pack. A pack writes each destination as
1123
+ `<input>_<template>.<ext>` and the table as `<input>_pack.md` in the **directory of** `-o`:
1124
+ `-o packs/talk.mp4` writes into `packs/`, and `-o packs` into the folder that holds `packs`
1125
+ (the file name in `-o` is not used).
1090
1126
  Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts` = `shorts`,
1091
1127
  `yt` = `youtube`, `instagram`/`ig` = `reels`, `twitter` = `x`, `fb` = `facebook`
1092
1128
  (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
@@ -1137,7 +1173,8 @@ whose destinations did not all pass exits non-zero with the per-destination rows
1137
1173
 
1138
1174
  `audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
1139
1175
  **first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
1140
- then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
1176
+ then captions, a `--title` lower third (graphics, as the youtube template draws it), loudness,
1177
+ export and check, with `--brand` reaching the audiogram, caption and graphics stages. It needs `--image` (a local file) or a `background`
1141
1178
  colour in the project — this skill fetches nothing and invents no cover art; `--template
1142
1179
  audiogram` without `--image` is refused before anything runs, and a project with an `audiogram`
1143
1180
  and more than one clip is refused (it is drawn from one audio source) — and it is
@@ -1331,8 +1368,18 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
1331
1368
  ```
1332
1369
  `batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
1333
1370
  placeholders, chained) or `project` (a render project applied per file).
1334
- Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
1335
- already done with the same recipe. Use `--dry-run` to preview the plan. The recipe's
1371
+ Outputs land in `output_dir` with `suffix`, named with the source's extension
1372
+ unless the recipe sets `ext` -- except a `.webm` source, whose output is `.mp4`
1373
+ (the steps re-encode to H.264/AAC, which WebM cannot hold; a recipe that keeps
1374
+ WebM sets `"ext": "webm"`). `glob` matches names without regard to case on every
1375
+ platform (`"*.mp4"` finds `GOPR0001.MP4`; the letters inside a `[...]` class keep
1376
+ theirs), and a glob that matches nothing is a note. A cache skips files already
1377
+ done with the same recipe: an item's key is its source's `file_key()` (name,
1378
+ size, mtime in nanoseconds, first and last MiB), the recipe, the GPU setting,
1379
+ the files the steps name and, for a project recipe, the project file and every
1380
+ file it names (music, cues, brand and its logo and font, overlays, chapters,
1381
+ the clips other than the one each item replaces), read again on every
1382
+ `--watch` pass. Use `--dry-run` to preview the plan. The recipe's
1336
1383
  keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
1337
1384
  other key is refused before an item runs, with the nearest valid name when one is close, else
1338
1385
  with the list of valid keys.
@@ -2692,10 +2692,23 @@ def parse_json(text: str) -> Any:
2692
2692
  if isinstance(value, (dict, list)):
2693
2693
  if depth > JSON_MAX_DEPTH:
2694
2694
  raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
2695
+ if isinstance(value, dict):
2696
+ stack.extend((k, depth) for k in value)
2695
2697
  stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
2698
+ elif isinstance(value, str) and _SURROGATE.search(value):
2699
+ # A264: "\ud83d" alone is valid JSON and no text: json reads it as a lone surrogate,
2700
+ # which no UTF-8 file, argument or stdout can encode -- the run died on the first write
2701
+ # with a UnicodeEncodeError traceback and nothing on stdout
2702
+ bad = _SURROGATE.search(value).group(0)
2703
+ raise ValueError(f"the string \"{value[:60].encode('ascii', 'backslashreplace').decode()}\" holds \\u{ord(bad):04x}, "
2704
+ "half of a UTF-16 surrogate pair with no other half: it is no character, so no file can "
2705
+ "hold it -- write the character itself, or both halves of its \\u escape")
2696
2706
  return data
2697
2707
 
2698
2708
 
2709
+ _SURROGATE = re.compile("[\ud800-\udfff]")
2710
+
2711
+
2699
2712
  def read_json(path: "os.PathLike[str] | str") -> Any:
2700
2713
  """parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
2701
2714
  (UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""
@@ -142,11 +142,15 @@ def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
142
142
 
143
143
  def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
144
144
  """`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
145
- whose own timecode is drop-frame, so the label is the one the media itself shows."""
145
+ whose own timecode is drop-frame, so the label is the one the media itself shows. Timecode
146
+ wraps at 24 hours as the media's own does (output_timecode_args() counts the same day): a
147
+ source starting at 23:59:50:00 is labelled 00:00:00:00 ten seconds in, not 24:00:00:00, which
148
+ no editor reads (A266)."""
146
149
  nominal = int(round(float(rate)))
147
150
  if not _drop_frame(drop, rate):
148
- return _tc(frames, nominal)
151
+ return _tc(frames % (nominal * 86400), nominal) if nominal >= 1 else _tc(frames, nominal)
149
152
  d = nominal // 15
153
+ frames %= (nominal * 600 - d * 9) * 144
150
154
  per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
151
155
  tens, rem = divmod(frames, per_10)
152
156
  frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
@@ -376,6 +380,19 @@ def _tc(frames: int, fps_int: int) -> str:
376
380
  return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
377
381
 
378
382
 
383
+ # CMX 3600 numbers events in a three-digit field, and record timecode runs from 01:00:00:00 within
384
+ # one 24-hour day.
385
+ EDL_MAX_EVENTS = 999
386
+
387
+
388
+ def _edl_text(value: Any) -> str:
389
+ """Caller text (a file name, a clip or chapter name, the title) on one EDL line. The format is
390
+ one statement per line with no escape, so a line break -- any str.splitlines() boundary -- or
391
+ another control character in a name added lines, or whole events, that parse_error() then
392
+ accepted (A261). Each becomes a space."""
393
+ return "".join(" " if (ch in "\x85\u2028\u2029" or ord(ch) < 32 or ord(ch) == 127) else ch for ch in str(value))
394
+
395
+
379
396
  def to_edl(tl: Dict[str, Any]) -> str:
380
397
  """CMX 3600, non-drop timecode at the nominal integer rate, record starting at 01:00:00:00.
381
398
  Every event is reel AX (the convention for file-based media) with the file named in
@@ -386,16 +403,24 @@ def to_edl(tl: Dict[str, Any]) -> str:
386
403
  from where the previous one stopped to where the next dissolve begins."""
387
404
  fps_int = int(round(float(tl["rate"])))
388
405
  hour = fps_int * 3600
406
+ rate = tl["rate"]
389
407
  clips = tl["clips"]
390
408
  d = tl["transition_frames"]
391
409
  # record range of each event: starts `trim_head` before its visible start (the dissolve
392
410
  # window), ends where the next event starts
393
411
  starts = [c["record_in"] - c["trim_head"] for c in clips]
394
412
  ends = starts[1:] + [tl["total"]]
395
- lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
413
+ events = len(clips) + (1 if tl["music"] else 0)
414
+ if events > EDL_MAX_EVENTS:
415
+ # A265: f"{n:03d}" wrote event 1000 as four digits, outside the field
416
+ raise TimelineError(f"an EDL numbers its events in three digits (at most {EDL_MAX_EVENTS}); this timeline has "
417
+ f"{events} -- export .fcpxml or .otio, or split the project")
418
+ if hour + tl["total"] > fps_int * 86400:
419
+ raise TimelineError(f"the timeline ({float(Fraction(tl['total']) / rate):.0f}s) runs past 24:00:00:00 when the "
420
+ "EDL's record starts at 01:00:00:00 -- export .fcpxml or .otio, or split the project")
421
+ lines = [f"TITLE: {_edl_text(tl['name'])}", "FCM: NON-DROP FRAME", ""]
396
422
  n = 0
397
423
  prev_src_end = ""
398
- rate = tl["rate"]
399
424
 
400
425
  def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
401
426
  """The source timecode `seq_frames` (sequence frames of source media) after the clip's in
@@ -415,13 +440,13 @@ def to_edl(tl: Dict[str, Any]) -> str:
415
440
  f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
416
441
  lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
417
442
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
418
- lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
419
- lines.append(f"* TO CLIP NAME: {c['name']}")
443
+ lines.append(f"* FROM CLIP NAME: {_edl_text(clips[i - 1]['name'])}")
444
+ lines.append(f"* TO CLIP NAME: {_edl_text(c['name'])}")
420
445
  else:
421
446
  lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
422
447
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
423
- lines.append(f"* FROM CLIP NAME: {c['name']}")
424
- lines.append(f"* SOURCE FILE: {c['src']}")
448
+ lines.append(f"* FROM CLIP NAME: {_edl_text(c['name'])}")
449
+ lines.append(f"* SOURCE FILE: {_edl_text(c['src'])}")
425
450
  if abs(c["speed"] - 1.0) > 1e-6:
426
451
  # the speed in source frames per second, at the rate the source labels are counted in
427
452
  own_fps = int(round(float(c.get("rate") or rate)))
@@ -433,11 +458,11 @@ def to_edl(tl: Dict[str, Any]) -> str:
433
458
  m = tl["music"]
434
459
  lines.append(f"{n:03d} AX A2 C {_tc(0, fps_int)} {_tc(m['length'], fps_int)} "
435
460
  f"{_tc(hour, fps_int)} {_tc(hour + m['length'], fps_int)}")
436
- lines.append(f"* FROM CLIP NAME: {m['name']}")
437
- lines.append(f"* SOURCE FILE: {m['src']}")
461
+ lines.append(f"* FROM CLIP NAME: {_edl_text(m['name'])}")
462
+ lines.append(f"* SOURCE FILE: {_edl_text(m['src'])}")
438
463
  lines.append("")
439
464
  for mk in tl["markers"]:
440
- lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {mk['title']}")
465
+ lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {_edl_text(mk['title'])}")
441
466
  return "\n".join(lines).rstrip("\n") + "\n"
442
467
 
443
468
 
@@ -459,6 +484,14 @@ def _url(path: str) -> str:
459
484
  return Path(os.path.abspath(path)).as_uri()
460
485
 
461
486
 
487
+ def _xml_text(value: Any) -> str:
488
+ """Caller text as XML 1.0 can hold it: a control character other than tab, line feed and
489
+ carriage return (and U+FFFE/U+FFFF) has no representation at all, not even escaped, so a clip
490
+ or chapter name carrying one made the FCPXML not well-formed (the sibling of A261). Each
491
+ becomes a space."""
492
+ return "".join(" " if ((ord(ch) < 32 and ch not in "\t\n\r") or ch in "\ufffe\uffff") else ch for ch in str(value))
493
+
494
+
462
495
  def to_fcpxml(tl: Dict[str, Any]) -> str:
463
496
  """FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
464
497
  asset-clips with cross dissolves between them, the music bed as a connected clip on lane -1
@@ -466,7 +499,7 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
466
499
  sequence has its own format (r1); each video asset has its source's, and a clip of another
467
500
  aspect states its spatial conform (adjust-conform) for frame.fit."""
468
501
  rate = tl["rate"]
469
- q = lambda s: _xml_escape(str(s), {'"': "&quot;"}) # noqa: E731
502
+ q = lambda s: _xml_escape(_xml_text(s), {'"': "&quot;"}) # noqa: E731
470
503
  fd = _t(1, rate)
471
504
  res: List[str] = []
472
505
  ids: Dict[Any, str] = {} # a format's (width, height, frameDuration) or an asset's path -> its id
@@ -751,7 +751,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
751
751
  "guides": {"type": "array", "description": "--guides only: each outline drawn on the PNG, {guide: action|title, fraction (0.93 / 0.90 of the frame, SMPTE ST 2046-1), of: frame} or {guide: 4:3, window, action: 0.90, title: 0.80}; drawn on the preview only"}}
752
752
  elif name == "render":
753
753
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
754
- "cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
754
+ "cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path|destination_not_owned, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
755
755
  "caption": {"type": ["object", "null"], "description": "the caption stage's own block, forwarded verbatim from caption.py (cue-layout counts and fit-size keys); null without a caption stage"},
756
756
  "snap": {"type": ["object", "null"], "description": "a project's \"snap\": cut.py's snap block for the first snapped clip, with every clip's in clips; null when nothing was snapped"},
757
757
  "intermediate_preset": {"type": ["string", "null"], "description": "2.15: the x264/x265 preset the stages before an encoding export ran at (--intermediate-preset, default superfast since 2.16); null when no stage received it (no encoding export reached, --fast, --stop-after before the export, a copy export, or nothing but the export encoded)"},
@@ -763,7 +763,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
763
763
  "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
764
764
  "timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
765
765
  "transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
766
- "editor timeline cannot carry)"}}
766
+ "editor timeline cannot carry)"},
767
+ "templates": {"type": "array", "description": "--list-templates --json: one row per shipped template, {template, "
768
+ "destination, frame: {width, height} or null (audio), fit, max_duration, lufs, safe: {top, bottom, "
769
+ "left, right}}; without --json the same rows are a table"}}
767
770
  elif name == "verify":
768
771
  extra = {"report": {"type": ["string", "null"]},
769
772
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
package/scripts/batch.py CHANGED
@@ -37,7 +37,8 @@ from _common import STATE, add_common, apply_common, child_args, die, emit, info
37
37
  from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, read_json,
38
38
  owned_identity, stop_children)
39
39
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
40
- from _common.decision import rebase_project_paths
40
+ from _common.decision import NO_DEFAULT_ENCODE_EXT, rebase_project_paths
41
+ from _common.runner import parse_json
41
42
 
42
43
  HERE = Path(__file__).resolve().parent
43
44
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -51,9 +52,16 @@ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswi
51
52
 
52
53
 
53
54
  def file_key(path: Path) -> str:
55
+ """A cheap change detector: the name, the size, the modification time in nanoseconds and the
56
+ first and last MiB. A rewrite of the middle of a file keeps its size and its ends, so the
57
+ modification time is what tells it apart: in whole seconds (up to 3.8.4) two writes within
58
+ one second shared a key, and the old output was served as cached. A file whose mtime is reset
59
+ to the earlier value (``touch -r``, a copy that preserves it) or a filesystem that keeps
60
+ whole seconds (FAT keeps two) still shares one; hashing every byte would cost a read of the
61
+ whole file per item per run (docs/design-decisions.md, "After 3.8.4")."""
54
62
  st = path.stat()
55
63
  h = hashlib.sha1(usedforsecurity=False) # a change detector, not a secret
56
- h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
64
+ h.update(f"{path.name}|{st.st_size}|{st.st_mtime_ns}".encode())
57
65
  with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
58
66
  h.update(fh.read(1 << 20))
59
67
  if st.st_size > 2 << 20:
@@ -86,6 +94,30 @@ def side_inputs_key(recipe: Dict[str, Any]) -> str:
86
94
  return h.hexdigest()[:8]
87
95
 
88
96
 
97
+ def caseless_glob(pattern: str) -> str:
98
+ """`pattern` with every letter outside a [...] class matched in either case: "*.mp4" finds
99
+ GOPR0001.MP4, as it already did on macOS's and Windows' default filesystems, so a recipe means
100
+ the same files on every platform (A82: on Linux a camera folder was an empty batch). pathlib's
101
+ case_sensitive= needs Python 3.12; 3.10 is the floor."""
102
+ out: List[str] = []
103
+ in_class = False
104
+ first = -1 # index of the class's first member: a "]" there is a member, not the end ("[]a]", "[!]a]")
105
+ for i, ch in enumerate(pattern):
106
+ if in_class:
107
+ out.append(ch)
108
+ if ch == "]" and i > first:
109
+ in_class = False
110
+ elif ch == "[":
111
+ first = i + 2 if pattern[i + 1:i + 2] == "!" else i + 1
112
+ in_class = "]" in pattern[first + 1:] # an unclosed [ ("[!]x") is a literal character to fnmatch
113
+ out.append(ch if in_class else "[[]")
114
+ elif ch.isalpha() and ch.lower() != ch.upper():
115
+ out.append(f"[{ch.lower()}{ch.upper()}]")
116
+ else:
117
+ out.append(ch)
118
+ return "".join(out)
119
+
120
+
89
121
  def recipe_key(recipe: Dict[str, Any]) -> str:
90
122
  # A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
91
123
  # (export preset, captions, everything) live in the file at that path, not in this dict.
@@ -93,18 +125,57 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
93
125
  # itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
94
126
  # "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
95
127
  # content into the key so a content change invalidates the cache like any other edit would.
128
+ # main() calls this on every pass, so a --watch loop sees an edit made while it runs (A81).
96
129
  project_content = ""
97
130
  if recipe.get("project"):
98
131
  try:
99
132
  project_content = Path(recipe["project"]).read_text(encoding="utf-8")
100
133
  except (OSError, UnicodeDecodeError) as exc:
101
134
  # keyed on the recipe alone: an unreadable project cannot invalidate the cache by its
102
- # content, so say so (each item that needs it then fails on reading it)
103
- warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
104
- "recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
135
+ # content, so say so (each item that needs it then fails on reading it); once per run
136
+ entry = {"code": "recipe_project_unreadable", "path": str(recipe["project"])}
137
+ if not any(all(w.get(k) == v for k, v in entry.items()) for w in STATE.warnings if isinstance(w, dict)):
138
+ warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
139
+ "recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
140
+ else:
141
+ project_content += "\0" + project_files_key(recipe, project_content)
105
142
  return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode(), usedforsecurity=False).hexdigest()[:12]
106
143
 
107
144
 
145
+ def project_files_key(recipe: Dict[str, Any], text: str) -> str:
146
+ """The files a project recipe's project names (music, cues, the brand and its logo and font,
147
+ overlay images and videos, the chapters file, every clip but the one each item replaces),
148
+ each keyed by its own content as file_key() keys a source (A80): an edited cue file or music
149
+ bed changes the output, so it has to change the key. The list is render.py's own
150
+ project_input_files(), read against the project file's directory as render reads it. A
151
+ project that does not parse adds nothing: each item then fails reading it."""
152
+ try:
153
+ proj = parse_json(text)
154
+ except ValueError:
155
+ return ""
156
+ if not isinstance(proj, dict):
157
+ return ""
158
+ from render import project_input_files # here, not at the top: render.py imports this module
159
+ base = Path(str(recipe["project"])).resolve().parent
160
+ clips = proj.get("clips")
161
+ idx = int(recipe.get("clip_key") or 0) # check_recipe_types() has refused anything else
162
+ if isinstance(clips, list) and idx < len(clips) and isinstance(clips[idx], dict):
163
+ # the clip every item replaces with itself: its placeholder is no input of any item
164
+ proj = dict(proj, clips=[dict(c, src=None) if i == idx else c for i, c in enumerate(clips)])
165
+
166
+ def rel(p: Any) -> str:
167
+ return str(p) if os.path.isabs(str(p)) else str(base / str(p))
168
+ h = hashlib.sha1(usedforsecurity=False)
169
+ for name in sorted(set(project_input_files(proj, rel))):
170
+ path = Path(name)
171
+ try:
172
+ ident = file_key(path) if path.is_file() else "-"
173
+ except OSError:
174
+ ident = "-"
175
+ h.update(f"{name}\0{ident}\0".encode())
176
+ return h.hexdigest()
177
+
178
+
108
179
  def gpu_key() -> str:
109
180
  """The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
110
181
  not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
@@ -222,6 +293,11 @@ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
222
293
  # detection of its own; see one_pass()'s pre-flight check, which uses this same computation
223
294
  # to catch that before any file is actually processed (and the earlier one silently clobbered).
224
295
  final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
296
+ if not recipe.get("ext") and src.suffix.lower() in NO_DEFAULT_ENCODE_EXT:
297
+ # the #415 default-name rule (default_output()): the steps re-encode to x264/AAC, which a
298
+ # WebM cannot hold, so every .webm item was refused output_container_codecs (A83). A
299
+ # recipe that keeps WebM (a stream copy, export.py --preset vp9-alpha) sets "ext".
300
+ final_ext = "mp4"
225
301
  steps = recipe.get("steps") or []
226
302
  if not recipe.get("ext") and not recipe.get("project") and steps and not step_writes(steps[-1]) and not writes_nothing(recipe):
227
303
  # the last step writes nothing, so what is delivered is an intermediate: its name says its
@@ -649,7 +725,6 @@ def main() -> int:
649
725
  audit("batch_cache_reset", {"path": str(cache_path), "size": size, "error": str(exc)},
650
726
  default_path=str(outdir / ".ffskill-audit.jsonl"))
651
727
  cache = {}
652
- rkey = recipe_key(recipe) + gpu_key()
653
728
  glob = recipe.get("glob") or "*"
654
729
 
655
730
  # --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
@@ -706,6 +781,7 @@ def main() -> int:
706
781
  # first seen, last modified at least one interval ago; until then its row is
707
782
  # skipped: "settling", not cached, and the next pass looks again.
708
783
  last_seen: Dict[str, "tuple[int, int]"] = {}
784
+ shown_empty = {"done": False}
709
785
 
710
786
  def settle_poll(files: List[Path]) -> "set":
711
787
  if not args.watch:
@@ -728,7 +804,12 @@ def main() -> int:
728
804
  return unsettled
729
805
 
730
806
  def one_pass() -> List[Dict[str, Any]]:
731
- files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
807
+ files = sorted(p for p in folder.glob(caseless_glob(glob)) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
808
+ if not files and not shown_empty["done"]:
809
+ # a batch of nothing completed with total 0 and not a word (A82): say what matched nothing
810
+ shown_empty["done"] = True
811
+ note(f"no media file in {folder} matches the recipe's glob {glob!r} (matched without regard to case); "
812
+ "nothing was processed")
732
813
  # Two different sources can compute the same final path (most often a fixed recipe "ext"
733
814
  # collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
734
815
  # processing anything, rather than letting the later one silently overwrite the earlier
@@ -805,7 +886,9 @@ def main() -> int:
805
886
  slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
806
887
  pending: List[tuple] = []
807
888
  cache_miss: Dict[int, Dict[str, Any]] = {}
808
- pass_key = rkey + side_inputs_key(recipe)
889
+ # read on every pass: a --watch loop serves nothing from an edited project.json, cue file or
890
+ # music bed (computed once before the loop, it did: A81)
891
+ pass_key = recipe_key(recipe) + gpu_key() + side_inputs_key(recipe)
809
892
  polled = settle_poll(files)
810
893
  for i, src in enumerate(files):
811
894
  if str(src) in polled:
@@ -52,6 +52,10 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
52
52
  # chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
53
53
  start = time_arg(parts[0], f"{path}:{n}")
54
54
  title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
55
+ if "\x00" in title:
56
+ # FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
57
+ # sibling of A260 in render.py's inline chapters)
58
+ die(f"{path}:{n}: the chapter title holds a NUL character, which a chapter title cannot carry; remove it")
55
59
  if entries and start <= entries[-1]["start"]:
56
60
  die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
57
61
  if duration and start >= duration:
package/scripts/render.py CHANGED
@@ -75,7 +75,7 @@ from typing import Any, Dict, List, Optional, Sequence
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
77
  from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
- from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, read_json, remember_written
78
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
80
  from _common.decision import rebase_project_paths
81
81
  import subprocess
@@ -304,8 +304,11 @@ def template_output(input_path: str, name: str, dest: str) -> Path:
304
304
 
305
305
 
306
306
  def list_templates() -> None:
307
- """The templates, the destination each delivers to, and the zones its UI covers."""
308
- print("%-15s %-11s %-5s %7s %-10s %s" % ("template", "frame", "fit", "max", "loudness", "safe zones (fraction of the frame)"))
307
+ """The templates, the destination each delivers to, and the zones its UI covers: a table, or
308
+ under --json one row per template in `templates` (A102: the table was printed under --json)."""
309
+ rows: List[Dict[str, Any]] = []
310
+ if not STATE.json:
311
+ print("%-15s %-11s %-5s %7s %-10s %s" % ("template", "frame", "fit", "max", "loudness", "safe zones (fraction of the frame)"))
309
312
  for name in template_names():
310
313
  tpl = load_template(name)
311
314
  dest = str((tpl.get("check") or {}).get("platform") or name)
@@ -318,7 +321,13 @@ def list_templates() -> None:
318
321
  size = "%dx%d" % (frame["w"], frame["h"]) if frame else "audio"
319
322
  fit = str((tpl.get("frame") or {}).get("fit") or "-")
320
323
  loud = ("%g LUFS" % spec["lufs"]) if spec.get("lufs") is not None else "-"
321
- print("%-15s %-11s %-5s %7s %-10s %s" % (name, size, fit, ("%gs" % dur) if dur else "-", loud, zones or "none"))
324
+ rows.append({"template": name, "destination": dest, "frame": {"width": frame["w"], "height": frame["h"]} if frame else None,
325
+ "fit": None if fit == "-" else fit, "max_duration": dur, "lufs": spec.get("lufs"),
326
+ "safe": {edge: safe.get(edge, 0) for edge in ("top", "bottom", "left", "right")}})
327
+ if not STATE.json:
328
+ print("%-15s %-11s %-5s %7s %-10s %s" % (name, size, fit, ("%gs" % dur) if dur else "-", loud, zones or "none"))
329
+ if STATE.json:
330
+ emit(None, templates=rows, stages=[], check=None)
322
331
 
323
332
 
324
333
  # `--template all`: every destination a single edit is normally posted to. The podcast template
@@ -346,6 +355,19 @@ def expand_templates(value: str) -> List[str]:
346
355
  return seen
347
356
 
348
357
 
358
+ def md_line(text: Any) -> str:
359
+ """Caller text on one Markdown line: each line break (any str.splitlines() boundary) and other
360
+ control character becomes a space, so a file name cannot start a line of its own (A263)."""
361
+ return "".join(" " if (ch in "\x85\u2028\u2029" or ord(ch) < 32 or ord(ch) == 127) else ch for ch in str(text))
362
+
363
+
364
+ def md_cell(text: Any) -> str:
365
+ """Caller text in one cell of a GFM table row: on one line (md_line()), a backslash doubled
366
+ and a '|' escaped as '\\|', so `a | b.mp4` stays in its column. report.py --pack reads the
367
+ escapes back (A263: the columns shifted and passing destinations were reported failed)."""
368
+ return md_line(text).replace("\\", "\\\\").replace("|", "\\|")
369
+
370
+
349
371
  def render_pack(names: List[str], args) -> int:
350
372
  """The social pack: one edit delivered to every named destination, plus a table of what
351
373
  was written. Each destination is a full render (its own frame, captions, loudness, export
@@ -415,14 +437,14 @@ def render_pack(names: List[str], args) -> int:
415
437
  if err.get("kind") in ("timeout", "interrupted"):
416
438
  stopped.append({"kind": err["kind"], "exit_code": child_exit_code(doc, proc.returncode)})
417
439
  pack = str(outdir / f"{stem}_pack.md")
418
- lines = [f"# Social pack — {stem}", "",
440
+ lines = [f"# Social pack — {md_line(stem)}", "",
419
441
  "| platform | file | size | duration | check |", "|---|---|---|---|---|"]
420
442
  for r in rows:
421
443
  dur = f"{r['duration']:.2f} s" if r.get("duration") else "-"
422
444
  mb = f"{r['size_bytes'] / 1024 / 1024:.1f} MB" if r.get("size_bytes") else "-"
423
445
  verdict = r["check"] + (f" (upload-limit guidance: {'; '.join(r['guidance'])})" if r.get("guidance") else "")
424
- lines.append(f"| {r['platform']} | {r['file']} | {mb} | {dur} | {verdict} |")
425
- lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
446
+ lines.append("| " + " | ".join(md_cell(c) for c in (r["platform"], r["file"], mb, dur, verdict)) + " |")
447
+ lines += ["", f"{len(rows)} destinations from one edit ({md_line(os.path.basename(args.input))}).",
426
448
  "Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
427
449
  if not STATE.dry_run:
428
450
  if not outdir.is_dir():
@@ -501,7 +523,8 @@ def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
501
523
  if not must_exist or os.path.isfile(p):
502
524
  found.append(p)
503
525
 
504
- for c in proj.get("clips") or []:
526
+ clips = proj.get("clips")
527
+ for c in clips if isinstance(clips, list) else []: # a wrong type is validate_project()'s to refuse
505
528
  if isinstance(c, dict):
506
529
  add(c.get("src"))
507
530
  if isinstance(c.get("snap"), dict):
@@ -604,6 +627,13 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
604
627
  check_keys(item, "chapters[]", f"chapters[{i}]")
605
628
  if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
606
629
  die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
630
+ # the chapters stage writes one `TIME TITLE` line per chapter for metadata.py, which
631
+ # reads a line break as the end of the chapter (a CR too: the file is read as text) and
632
+ # FFmpeg ends a title at NUL: "Intro\n0:01 Evil" was two chapters, verified (A260)
633
+ bad = next((ch for ch in str(item["title"]) if ch in "\n\r\x00"), None)
634
+ if bad is not None:
635
+ die(f"chapters[{i}].title: holds U+{ord(bad):04X} ({'NUL' if bad == chr(0) else 'a line break'}), which "
636
+ "a chapter title cannot carry -- a title is one line of text; remove it", kind="input")
607
637
  for name in ("clips", "graphics", "overlays"):
608
638
  items = proj.get(name)
609
639
  if name == "clips" or (not timeline and _reaches(READ_AT[name], stop_after)): # the export reads only the clips
@@ -707,6 +737,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
707
737
  _LAST_DOC["cached"] = True
708
738
  if not STATE.dry_run:
709
739
  remember_written(dest) # placed by this run, as a stage's own write would be (#415)
740
+ _record_own_stage_file(dest)
710
741
  return dest
711
742
  CACHE["misses"].append(stage)
712
743
  _refuse_uncached_earlier_stage(stage)
@@ -714,7 +745,12 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
714
745
  CACHE["misses"].append(stage)
715
746
  _cache_miss(stage, "no_output_path") # nothing names where the artifact goes, so there is nothing to keep
716
747
  started = time.time()
717
- cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
748
+ # an earlier render's own stage file in this --work (A98): replaced as this run's own would be.
749
+ # After the cache key, which this does not change.
750
+ own = ["--overwrite"] if not STATE.overwrite and _earlier_own_stage_file(dest) else []
751
+ if own:
752
+ info(f" {os.path.basename(str(dest))} is an earlier render's own stage file in {_OWN_STAGES['dir']}, unchanged since: replaced")
753
+ cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
718
754
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
719
755
  proc = run_tool(cmd)
720
756
  for line in proc.stderr.splitlines():
@@ -754,6 +790,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
754
790
  # places its tagged copy over the export's delivery, which the output guard would
755
791
  # otherwise refuse as an existing file without --overwrite (#415).
756
792
  remember_written(out_path, dest)
793
+ _record_own_stage_file(dest)
757
794
  if key:
758
795
  cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
759
796
  return out_path
@@ -808,12 +845,20 @@ def _intermediate_used() -> Optional[str]:
808
845
  return INTERMEDIATE["preset"] if INTERMEDIATE["applied"] else None
809
846
 
810
847
 
848
+ # Small files this run writes itself and hands to a stage (the inline chapters list's chapters.txt):
849
+ # keyed by their whole content, not by file_key()'s mtime, since a new run writes them anew and
850
+ # their stage could never hit the cache (A100).
851
+ CONTENT_KEYED: set = set()
852
+
853
+
811
854
  def _content_hash(path: str) -> str:
812
855
  """The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
813
856
  MB. Reused rather than reinvented so one file has one identity across the skill. A file that
814
857
  cannot be read keys as "missing" -- recorded (cache_input_unreadable), since two unreadable
815
- files then share that part of the key."""
858
+ files then share that part of the key. A file in CONTENT_KEYED is keyed by its bytes."""
816
859
  try:
860
+ if os.path.abspath(path) in CONTENT_KEYED:
861
+ return "content:" + hashlib.sha1(Path(path).read_bytes(), usedforsecurity=False).hexdigest()
817
862
  return file_key(Path(path))
818
863
  except (OSError, ValueError) as exc:
819
864
  entry = {"code": "cache_input_unreadable", "path": str(path)}
@@ -923,6 +968,10 @@ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) ->
923
968
  if STATE.dry_run:
924
969
  CACHE["would_hit"].append(stage)
925
970
  return True
971
+ if Path(dest).exists() and not STATE.overwrite and not _earlier_own_stage_file(dest):
972
+ # a file in --work that is not this render's own (the caller's, or one they edited): a hit
973
+ # would unlink it without --overwrite. Run the stage instead, which refuses it by its rule
974
+ return _cache_miss(stage, "destination_not_owned", key=key, destination=str(dest))
926
975
  Path(dest).parent.mkdir(parents=True, exist_ok=True)
927
976
  try:
928
977
  if Path(dest).exists():
@@ -1034,6 +1083,13 @@ def run_check(output: str, platform: Optional[str], content: bool) -> Dict[str,
1034
1083
  "message": msg, "exit_code": cp.returncode, "stderr_tail": tail}}
1035
1084
 
1036
1085
 
1086
+ def _from_plan_cwd(output: Any) -> Any:
1087
+ """A plan's output as an absolute path. execute_plan() has moved into the plan's cwd, so a
1088
+ relative one named a file there, and the result reported it to a caller in another directory
1089
+ as if it were in theirs (A96)."""
1090
+ return os.path.abspath(str(output)) if isinstance(output, str) and output else output
1091
+
1092
+
1037
1093
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
1038
1094
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
1039
1095
  the plan was made (the plan's commands would then describe a different edit), run the tool
@@ -1093,7 +1149,8 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1093
1149
  if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True):
1094
1150
  record_skip("[dry-run] the plan's check step is not run: there is no output to check yet",
1095
1151
  "check_dry_run", platform=step.get("platform"), content=step.get("content") is True)
1096
- emit(plan.get("output"), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None, unbound_inputs=unbound)
1152
+ emit(_from_plan_cwd(plan.get("output")), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None,
1153
+ unbound_inputs=unbound)
1097
1154
  return 0
1098
1155
  info(f"executing plan {path}: {tool} " + " ".join(argv))
1099
1156
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
@@ -1114,7 +1171,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1114
1171
  code=child_exit_code(doc, proc.returncode), kind=err.get("kind", "ffmpeg"), plan=path, tool=tool,
1115
1172
  error_extra=error_extra, **_stage_failure_hw(doc))
1116
1173
  absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
1117
- output = doc.get("output") or plan.get("output")
1174
+ output = _from_plan_cwd(doc.get("output") or plan.get("output"))
1118
1175
  check_result = None
1119
1176
  exit_code = 0
1120
1177
  for step in plan.get("verify") or []:
@@ -1180,6 +1237,8 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
1180
1237
  paths.append(rel(proj["audio"]["music"]))
1181
1238
  probes: Dict[str, Dict[str, Any]] = {}
1182
1239
  for path in paths:
1240
+ if path in probes:
1241
+ continue # one ffprobe per file: ten clips of one recording are one probe (A267)
1183
1242
  if not os.path.exists(path):
1184
1243
  die(f"timeline source not found: {path}")
1185
1244
  probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
@@ -1197,9 +1256,9 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
1197
1256
  del frame["aspect"]
1198
1257
  try:
1199
1258
  tl = tlmod.build(dict(proj, frame=frame), probes, rel)
1259
+ text = tlmod.WRITERS[fmt](tl) # a writer refuses what its format cannot hold (EDL: 999 events, 24 h)
1200
1260
  except tlmod.TimelineError as exc:
1201
1261
  die(f"--export-timeline: {exc}", kind="input")
1202
- text = tlmod.WRITERS[fmt](tl)
1203
1262
  report = tlmod.summary(tl, fmt)
1204
1263
  _check_output_path(["ffmpeg", dest])
1205
1264
  _check_existing_output(["ffmpeg", dest])
@@ -1234,6 +1293,89 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
1234
1293
  return False
1235
1294
 
1236
1295
 
1296
+ def cache_report(cache_dir: Optional[str]) -> Optional[Dict[str, Any]]:
1297
+ """The result's `cache` object (None without --cache), with its stderr summary line."""
1298
+ if not cache_dir:
1299
+ return None
1300
+ report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1301
+ "hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
1302
+ "saved_seconds": round(CACHE["saved_seconds"], 1),
1303
+ "entries": CACHE["entries"]}
1304
+ if STATE.dry_run:
1305
+ report["would_hit"] = CACHE["would_hit"]
1306
+ info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
1307
+ f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
1308
+ return report
1309
+
1310
+
1311
+ # One container family: a copy of an MP4 file named .mov (or .m4v, .m4a, .3gp) is the bytes that
1312
+ # name says. Every other pair of extensions names two different containers.
1313
+ ISO_BMFF_EXT = frozenset({".mp4", ".mov", ".m4v", ".m4a", ".3gp"})
1314
+
1315
+
1316
+ def refuse_copied_container(have: str, output: str) -> None:
1317
+ """Refuse delivering a file of container `have` (an extension) by copying it to `output`
1318
+ under another container's name: with no export stage nothing converts it (A84)."""
1319
+ want = Path(output).suffix.lower()
1320
+ if have == want or (have in ISO_BMFF_EXT and want in ISO_BMFF_EXT):
1321
+ return
1322
+ die(f"output {output}: this project has no \"export\" stage, so the delivery is the last stage's "
1323
+ f"{have or 'extension-less'} file copied as it is, which the name {want or '(no extension)'} would mislabel -- "
1324
+ f"name the output {have or 'with that extension'}, or add an \"export\" stage, which writes the container "
1325
+ "its preset names", kind="input", hint=f"\"output\": \"{Path(output).with_suffix(have).name}\"" if have else None)
1326
+
1327
+
1328
+ # A98: the stage files a render with an explicit --work wrote there, {real path: owned_identity()},
1329
+ # kept in <work>/.ffskill-render-stages.json. A later run with the same --work replaces one of them
1330
+ # without --overwrite while it is still that file (the overwrite rule's "this run's own file",
1331
+ # extended to an earlier render of the same work directory); one edited, replaced or never
1332
+ # written by render is refused as before.
1333
+ _OWN_STAGES: Dict[str, Any] = {"dir": None, "files": {}, "manifest": None}
1334
+
1335
+
1336
+ def own_stage_files(work: Path, load: bool) -> None:
1337
+ """Start this run's record of its stage files in `work`, reading an earlier render's when
1338
+ `load` (an explicit --work; the default directory is new every run)."""
1339
+ _OWN_STAGES.update(dir=os.path.realpath(str(work)), files={}, manifest=str(work / ".ffskill-render-stages.json") if load else None)
1340
+ if load:
1341
+ try:
1342
+ doc = json.loads(Path(_OWN_STAGES["manifest"]).read_text(encoding="utf-8"))
1343
+ except (OSError, ValueError):
1344
+ doc = {}
1345
+ files = doc.get("files") if isinstance(doc, dict) else None
1346
+ if isinstance(files, dict):
1347
+ _OWN_STAGES["files"] = {k: v for k, v in files.items() if isinstance(k, str) and isinstance(v, dict)}
1348
+
1349
+
1350
+ def _earlier_own_stage_file(dest: Optional[str]) -> bool:
1351
+ """Whether `dest`, a stage's output in the work directory, is a file an earlier render with
1352
+ this --work wrote there and nothing has changed since."""
1353
+ if not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
1354
+ return False
1355
+ real = os.path.realpath(dest)
1356
+ if os.path.dirname(real) != _OWN_STAGES["dir"]:
1357
+ return False
1358
+ expected = _OWN_STAGES["files"].get(real)
1359
+ return isinstance(expected, dict) and owned_identity(real) == expected
1360
+
1361
+
1362
+ def _record_own_stage_file(dest: Optional[str]) -> None:
1363
+ """Remember a stage file this run put in the --work directory, as it is now. A manifest that
1364
+ cannot be written only means the next run refuses that file as before."""
1365
+ if STATE.dry_run or not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
1366
+ return
1367
+ real = os.path.realpath(dest)
1368
+ if os.path.dirname(real) != _OWN_STAGES["dir"]:
1369
+ return
1370
+ ident = owned_identity(real)
1371
+ if ident:
1372
+ _OWN_STAGES["files"][real] = ident
1373
+ try:
1374
+ Path(_OWN_STAGES["manifest"]).write_text(json.dumps({"files": _OWN_STAGES["files"]}, indent=1), encoding="utf-8")
1375
+ except OSError:
1376
+ pass
1377
+
1378
+
1237
1379
  def guidance_warns(check_doc: Optional[Dict[str, Any]]) -> List[str]:
1238
1380
  """The rows of a check result that are over the platform table's upload limit (#485): WARN,
1239
1381
  `basis: guidance`. The check passes with them, so each place that says a check passed names
@@ -1323,7 +1465,8 @@ def main() -> int:
1323
1465
  guard_sidecar(args.init)
1324
1466
  Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
1325
1467
  info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
1326
- print(args.init)
1468
+ # the path alone, or under --json the document every other mode prints (A102)
1469
+ emit(args.init, stages=[], check=None)
1327
1470
  return 0
1328
1471
  if not args.project:
1329
1472
  die("give a project.json (or --init FILE, or --template NAME INPUT)")
@@ -1425,25 +1568,48 @@ def main() -> int:
1425
1568
  # fit.mp4, ...) mid-run. An explicit --work is left as given (the caller asked for that exact,
1426
1569
  # shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
1427
1570
  # made unique per process, since it's the one that's also auto-deleted at the end.
1428
- work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
1571
+ # Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
1572
+ # is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
1573
+ # template) carries its stages through the audio container instead of a video one.
1574
+ mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
1575
+ ex_cfg = proj.get("export")
1576
+ if (not args.stop_after or args.stop_after == "export") and not (isinstance(ex_cfg, dict) and ex_cfg.get("preset")):
1577
+ # A84: with no export stage the delivery is the last stage's file copied as it is, so a
1578
+ # .webm/.mkv name was MP4 bytes, completed and verified. Refused before the first stage
1579
+ # when the last file is certainly an intermediate; a single uncut clip that no stage
1580
+ # touches is the source itself, and the copy below checks what it really is.
1581
+ c0 = clips[0] if len(clips) == 1 and isinstance(clips[0], dict) else None
1582
+ passthrough = c0 is not None and c0.get("in") is None and c0.get("out") is None
1583
+ if not passthrough:
1584
+ refuse_copied_container(mid, output)
1585
+ # A77: the default work directory is new for every run (mkdtemp), not named by the PID alone and
1586
+ # reused with exist_ok -- a later run that got the same PID deleted an earlier run's kept preview.
1587
+ # An explicit --work is the caller's: created when missing, never emptied.
1588
+ created_work = bool(args.work) and not Path(args.work).exists()
1429
1589
  try:
1430
- work.mkdir(parents=True, exist_ok=True)
1590
+ if args.work:
1591
+ work = Path(args.work)
1592
+ work.mkdir(parents=True, exist_ok=True)
1593
+ else:
1594
+ import tempfile
1595
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
1596
+ work = Path(tempfile.mkdtemp(prefix=f"{Path(output).with_suffix('').name}_work_{os.getpid()}_",
1597
+ dir=str(Path(output).parent)))
1598
+ created_work = True
1431
1599
  except OSError as e:
1432
- die(f"cannot create the work directory {work}: {e}")
1600
+ die(f"cannot create the work directory {args.work or Path(output).parent}: {e}")
1601
+ own_stage_files(work, load=bool(args.work))
1433
1602
  # --stop-after before the export reports a stage file inside the work directory as its output:
1434
1603
  # removing the directory at exit deleted the file the result named (and its output_identity)
1435
1604
  previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
1436
1605
  if previewing and not args.keep and not args.work:
1437
1606
  info(f"--stop-after {args.stop_after}: keeping the work directory {work}, since the result's output is in it")
1438
- if not args.keep and not args.work and not previewing:
1607
+ if (not args.keep and not args.work and not previewing) or (STATE.dry_run and created_work):
1439
1608
  # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
1440
- # auto-named directory is ours alone, so remove it on every exit path
1609
+ # auto-named directory is ours alone, so remove it on every exit path; a dry run removes
1610
+ # a --work directory it created too, --keep or not (A99: a plan leaves nothing behind)
1441
1611
  import atexit
1442
1612
  atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
1443
- # Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
1444
- # is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
1445
- # template) carries its stages through the audio container instead of a video one.
1446
- mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
1447
1613
  frame = dict(proj.get("frame") or {})
1448
1614
  trans = proj.get("transition") or {}
1449
1615
  frame_from_preset(frame, proj.get("export") or {})
@@ -1454,7 +1620,6 @@ def main() -> int:
1454
1620
  # unchanged -- it never gets a --platform it did not ask for.
1455
1621
  dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
1456
1622
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
1457
- ex_cfg = proj.get("export")
1458
1623
  # a malformed "export" is refused by the export stage with its own message; here it only means
1459
1624
  # "no encoding export", never a traceback before that refusal
1460
1625
  ex_preset = str(ex_cfg.get("preset") or "") if isinstance(ex_cfg, dict) else ""
@@ -1468,6 +1633,7 @@ def main() -> int:
1468
1633
 
1469
1634
  CACHE.clear()
1470
1635
  CACHE.update(_fresh_cache())
1636
+ CONTENT_KEYED.clear()
1471
1637
  if args.cache:
1472
1638
  cdir = Path(args.cache)
1473
1639
  try:
@@ -1826,12 +1992,21 @@ def main() -> int:
1826
1992
  sh("export.py", *argv, stage="export")
1827
1993
  stages_done.append("export")
1828
1994
  else:
1995
+ refuse_copied_container(Path(current).suffix.lower(), output) # what the copy really is (A84)
1829
1996
  if not STATE.dry_run:
1830
1997
  place_output(current, output)
1831
1998
  info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
1832
1999
  current = output
1833
2000
  if not STATE.dry_run:
1834
2001
  mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
2002
+ if args.stop_after == "export":
2003
+ # A87: the chapters and check stages ran after "--stop-after export", and a "check" this
2004
+ # run never reads (validate_project() skips it) was then read with .get(): a traceback
2005
+ if not args.keep and not args.work:
2006
+ shutil.rmtree(work, ignore_errors=True)
2007
+ emit(output, stages=stages_done, check=None, cache=cache_report(args.cache),
2008
+ intermediate_preset=_intermediate_used())
2009
+ return 0
1835
2010
 
1836
2011
  # ---- chapters (metadata.py on the delivered file: streams copied, markers written)
1837
2012
  ch = proj.get("chapters")
@@ -1840,9 +2015,19 @@ def main() -> int:
1840
2015
  # which a dry run has not written either. The plan is the run, so --dry-run shows the
1841
2016
  # command and lists the stage (the child's own --dry-run prints rather than writes).
1842
2017
  if isinstance(ch, list):
1843
- chapter_file = str(work / "chapters.txt")
2018
+ # a dry run writes nothing into the work directory (A99): its metadata.py --dry-run
2019
+ # still reads the list, from a temporary directory removed at exit
2020
+ if STATE.dry_run:
2021
+ import atexit
2022
+ import tempfile
2023
+ tmp_dir = tempfile.mkdtemp(prefix="ffskill_render_chapters_")
2024
+ atexit.register(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
2025
+ chapter_file = os.path.join(tmp_dir, "chapters.txt")
2026
+ else:
2027
+ chapter_file = str(work / "chapters.txt")
1844
2028
  lines = [f"{entry['at']} {entry['title']}" for entry in ch]
1845
2029
  Path(chapter_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
2030
+ CONTENT_KEYED.add(os.path.abspath(chapter_file)) # rewritten every run: keyed by what it says (A100)
1846
2031
  else:
1847
2032
  chapter_file = rel(ch)
1848
2033
  tagged = str(work / ("chapters" + Path(output).suffix))
@@ -1903,23 +2088,13 @@ def main() -> int:
1903
2088
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
1904
2089
  probe=probe(output, role="output"))
1905
2090
  info(f"rendered {output} via {' → '.join(stages_done)}")
1906
- cache_report = None
1907
- if args.cache:
1908
- cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1909
- "hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
1910
- "saved_seconds": round(CACHE["saved_seconds"], 1),
1911
- "entries": CACHE["entries"]}
1912
- if STATE.dry_run:
1913
- cache_report["would_hit"] = CACHE["would_hit"]
1914
- info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
1915
- f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
1916
2091
  # One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
1917
2092
  # caller reads today by also carrying the first entry's keys at the top level.
1918
2093
  snap_report: Optional[Dict[str, Any]] = None
1919
2094
  if snap_reports:
1920
2095
  snap_report = dict(snap_reports[0])
1921
2096
  snap_report["clips"] = snap_reports
1922
- emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
2097
+ emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report(args.cache),
1923
2098
  caption=caption_report, intermediate_preset=_intermediate_used(),
1924
2099
  verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
1925
2100
  return 0
package/scripts/report.py CHANGED
@@ -119,6 +119,32 @@ PACK_CSS = """
119
119
  """
120
120
 
121
121
 
122
+ def md_cells(line: str) -> List[str]:
123
+ """The cells of one GFM table row, as render.py's md_cell() writes them: a '|' preceded by a
124
+ backslash is part of the cell, and a doubled backslash is one. Splitting on every '|' moved a
125
+ file name such as `a | b.mp4` into the next column (A263)."""
126
+ cells: List[str] = []
127
+ cur: List[str] = []
128
+ body = line.strip()
129
+ body = body[1:] if body.startswith("|") else body
130
+ i = 0
131
+ while i < len(body):
132
+ ch = body[i]
133
+ if ch == "\\" and i + 1 < len(body) and body[i + 1] in "\\|":
134
+ cur.append(body[i + 1])
135
+ i += 2
136
+ continue
137
+ if ch == "|":
138
+ cells.append("".join(cur).strip())
139
+ cur = []
140
+ else:
141
+ cur.append(ch)
142
+ i += 1
143
+ if "".join(cur).strip():
144
+ cells.append("".join(cur).strip())
145
+ return cells
146
+
147
+
122
148
  def pack_report(args) -> int:
123
149
  """render.py --template all writes <stem>_pack.md: one row per destination. This renders the
124
150
  same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
@@ -130,7 +156,7 @@ def pack_report(args) -> int:
130
156
  line = line.strip()
131
157
  if not line.startswith("|"):
132
158
  continue
133
- cells = [c.strip() for c in line.strip("|").split("|")]
159
+ cells = md_cells(line)
134
160
  if all(set(c) <= set("-: ") for c in cells):
135
161
  continue
136
162
  if not header:
@@ -156,7 +156,11 @@ def main() -> int:
156
156
  else:
157
157
  out_w, out_h = even(sw), even(sh)
158
158
 
159
- output = args.output or default_output(str(directory).rstrip("/\\") or "sequence", "sequence", "mp4")
159
+ # beside the folder, named after it: `--dir .` (run inside the frames folder) or `--dir ..` has
160
+ # no name of its own, so the folder's resolved name is used (A108: "." was a ValueError
161
+ # traceback); the filesystem root, which has none either, still names it sequence_sequence.mp4
162
+ named = directory if directory.name not in ("", ".", "..") else directory.resolve()
163
+ output = args.output or default_output(str(named).rstrip("/\\") or "sequence", "sequence", "mp4")
160
164
  with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
161
165
  list_path = Path(tmp) / "frames.txt"
162
166
  list_path.write_text(concat_list([f.resolve() for f in frames], rate), encoding="utf-8")
@@ -6,6 +6,7 @@
6
6
  "src": "$INPUT"
7
7
  }
8
8
  ],
9
+ "brand": "$BRAND",
9
10
  "audiogram": {
10
11
  "image": "$IMAGE",
11
12
  "style": "waveform",
@@ -17,6 +18,14 @@
17
18
  "position": "bottom",
18
19
  "margin": 14
19
20
  },
21
+ "graphics": [
22
+ {
23
+ "template": "lower-third",
24
+ "name": "$TITLE",
25
+ "start": 1,
26
+ "end": 6
27
+ }
28
+ ],
20
29
  "loudness": {
21
30
  "lufs": -14,
22
31
  "tp": -1