ffmpeg-skill 1.7.0 → 1.7.2
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 -0
- package/SKILL.md +3 -3
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +10 -3
- package/scripts/_contract.py +2 -1
- package/scripts/check.py +17 -4
- package/scripts/export.py +33 -3
- package/scripts/render.py +25 -2
package/README.md
CHANGED
|
@@ -355,6 +355,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
|
|
|
355
355
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
356
356
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
|
357
357
|
| **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
|
|
358
|
+
| **108 / 108** | 1.7.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 24 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 25/25, report format 108/108 by regex (104/108 by the stricter grader), user's language 101/108 (six English refusals answered in Spanish or Portuguese, one Japanese request in English); trigger set 22/22. Iteration-6 fixes held (fade-in only, Japanese audio trims, music no longer shortens the video). Details in `evals/results/iteration-7.json` |
|
|
358
359
|
| **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
|
|
359
360
|
| **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
|
|
360
361
|
| **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
|
package/SKILL.md
CHANGED
|
@@ -160,7 +160,7 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
|
|
|
160
160
|
| "remove the green screen", "chroma key this" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
|
|
161
161
|
| "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
|
|
162
162
|
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
163
|
-
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
|
|
163
|
+
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` (`--normalize` meets the platform's loudness spec in the same call, no separate `loudness.py` pass) |
|
|
164
164
|
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
165
165
|
| "make a small/low-res proxy for an analysis pass", "a cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset, see `export.py` for those |
|
|
166
166
|
| "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
|
|
@@ -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
|
|
242
|
+
Reply in the language the request itself is written in: the language of the user's own sentences, not a language the request talks about (an English request for Spanish subtitles gets an English report) and not the language of a tool's error text or of the file names. 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, and never drift into another language because the job is short or the report is a failure: a one-line "file does not exist" is written in the request's language too. 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
|
|
|
@@ -263,7 +263,7 @@ Look: not needed (nothing written)
|
|
|
263
263
|
Notes: send a valid .cube, or say if you want the clip left as is
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
|
|
266
|
+
A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. That includes the shortest failure: a missing input or an invalid LUT still gets `Failed:`, `Steps:`, `Check:`, `Look:` and `Notes:` lines, never prose headings in their place. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
|
|
267
267
|
|
|
268
268
|
Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification | interrupted, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
|
|
269
269
|
|
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.7.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.7.2`) | 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.7.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.7.2", "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"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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
|
@@ -385,6 +385,8 @@ plan), runs the tool with the planned argv, then the verify steps (probe; `check
|
|
|
385
385
|
for a `--platform` or a platform export preset), and reports `plan`, `tool`,
|
|
386
386
|
`tool_result` and `check`. Show the plan to the user, get the yes, execute:
|
|
387
387
|
one round trip instead of re-deriving the command.
|
|
388
|
+
`"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
|
|
389
|
+
so the rendered file meets the platform's loudness without a separate pass.
|
|
388
390
|
|
|
389
391
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
390
392
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
|
@@ -413,7 +415,10 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
|
|
|
413
415
|
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
|
|
414
416
|
```
|
|
415
417
|
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
416
|
-
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
418
|
+
step before reporting a deliverable; fix FAILs, mention WARNs. Without
|
|
419
|
+
`--platform` the youtube spec is assumed and the judgement rows (duration,
|
|
420
|
+
aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
|
|
421
|
+
line, not FAIL: name the platform when the file is a delivery for it.
|
|
417
422
|
|
|
418
423
|
### batch.py — same recipe over a folder, cached
|
|
419
424
|
```
|
|
@@ -640,7 +645,7 @@ integrated loudness ended more than 1 LU from the target because of it.
|
|
|
640
645
|
|
|
641
646
|
### export.py — delivery presets
|
|
642
647
|
```
|
|
643
|
-
export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
|
|
648
|
+
export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
|
|
644
649
|
export.py --list
|
|
645
650
|
```
|
|
646
651
|
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
@@ -648,7 +653,9 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
|
|
|
648
653
|
does not touch levels: for youtube / youtube4k / reels / x the written file is
|
|
649
654
|
measured and the result's `loudness` (and a `notes` line) says when it is
|
|
650
655
|
outside the platform's LUFS / true-peak spec, naming the `loudness.py` call
|
|
651
|
-
that fixes it --
|
|
656
|
+
that fixes it -- or pass `--normalize`, which runs that call on the written
|
|
657
|
+
file itself (audio re-encoded, video copied; `loudness.normalized: true`) so a
|
|
658
|
+
platform export is one command instead of export, loudness, export again.
|
|
652
659
|
|
|
653
660
|
### proxy.py — low-bitrate proxy for analysis/preview
|
|
654
661
|
```
|
package/scripts/_contract.py
CHANGED
|
@@ -379,6 +379,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
379
379
|
extra: Dict[str, Any] = {}
|
|
380
380
|
if name == "check":
|
|
381
381
|
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
|
|
382
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
|
|
382
383
|
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
383
384
|
elif name == "scenes":
|
|
384
385
|
extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
|
|
@@ -400,7 +401,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
400
401
|
elif name == "report":
|
|
401
402
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
402
403
|
elif name == "export":
|
|
403
|
-
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"},
|
|
404
|
+
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; normalized true when --normalize ran loudness.py on the file"},
|
|
404
405
|
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
405
406
|
elif name == "loudness":
|
|
406
407
|
extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
|
package/scripts/check.py
CHANGED
|
@@ -66,7 +66,7 @@ def aspect_name(w: int, h: int) -> str:
|
|
|
66
66
|
def main() -> int:
|
|
67
67
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
68
68
|
ap.add_argument("input")
|
|
69
|
-
ap.add_argument("--platform", choices=sorted(SPECS), default="youtube")
|
|
69
|
+
ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
70
70
|
ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
|
|
71
71
|
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
72
72
|
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
@@ -77,6 +77,11 @@ def main() -> int:
|
|
|
77
77
|
args = ap.parse_args()
|
|
78
78
|
apply_common(args)
|
|
79
79
|
|
|
80
|
+
# Eval 7: runs that only wanted the format rows got youtube's loudness / true-peak FAILs and
|
|
81
|
+
# spent a paragraph explaining why they left them alone. Without a named platform the
|
|
82
|
+
# judgement rows are advisory: WARN, not FAIL, and not counted as failed.
|
|
83
|
+
named = args.platform is not None
|
|
84
|
+
args.platform = args.platform or "youtube"
|
|
80
85
|
spec = dict(SPECS[args.platform])
|
|
81
86
|
if args.max_duration is not None:
|
|
82
87
|
spec["max_duration"] = args.max_duration
|
|
@@ -100,6 +105,8 @@ def main() -> int:
|
|
|
100
105
|
# (what is cut, what is cropped, how loud ambience gets) and need a decision.
|
|
101
106
|
# "fix" is the command that resolves it; "reason" (only on the FAILs a non-technical
|
|
102
107
|
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
108
|
+
if status == "FAIL" and not named and (name in JUDGEMENT or name == "true peak"):
|
|
109
|
+
status = "WARN"
|
|
103
110
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
104
111
|
"reason": reason if status != "PASS" else "",
|
|
105
112
|
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
@@ -179,9 +186,12 @@ def main() -> int:
|
|
|
179
186
|
|
|
180
187
|
failed = [r for r in rows if r["status"] == "FAIL"]
|
|
181
188
|
warned = [r for r in rows if r["status"] == "WARN"]
|
|
189
|
+
notes: List[str] = []
|
|
190
|
+
if not named:
|
|
191
|
+
notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
|
|
182
192
|
if not args.json:
|
|
183
193
|
width = max(len(r["check"]) for r in rows)
|
|
184
|
-
print(f"{args.input} — {args.platform}")
|
|
194
|
+
print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
|
|
185
195
|
for r in rows:
|
|
186
196
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
187
197
|
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
@@ -192,11 +202,14 @@ def main() -> int:
|
|
|
192
202
|
line += f" -> {r['fix']}"
|
|
193
203
|
print(line)
|
|
194
204
|
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
|
|
205
|
+
for n in notes:
|
|
206
|
+
print(f" note: {n}")
|
|
207
|
+
extra: Dict[str, Any] = {"notes": notes} if notes else {}
|
|
195
208
|
if failed:
|
|
196
209
|
die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
|
|
197
210
|
kind="verification", output=None, dry_run=STATE.dry_run,
|
|
198
|
-
platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
|
|
199
|
-
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
|
|
211
|
+
platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False, **extra)
|
|
212
|
+
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True, **extra)
|
|
200
213
|
return 0
|
|
201
214
|
|
|
202
215
|
|
package/scripts/export.py
CHANGED
|
@@ -17,16 +17,19 @@ Presets:
|
|
|
17
17
|
Examples:
|
|
18
18
|
python3 export.py final.mp4 --preset youtube
|
|
19
19
|
python3 export.py final.mp4 --preset reels --fit crop
|
|
20
|
+
python3 export.py final.mp4 --preset reels --normalize # meet the platform's loudness in the same call
|
|
20
21
|
python3 export.py final.mp4 --preset prores -o master.mov
|
|
21
22
|
python3 export.py final.mp4 --preset copy -o delivered.mp4
|
|
22
23
|
python3 export.py --list
|
|
23
24
|
"""
|
|
24
25
|
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
25
28
|
import sys
|
|
26
29
|
from pathlib import Path
|
|
27
30
|
from typing import Dict, List
|
|
28
31
|
|
|
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
|
|
32
|
+
from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
30
33
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
31
34
|
PRESETS: Dict[str, Dict] = {
|
|
32
35
|
"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"},
|
|
@@ -42,6 +45,7 @@ PRESETS: Dict[str, Dict] = {
|
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
# which check.py platform a preset targets (its loudness spec is measured after the write)
|
|
48
|
+
HERE = Path(__file__).resolve().parent
|
|
45
49
|
PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
|
|
46
50
|
|
|
47
51
|
|
|
@@ -56,6 +60,7 @@ def main() -> int:
|
|
|
56
60
|
ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
|
|
57
61
|
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
|
|
58
62
|
ap.add_argument("--crf", type=int, help="override CRF")
|
|
63
|
+
ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
|
|
59
64
|
ap.add_argument("--list", action="store_true", help="list presets and exit")
|
|
60
65
|
add_common(ap)
|
|
61
66
|
args = ap.parse_args()
|
|
@@ -140,9 +145,34 @@ def main() -> int:
|
|
|
140
145
|
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
|
|
141
146
|
extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
|
|
142
147
|
extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
|
|
143
|
-
if not ok:
|
|
148
|
+
if not ok and args.normalize:
|
|
149
|
+
# Eval 7: every platform job ran export -> loudness.py -> export again (two video
|
|
150
|
+
# encodes). The levels pass only re-encodes audio, so do it here on the written
|
|
151
|
+
# file and the caller gets one export that meets the spec.
|
|
152
|
+
info(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s spec; normalising to {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP")
|
|
153
|
+
tmp = str(Path(output).with_name(Path(output).stem + "_loudnorm" + Path(output).suffix))
|
|
154
|
+
proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json", "--overwrite"] + child_args())
|
|
155
|
+
try:
|
|
156
|
+
child = json.loads(proc.stdout)
|
|
157
|
+
except ValueError:
|
|
158
|
+
child = {}
|
|
159
|
+
if proc.returncode != 0 or child.get("status") != "completed":
|
|
160
|
+
err = child.get("error") or {}
|
|
161
|
+
if os.path.exists(tmp):
|
|
162
|
+
os.remove(tmp)
|
|
163
|
+
die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
|
|
164
|
+
kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
|
|
165
|
+
os.replace(tmp, output)
|
|
166
|
+
m = measure_loudness(output) or m
|
|
167
|
+
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
|
|
168
|
+
extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
|
|
169
|
+
extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
|
|
170
|
+
if not ok:
|
|
171
|
+
notes.append(f"loudness is still {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP after loudness.py (target {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP): {child.get('result', {}).get('note') or 'the encoder overshoots the ceiling'}")
|
|
172
|
+
info("warning: " + notes[-1])
|
|
173
|
+
elif not ok:
|
|
144
174
|
notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
|
|
145
|
-
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file
|
|
175
|
+
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
|
|
146
176
|
info("warning: " + notes[-1])
|
|
147
177
|
if notes:
|
|
148
178
|
extra["notes"] = notes
|
package/scripts/render.py
CHANGED
|
@@ -27,7 +27,7 @@ Project format (all keys optional except clips):
|
|
|
27
27
|
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
|
|
28
28
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
29
|
"fit": {"duration": 60},
|
|
30
|
-
"export": {"preset": "reels"},
|
|
30
|
+
"export": {"preset": "reels", "normalize": true},
|
|
31
31
|
"check": {"platform": "reels"}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -56,6 +56,7 @@ import sys
|
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
from typing import Any, Dict, List
|
|
58
58
|
|
|
59
|
+
from export import PRESETS
|
|
59
60
|
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
|
|
60
61
|
|
|
61
62
|
HERE = Path(__file__).resolve().parent
|
|
@@ -178,6 +179,25 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
178
179
|
return 0
|
|
179
180
|
|
|
180
181
|
|
|
182
|
+
def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
|
|
183
|
+
"""Fill frame.width/height from the export preset when the project gave only an aspect.
|
|
184
|
+
Eval 7 (j08 twice, e01 by hand): "frame": {"aspect": "9:16"} with a reels export fitted a
|
|
185
|
+
1280x720 source to 406x720, captions were burned at that size, and export.py upscaled them
|
|
186
|
+
soft. A preset that names a delivery frame of the same aspect is that frame."""
|
|
187
|
+
if not frame.get("aspect") or frame.get("width") or frame.get("height"):
|
|
188
|
+
return
|
|
189
|
+
preset = PRESETS.get(str(export.get("preset") or ""), {})
|
|
190
|
+
if not (preset.get("w") and preset.get("h")):
|
|
191
|
+
return
|
|
192
|
+
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*", str(frame["aspect"]))
|
|
193
|
+
if not m or float(m.group(2)) == 0:
|
|
194
|
+
return
|
|
195
|
+
if abs(float(m.group(1)) / float(m.group(2)) - preset["w"] / preset["h"]) > 0.01:
|
|
196
|
+
return
|
|
197
|
+
frame["width"], frame["height"] = preset["w"], preset["h"]
|
|
198
|
+
info(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)")
|
|
199
|
+
|
|
200
|
+
|
|
181
201
|
def main() -> int:
|
|
182
202
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
183
203
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -236,8 +256,9 @@ def main() -> int:
|
|
|
236
256
|
import atexit
|
|
237
257
|
import shutil
|
|
238
258
|
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
239
|
-
frame = proj.get("frame") or {}
|
|
259
|
+
frame = dict(proj.get("frame") or {})
|
|
240
260
|
trans = proj.get("transition") or {}
|
|
261
|
+
frame_from_preset(frame, proj.get("export") or {})
|
|
241
262
|
brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
|
|
242
263
|
stages_done: List[str] = []
|
|
243
264
|
|
|
@@ -443,6 +464,8 @@ def main() -> int:
|
|
|
443
464
|
argv += ["--fit", ex["fit"]]
|
|
444
465
|
if ex.get("crf") is not None:
|
|
445
466
|
argv += ["--crf", str(ex["crf"])]
|
|
467
|
+
if ex.get("normalize"):
|
|
468
|
+
argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
|
|
446
469
|
sh("export.py", *argv)
|
|
447
470
|
stages_done.append("export")
|
|
448
471
|
else:
|