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 +4 -3
- package/package.json +1 -1
- package/references/scripts.md +58 -11
- package/scripts/_common/runner.py +13 -0
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +5 -2
- package/scripts/batch.py +91 -8
- package/scripts/metadata.py +4 -0
- package/scripts/render.py +211 -36
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/templates/audiogram.json +9 -0
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.
|
|
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.
|
|
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
|
-
| `
|
|
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.
|
|
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": [
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
981
|
-
|
|
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
|
|
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
|
|
1041
|
-
|
|
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
|
|
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,
|
|
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
|
|
1335
|
-
|
|
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
|
-
|
|
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(
|
|
502
|
+
q = lambda s: _xml_escape(_xml_text(s), {'"': """}) # 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
|
package/scripts/_contract.py
CHANGED
|
@@ -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}|{
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/metadata.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
159
|
+
cells = md_cells(line)
|
|
134
160
|
if all(set(c) <= set("-: ") for c in cells):
|
|
135
161
|
continue
|
|
136
162
|
if not header:
|
package/scripts/sequence.py
CHANGED
|
@@ -156,7 +156,11 @@ def main() -> int:
|
|
|
156
156
|
else:
|
|
157
157
|
out_w, out_h = even(sw), even(sh)
|
|
158
158
|
|
|
159
|
-
|
|
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")
|
package/templates/audiogram.json
CHANGED
|
@@ -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
|