ffmpeg-skill 1.5.1 → 1.6.0
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/README.md +1 -1
- package/SKILL.md +2 -2
- package/docs/contract.md +10 -2
- package/package.json +1 -1
- package/references/scripts.md +22 -3
- package/scripts/_common.py +97 -2
- package/scripts/_contract.py +12 -4
- package/scripts/cut.py +5 -1
- package/scripts/export.py +23 -1
- package/scripts/overlay.py +7 -7
- package/scripts/render.py +71 -2
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
143
143
|
|
|
144
144
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
145
145
|
2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
146
|
-
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
146
|
+
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
147
147
|
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
148
148
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
149
149
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
|
@@ -239,7 +239,7 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
|
|
|
239
239
|
|
|
240
240
|
## Report format
|
|
241
241
|
|
|
242
|
-
Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one.
|
|
242
|
+
Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
|
|
243
243
|
|
|
244
244
|
Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
|
|
245
245
|
|
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 (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.6.0`) | 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`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.6.0", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -162,6 +162,14 @@ tool's job; only the artifact is skipped, including side files such as `--edl`,
|
|
|
162
162
|
generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
|
|
163
163
|
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
164
164
|
|
|
165
|
+
`--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
|
|
166
|
+
"tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
|
|
167
|
+
"output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
|
|
168
|
+
implies `--dry-run`, so the same execution rules apply. `render.py FILE` executes a plan:
|
|
169
|
+
it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
|
|
170
|
+
runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
|
|
171
|
+
`tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
|
|
172
|
+
|
|
165
173
|
### Repeatability
|
|
166
174
|
|
|
167
175
|
No tool keeps state or uses randomness. `deterministic_inputs` is `false` only for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
|
@@ -55,7 +55,9 @@ keyframes, instant, lossless); if the snapped result deviates more than
|
|
|
55
55
|
`--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
|
|
56
56
|
(x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
|
|
57
57
|
Multiple segments are concatenated in the order given. stderr reports whether
|
|
58
|
-
the result was "lossless stream copy" or "re-encoded"
|
|
58
|
+
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
59
|
+
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
60
|
+
`--start` that would stream-copy instead, so the trade can be offered.
|
|
59
61
|
|
|
60
62
|
### fit.py — target duration and/or aspect, rotate/flip
|
|
61
63
|
```
|
|
@@ -373,7 +375,17 @@ output; the result says so with `dropped_non_av_streams: true`.
|
|
|
373
375
|
```
|
|
374
376
|
render.py --init project.json # starter file
|
|
375
377
|
render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
|
|
378
|
+
render.py plan.json # execute a plan written by <tool> --plan plan.json
|
|
376
379
|
```
|
|
380
|
+
A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
|
|
381
|
+
--plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
|
|
382
|
+
head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
|
|
383
|
+
re-fingerprints the inputs (refusing, `kind: input`, if any changed since the
|
|
384
|
+
plan), runs the tool with the planned argv, then the verify steps (probe; `check`
|
|
385
|
+
for a `--platform` or a platform export preset), and reports `plan`, `tool`,
|
|
386
|
+
`tool_result` and `check`. Show the plan to the user, get the yes, execute:
|
|
387
|
+
one round trip instead of re-deriving the command.
|
|
388
|
+
|
|
377
389
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
378
390
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
|
379
391
|
CLI flags of each script (see the docstring). Use it whenever an edit has
|
|
@@ -534,6 +546,9 @@ playing. `--video` composites a second video as a picture-in-picture layer
|
|
|
534
546
|
input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
|
|
535
547
|
(with `--video`) keys out that colour first for green-screen compositing.
|
|
536
548
|
|
|
549
|
+
`--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
|
|
550
|
+
only at `--end`, so a logo with no `--end` stays to the last frame.
|
|
551
|
+
|
|
537
552
|
### sync.py — offset detection, alignment, drift correction
|
|
538
553
|
```
|
|
539
554
|
sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
|
|
@@ -629,7 +644,11 @@ export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|cr
|
|
|
629
644
|
export.py --list
|
|
630
645
|
```
|
|
631
646
|
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
632
|
-
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
647
|
+
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
|
|
648
|
+
does not touch levels: for youtube / youtube4k / reels / x the written file is
|
|
649
|
+
measured and the result's `loudness` (and a `notes` line) says when it is
|
|
650
|
+
outside the platform's LUFS / true-peak spec, naming the `loudness.py` call
|
|
651
|
+
that fixes it -- plan that pass instead of discovering it from `check.py`.
|
|
633
652
|
|
|
634
653
|
### proxy.py — low-bitrate proxy for analysis/preview
|
|
635
654
|
```
|
package/scripts/_common.py
CHANGED
|
@@ -227,7 +227,7 @@ class Context:
|
|
|
227
227
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
228
228
|
"""
|
|
229
229
|
|
|
230
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
230
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan")
|
|
231
231
|
|
|
232
232
|
def __init__(self) -> None:
|
|
233
233
|
self.reset()
|
|
@@ -243,6 +243,7 @@ class Context:
|
|
|
243
243
|
self.overwrite = False # --overwrite: an existing output may be replaced
|
|
244
244
|
self.written: set = set() # output paths this process has written itself
|
|
245
245
|
self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
|
|
246
|
+
self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
|
|
246
247
|
|
|
247
248
|
|
|
248
249
|
|
|
@@ -261,10 +262,13 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
261
262
|
help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
|
|
262
263
|
g.add_argument("--overwrite", action="store_true",
|
|
263
264
|
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
265
|
+
g.add_argument("--plan", metavar="FILE",
|
|
266
|
+
help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
|
|
264
267
|
|
|
265
268
|
|
|
266
269
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
267
|
-
STATE.
|
|
270
|
+
STATE.plan = getattr(args, "plan", None) or None
|
|
271
|
+
STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
|
|
268
272
|
STATE.json = bool(getattr(args, "json", False))
|
|
269
273
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
270
274
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
@@ -350,11 +354,102 @@ def emit(output: Optional[str], **extra: Any) -> None:
|
|
|
350
354
|
doc.update(extra)
|
|
351
355
|
if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
|
|
352
356
|
doc["result_v2"] = _result_v2(output, meta, extra)
|
|
357
|
+
if STATE.plan:
|
|
358
|
+
doc["plan"] = write_plan(STATE.plan, output, extra)
|
|
353
359
|
print_json(doc)
|
|
360
|
+
elif STATE.plan:
|
|
361
|
+
print(write_plan(STATE.plan, output, extra))
|
|
354
362
|
elif output:
|
|
355
363
|
print(output)
|
|
356
364
|
|
|
357
365
|
|
|
366
|
+
PLAN_VERSION = 1
|
|
367
|
+
_PLAN_STRIP = ("--plan", "--dry-run", "--json")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def fingerprint(path: str) -> Dict[str, Any]:
|
|
371
|
+
"""Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
|
|
372
|
+
or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
|
|
373
|
+
planning slower than the edit)."""
|
|
374
|
+
import hashlib
|
|
375
|
+
st = os.stat(path)
|
|
376
|
+
h = hashlib.sha256()
|
|
377
|
+
chunk = 8 * 1024 * 1024
|
|
378
|
+
with open(path, "rb") as f:
|
|
379
|
+
h.update(f.read(chunk))
|
|
380
|
+
if st.st_size > 2 * chunk:
|
|
381
|
+
f.seek(-chunk, os.SEEK_END)
|
|
382
|
+
h.update(f.read(chunk))
|
|
383
|
+
elif st.st_size > chunk:
|
|
384
|
+
h.update(f.read())
|
|
385
|
+
return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _plan_inputs(commands: Sequence[str]) -> List[str]:
|
|
389
|
+
"""Every existing file named by `-i` in the planned commands (shell-quoted lines)."""
|
|
390
|
+
import shlex
|
|
391
|
+
seen: List[str] = []
|
|
392
|
+
for line in commands:
|
|
393
|
+
try:
|
|
394
|
+
toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
|
|
395
|
+
except ValueError:
|
|
396
|
+
continue
|
|
397
|
+
for i, tok in enumerate(toks[:-1]):
|
|
398
|
+
if tok == "-i" and os.path.isfile(toks[i + 1]) and toks[i + 1] not in seen:
|
|
399
|
+
seen.append(toks[i + 1])
|
|
400
|
+
return seen
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
404
|
+
"""The dry run as an artifact: what will run, on which exact inputs, producing what, checked
|
|
405
|
+
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C)."""
|
|
406
|
+
import datetime
|
|
407
|
+
argv = [a for a in sys.argv[1:]]
|
|
408
|
+
cleaned: List[str] = []
|
|
409
|
+
skip = False
|
|
410
|
+
for a in argv:
|
|
411
|
+
if skip:
|
|
412
|
+
skip = False
|
|
413
|
+
continue
|
|
414
|
+
if a in _PLAN_STRIP:
|
|
415
|
+
skip = a == "--plan"
|
|
416
|
+
continue
|
|
417
|
+
if a.startswith("--plan="):
|
|
418
|
+
continue
|
|
419
|
+
cleaned.append(a)
|
|
420
|
+
tool = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
421
|
+
verify: List[Dict[str, Any]] = [{"tool": "probe"}] if output else []
|
|
422
|
+
platform = None
|
|
423
|
+
if "--platform" in cleaned:
|
|
424
|
+
platform = cleaned[cleaned.index("--platform") + 1]
|
|
425
|
+
elif tool == "export" and "--preset" in cleaned:
|
|
426
|
+
platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
|
|
427
|
+
if platform and output and tool != "check":
|
|
428
|
+
verify.append({"tool": "check", "platform": platform})
|
|
429
|
+
doc = {
|
|
430
|
+
"plan_version": PLAN_VERSION,
|
|
431
|
+
"created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
432
|
+
"tool": tool,
|
|
433
|
+
"argv": cleaned,
|
|
434
|
+
"cwd": os.getcwd(),
|
|
435
|
+
"inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands)],
|
|
436
|
+
"commands": list(STATE.commands),
|
|
437
|
+
"output": os.path.abspath(output) if output else None,
|
|
438
|
+
"verify": verify,
|
|
439
|
+
"notes": list(extra.get("notes") or []),
|
|
440
|
+
}
|
|
441
|
+
try:
|
|
442
|
+
tmp = f"{path}.tmp{os.getpid()}"
|
|
443
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
444
|
+
json.dump(doc, f, indent=2, ensure_ascii=False)
|
|
445
|
+
f.write("\n")
|
|
446
|
+
os.replace(tmp, path)
|
|
447
|
+
except OSError as exc:
|
|
448
|
+
die(f"cannot write plan {path}: {exc}", kind="output")
|
|
449
|
+
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
|
|
450
|
+
return path
|
|
451
|
+
|
|
452
|
+
|
|
358
453
|
_V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
|
|
359
454
|
|
|
360
455
|
|
package/scripts/_contract.py
CHANGED
|
@@ -181,7 +181,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
181
181
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
182
182
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
183
183
|
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
184
|
-
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"}
|
|
184
|
+
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"},
|
|
185
|
+
{"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
|
|
185
186
|
video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
186
187
|
"check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
|
|
187
188
|
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
|
|
@@ -332,7 +333,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
332
333
|
props: Dict[str, Any] = {}
|
|
333
334
|
required: List[str] = []
|
|
334
335
|
positional: List[str] = []
|
|
335
|
-
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite"}
|
|
336
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan"}
|
|
336
337
|
for action in parser._actions:
|
|
337
338
|
if isinstance(action, argparse._HelpAction):
|
|
338
339
|
continue
|
|
@@ -385,13 +386,19 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
385
386
|
elif name == "look":
|
|
386
387
|
extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
|
|
387
388
|
elif name == "render":
|
|
388
|
-
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]}
|
|
389
|
+
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
|
|
390
|
+
"plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
|
|
391
|
+
"tool": {"type": "string", "description": "plan execution: the tool the plan ran"},
|
|
392
|
+
"tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"}}
|
|
389
393
|
elif name == "verify":
|
|
390
394
|
extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
391
395
|
elif name == "batch":
|
|
392
396
|
extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
393
397
|
elif name == "report":
|
|
394
398
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
399
|
+
elif name == "export":
|
|
400
|
+
extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec"},
|
|
401
|
+
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
395
402
|
elif name == "loudness":
|
|
396
403
|
extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
|
|
397
404
|
elif name == "cut":
|
|
@@ -399,7 +406,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
399
406
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
400
407
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
401
408
|
"description": "packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
|
|
402
|
-
"reencoded": {"type": "boolean"}
|
|
409
|
+
"reencoded": {"type": "boolean"},
|
|
410
|
+
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
|
|
403
411
|
elif name == "join":
|
|
404
412
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
|
|
405
413
|
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
package/scripts/cut.py
CHANGED
|
@@ -245,7 +245,11 @@ def main() -> int:
|
|
|
245
245
|
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
246
246
|
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
247
247
|
mode=mode, keyframe_snapped=keyframe_snapped,
|
|
248
|
-
nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None
|
|
248
|
+
nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
|
|
249
|
+
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|
|
250
|
+
lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
|
|
251
|
+
f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
|
|
252
|
+
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None)
|
|
249
253
|
return 0
|
|
250
254
|
|
|
251
255
|
|
package/scripts/export.py
CHANGED
|
@@ -27,6 +27,7 @@ from pathlib import Path
|
|
|
27
27
|
from typing import Dict, List
|
|
28
28
|
|
|
29
29
|
from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
30
|
+
from check import SPECS as PLATFORMS, measure_loudness
|
|
30
31
|
PRESETS: Dict[str, Dict] = {
|
|
31
32
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
32
33
|
"youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
|
|
@@ -40,6 +41,10 @@ PRESETS: Dict[str, Dict] = {
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
# which check.py platform a preset targets (its loudness spec is measured after the write)
|
|
45
|
+
PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
|
|
46
|
+
|
|
47
|
+
|
|
43
48
|
def main() -> int:
|
|
44
49
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
45
50
|
ap.add_argument("input", nargs="?")
|
|
@@ -123,7 +128,24 @@ def main() -> int:
|
|
|
123
128
|
result = probe(output, role="output")
|
|
124
129
|
v = result["video"]
|
|
125
130
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
126
|
-
|
|
131
|
+
extra: Dict[str, object] = {}
|
|
132
|
+
platform = PLATFORM_OF.get(args.preset)
|
|
133
|
+
if platform and has_audio and not STATE.dry_run:
|
|
134
|
+
# Every eval run that exported for a platform then had to come back with loudness.py: the
|
|
135
|
+
# preset scales and tags but does not touch levels, and only check.py said so. Measure the
|
|
136
|
+
# written file here so the result names the gap and the caller plans one pass, not two.
|
|
137
|
+
spec = PLATFORMS[platform]
|
|
138
|
+
m = measure_loudness(output)
|
|
139
|
+
if m:
|
|
140
|
+
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
|
|
141
|
+
extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
|
|
142
|
+
if not ok:
|
|
143
|
+
notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
|
|
144
|
+
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file (or before export)")
|
|
145
|
+
info("warning: " + notes[-1])
|
|
146
|
+
if notes:
|
|
147
|
+
extra["notes"] = notes
|
|
148
|
+
emit(output, **extra)
|
|
127
149
|
return 0
|
|
128
150
|
|
|
129
151
|
|
package/scripts/overlay.py
CHANGED
|
@@ -103,7 +103,7 @@ def main() -> int:
|
|
|
103
103
|
ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
|
|
104
104
|
ap.add_argument("--start", help="show from this time (default: whole video)")
|
|
105
105
|
ap.add_argument("--end", help="hide after this time")
|
|
106
|
-
ap.add_argument("--fade", type=float, default=0.0, help="fade
|
|
106
|
+
ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
|
|
107
107
|
ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
|
|
108
108
|
img = ap.add_argument_group("image options")
|
|
109
109
|
img.add_argument("--scale", type=int, help="scale the image to this width in px (keeps aspect)")
|
|
@@ -188,12 +188,13 @@ def main() -> int:
|
|
|
188
188
|
if args.opacity < 1:
|
|
189
189
|
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
190
190
|
if args.fade > 0:
|
|
191
|
-
# no --start
|
|
191
|
+
# no --start: fade in at 0. The fade-out only exists when --end names a moment the
|
|
192
|
+
# overlay leaves; without --end it stays to the last frame ("fade in at the start" is
|
|
193
|
+
# the common ask, and a matching fade-out at the very end surprised every eval run)
|
|
192
194
|
s = start if start is not None else 0.0
|
|
193
|
-
e = end if end is not None else (meta.get("duration") or 0.0)
|
|
194
195
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
195
|
-
if
|
|
196
|
-
chain.append(f"fade=t=out:st={
|
|
196
|
+
if end is not None and end > args.fade:
|
|
197
|
+
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
197
198
|
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
198
199
|
ov = f"overlay={x}:{y}:format=auto"
|
|
199
200
|
if enable:
|
|
@@ -249,8 +250,7 @@ def main() -> int:
|
|
|
249
250
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
250
251
|
else:
|
|
251
252
|
opts.append(f"font='{escape_drawtext(args.font)}'")
|
|
252
|
-
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
|
|
253
|
-
end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
|
|
253
|
+
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
|
|
254
254
|
opts.append(f"fontcolor={args.font_color}")
|
|
255
255
|
if alpha != "1":
|
|
256
256
|
opts.append(f"alpha='{alpha}'")
|
package/scripts/render.py
CHANGED
|
@@ -49,13 +49,14 @@ Examples:
|
|
|
49
49
|
python3 render.py project.json --fast # preview quality
|
|
50
50
|
"""
|
|
51
51
|
import argparse
|
|
52
|
+
import re
|
|
52
53
|
import json
|
|
53
54
|
import os
|
|
54
55
|
import sys
|
|
55
56
|
from pathlib import Path
|
|
56
57
|
from typing import Any, Dict, List
|
|
57
58
|
|
|
58
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input
|
|
59
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
|
|
59
60
|
|
|
60
61
|
HERE = Path(__file__).resolve().parent
|
|
61
62
|
|
|
@@ -101,9 +102,75 @@ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
|
101
102
|
return str(doc.get("output") or "")
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
106
|
+
"""Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
|
|
107
|
+
the plan was made (the plan's commands would then describe a different edit), run the tool
|
|
108
|
+
with the planned argv, then the plan's verify steps. --dry-run prints the planned commands."""
|
|
109
|
+
if plan.get("plan_version") != PLAN_VERSION:
|
|
110
|
+
die(f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}")
|
|
111
|
+
tool = str(plan.get("tool") or "")
|
|
112
|
+
script = HERE / f"{tool}.py"
|
|
113
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", tool) or not script.exists() or tool == "render":
|
|
114
|
+
die(f"{path}: unknown tool {tool!r}")
|
|
115
|
+
changed = []
|
|
116
|
+
for inp in plan.get("inputs") or []:
|
|
117
|
+
p = inp.get("path")
|
|
118
|
+
if not p or not os.path.isfile(p):
|
|
119
|
+
changed.append(f"{p}: missing")
|
|
120
|
+
continue
|
|
121
|
+
now = fingerprint(p)
|
|
122
|
+
if now["size"] != inp.get("size") or now["sha256_head_tail"] != inp.get("sha256_head_tail"):
|
|
123
|
+
changed.append(f"{p}: content changed since the plan was made")
|
|
124
|
+
if changed:
|
|
125
|
+
die("plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(changed),
|
|
126
|
+
hint="plans are bound to the exact input files they were made from")
|
|
127
|
+
if plan.get("cwd") and os.path.isdir(plan["cwd"]):
|
|
128
|
+
os.chdir(plan["cwd"])
|
|
129
|
+
argv = [str(a) for a in plan.get("argv") or []]
|
|
130
|
+
if STATE.dry_run:
|
|
131
|
+
for c in plan.get("commands") or []:
|
|
132
|
+
STATE.commands.append(c)
|
|
133
|
+
info("[dry-run] " + c)
|
|
134
|
+
emit(plan.get("output"), plan=path, tool=tool, stages=[tool], check=None)
|
|
135
|
+
return 0
|
|
136
|
+
info(f"executing plan {path}: {tool} " + " ".join(argv))
|
|
137
|
+
proc = run_tool([str(script)] + argv + child_args() + ["--json"])
|
|
138
|
+
for line in proc.stderr.splitlines():
|
|
139
|
+
if line.startswith("$ "):
|
|
140
|
+
STATE.commands.append(line[2:])
|
|
141
|
+
elif line.strip():
|
|
142
|
+
info(" " + line)
|
|
143
|
+
try:
|
|
144
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
145
|
+
except ValueError:
|
|
146
|
+
doc = {}
|
|
147
|
+
if proc.returncode != 0 or doc.get("status") != "completed":
|
|
148
|
+
err = doc.get("error") or {}
|
|
149
|
+
die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
|
|
150
|
+
kind=err.get("kind", "ffmpeg"), plan=path, tool=tool)
|
|
151
|
+
output = doc.get("output") or plan.get("output")
|
|
152
|
+
check_result = None
|
|
153
|
+
exit_code = 0
|
|
154
|
+
for step in plan.get("verify") or []:
|
|
155
|
+
if step.get("tool") == "check" and step.get("platform") and output:
|
|
156
|
+
cp = run_tool([str(HERE / "check.py"), output, "--platform", step["platform"], "--json"] + child_args())
|
|
157
|
+
try:
|
|
158
|
+
check_result = json.loads(cp.stdout)
|
|
159
|
+
except ValueError:
|
|
160
|
+
check_result = {"error": cp.stderr.strip()[-300:]}
|
|
161
|
+
if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
|
|
162
|
+
exit_code = 1
|
|
163
|
+
if exit_code:
|
|
164
|
+
die(f"plan executed but {output} does not meet the {[s.get('platform') for s in plan.get('verify') or [] if s.get('tool') == 'check'][0]} spec",
|
|
165
|
+
kind="verification", output=output, plan=path, tool=tool, stages=[tool, "check"], check=check_result)
|
|
166
|
+
info(f"plan done: {output}")
|
|
167
|
+
emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
104
171
|
def main() -> int:
|
|
105
172
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
106
|
-
ap.add_argument("project", nargs="?", help="project.json")
|
|
173
|
+
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
107
174
|
ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
|
|
108
175
|
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
109
176
|
ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
|
|
@@ -123,6 +190,8 @@ def main() -> int:
|
|
|
123
190
|
proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
|
|
124
191
|
except (OSError, ValueError) as exc:
|
|
125
192
|
die(f"cannot read project: {exc}")
|
|
193
|
+
if isinstance(proj, dict) and "plan_version" in proj:
|
|
194
|
+
return execute_plan(proj, args.project)
|
|
126
195
|
base = Path(args.project).resolve().parent
|
|
127
196
|
|
|
128
197
|
def rel(p: Any) -> str:
|