ffmpeg-skill 1.4.9 → 1.4.11
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 +1 -1
- package/docs/contract.md +3 -3
- package/package.json +1 -1
- package/references/scripts.md +7 -0
- package/scripts/_common.py +102 -4
- package/scripts/_contract.py +1 -1
- package/scripts/batch.py +1 -1
- package/scripts/loudness.py +65 -10
package/README.md
CHANGED
|
@@ -293,7 +293,7 @@ The contract is generated from the code that runs, not maintained beside it. For
|
|
|
293
293
|
| `mutates_input` | always `false` |
|
|
294
294
|
| `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
|
|
295
295
|
|
|
296
|
-
`contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
|
|
296
|
+
`contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
|
|
297
297
|
|
|
298
298
|
### MCP
|
|
299
299
|
|
package/SKILL.md
CHANGED
|
@@ -265,7 +265,7 @@ Notes: send a valid .cube, or say if you want the clip left as is
|
|
|
265
265
|
|
|
266
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.
|
|
267
267
|
|
|
268
|
-
Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
|
|
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
|
|
|
270
270
|
## Things that look right but are wrong
|
|
271
271
|
|
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.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.11`) | 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.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.11", "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"},
|
|
@@ -309,7 +309,7 @@ before, and, when `--json` was given, on stdout:
|
|
|
309
309
|
|
|
310
310
|
```json
|
|
311
311
|
{"status": "failed", "exit_code": 1,
|
|
312
|
-
"error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": "...",
|
|
312
|
+
"error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": "...",
|
|
313
313
|
"code": "INPUT_INVALID | DEPENDENCY_MISSING | FFMPEG_EXECUTION_FAILED | OUTPUT_INVALID | TIMEOUT | VERIFICATION_FAILED | INTERNAL_ERROR",
|
|
314
314
|
"retryable": false},
|
|
315
315
|
"commands": ["ffmpeg ..."]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.11",
|
|
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
|
@@ -596,6 +596,13 @@ loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
|
596
596
|
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
597
597
|
the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
|
|
598
598
|
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
599
|
+
The written file is measured again: a lossy encoder can push peaks past the
|
|
600
|
+
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
601
|
+
+3.7 dBFS). When it does, the tool re-encodes -- first at 256k then 320k if you
|
|
602
|
+
did not pass `--audio-bitrate`, then with the loudnorm ceiling lowered by the
|
|
603
|
+
overshoot -- until the file itself meets `--tp`. `result` reports
|
|
604
|
+
`tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
|
|
605
|
+
integrated loudness ended more than 1 LU from the target because of it.
|
|
599
606
|
|
|
600
607
|
### export.py — delivery presets
|
|
601
608
|
```
|
package/scripts/_common.py
CHANGED
|
@@ -64,6 +64,7 @@ ERROR_CODE = {
|
|
|
64
64
|
"output": "OUTPUT_INVALID",
|
|
65
65
|
"timeout": "TIMEOUT",
|
|
66
66
|
"verification": "VERIFICATION_FAILED",
|
|
67
|
+
"interrupted": "INTERRUPTED",
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
# Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
|
|
@@ -263,6 +264,66 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
263
264
|
crf = getattr(args, "crf", None)
|
|
264
265
|
if crf is not None and not 0 <= int(crf) <= 51:
|
|
265
266
|
die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
|
|
267
|
+
install_signal_handlers()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# The child processes this tool is waiting on right now (an ffmpeg, or a sibling script under
|
|
271
|
+
# run_tool), with the command whose partial output would need removing. A signal handler
|
|
272
|
+
# reads it; the runners keep it current. Before 1.4.9 a SIGTERM to the tool (a cancelled MCP
|
|
273
|
+
# call, a supervisor's stop, a closed terminal) killed only the Python parent: ffmpeg carried on
|
|
274
|
+
# as an orphan, finished a file nobody verified, and the caller got no JSON at all; SIGINT was a
|
|
275
|
+
# KeyboardInterrupt traceback with the partial left on disk.
|
|
276
|
+
_CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
|
|
277
|
+
_SIGNALS_INSTALLED = False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _on_signal(signum: int, frame: Any) -> None:
|
|
281
|
+
import signal as _signal
|
|
282
|
+
name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
|
|
283
|
+
for proc, cmd in list(_CHILDREN):
|
|
284
|
+
try:
|
|
285
|
+
proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
|
|
286
|
+
try:
|
|
287
|
+
proc.wait(timeout=5)
|
|
288
|
+
except subprocess.TimeoutExpired:
|
|
289
|
+
proc.kill()
|
|
290
|
+
proc.wait()
|
|
291
|
+
except OSError:
|
|
292
|
+
pass
|
|
293
|
+
if cmd:
|
|
294
|
+
_cleanup_partial_output(cmd)
|
|
295
|
+
_CHILDREN.clear()
|
|
296
|
+
die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
|
|
297
|
+
code=128 + signum, kind="interrupted")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def install_signal_handlers() -> None:
|
|
301
|
+
"""SIGINT/SIGTERM stop the child, remove its partial output and exit with a failure document
|
|
302
|
+
(kind: interrupted, exit 130/143). Main thread only; on Windows SIGTERM is never delivered,
|
|
303
|
+
SIGINT (Ctrl-C) is."""
|
|
304
|
+
global _SIGNALS_INSTALLED
|
|
305
|
+
if _SIGNALS_INSTALLED:
|
|
306
|
+
return
|
|
307
|
+
import signal as _signal
|
|
308
|
+
import threading
|
|
309
|
+
if threading.current_thread() is not threading.main_thread():
|
|
310
|
+
return
|
|
311
|
+
for sig in (getattr(_signal, "SIGINT", None), getattr(_signal, "SIGTERM", None)):
|
|
312
|
+
if sig is None:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
_signal.signal(sig, _on_signal)
|
|
316
|
+
except (ValueError, OSError):
|
|
317
|
+
pass
|
|
318
|
+
_SIGNALS_INSTALLED = True
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _watch(proc: subprocess.Popen, cmd: Sequence[str]) -> None:
|
|
322
|
+
_CHILDREN.append((proc, cmd))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _unwatch(proc: subprocess.Popen) -> None:
|
|
326
|
+
_CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
|
|
266
327
|
|
|
267
328
|
|
|
268
329
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
@@ -376,6 +437,20 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
|
376
437
|
f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
|
|
377
438
|
|
|
378
439
|
|
|
440
|
+
def _check_output_path(cmd: Sequence[str]) -> None:
|
|
441
|
+
"""An output whose directory does not exist, or that names a directory, is a caller mistake:
|
|
442
|
+
say so as `kind: input` before ffmpeg runs, instead of the muxer's "No such file or directory"
|
|
443
|
+
as `kind: ffmpeg` (which reads as an encoder failure) or an `OUTPUT_INVALID` after the fact."""
|
|
444
|
+
output = cmd[-1]
|
|
445
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
446
|
+
return
|
|
447
|
+
if os.path.isdir(output):
|
|
448
|
+
die(f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})")
|
|
449
|
+
parent = os.path.dirname(os.path.abspath(output))
|
|
450
|
+
if not os.path.isdir(parent):
|
|
451
|
+
die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
|
|
452
|
+
|
|
453
|
+
|
|
379
454
|
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
380
455
|
"""An output path that already exists is someone's file: a previous result, a source the
|
|
381
456
|
agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
|
|
@@ -461,6 +536,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
461
536
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
462
537
|
if is_ffmpeg:
|
|
463
538
|
_check_no_overwrite_input(cmd)
|
|
539
|
+
_check_output_path(cmd)
|
|
464
540
|
_check_existing_output(cmd)
|
|
465
541
|
STATE.commands.append(_cmdline(cmd))
|
|
466
542
|
if not quiet:
|
|
@@ -534,9 +610,16 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
|
|
|
534
610
|
document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
|
|
535
611
|
exactly as they would from the child itself."""
|
|
536
612
|
limit = child_limit(per_call)
|
|
613
|
+
child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
614
|
+
_watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
|
|
537
615
|
try:
|
|
538
|
-
|
|
616
|
+
out, err = child.communicate(timeout=limit)
|
|
617
|
+
_unwatch(child)
|
|
618
|
+
return subprocess.CompletedProcess(child.args, child.returncode, out, err)
|
|
539
619
|
except subprocess.TimeoutExpired as e:
|
|
620
|
+
child.kill()
|
|
621
|
+
child.communicate()
|
|
622
|
+
_unwatch(child)
|
|
540
623
|
name = os.path.basename(str(argv[0]))
|
|
541
624
|
msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
|
|
542
625
|
doc = {"status": "failed", "exit_code": 124,
|
|
@@ -628,10 +711,18 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
|
|
|
628
711
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
629
712
|
"""Plain run with stdout/stderr captured."""
|
|
630
713
|
limit = _limit_for(cmd)
|
|
714
|
+
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
715
|
+
_watch(child, cmd)
|
|
631
716
|
try:
|
|
632
|
-
|
|
717
|
+
out, err = child.communicate(timeout=limit)
|
|
633
718
|
except subprocess.TimeoutExpired:
|
|
719
|
+
child.kill()
|
|
720
|
+
child.communicate()
|
|
721
|
+
_unwatch(child)
|
|
634
722
|
_timed_out(cmd, limit or 0)
|
|
723
|
+
finally:
|
|
724
|
+
_unwatch(child)
|
|
725
|
+
proc = subprocess.CompletedProcess(list(cmd), child.returncode, out, err)
|
|
635
726
|
if proc.returncode == 0 and _is_ffmpeg(cmd):
|
|
636
727
|
_remember_output(cmd)
|
|
637
728
|
if proc.returncode != 0:
|
|
@@ -671,6 +762,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
671
762
|
t0 = time.time()
|
|
672
763
|
limit = _limit_for(cmd)
|
|
673
764
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
765
|
+
_watch(proc, cmd)
|
|
674
766
|
assert proc.stdout is not None and proc.stderr is not None
|
|
675
767
|
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|
|
676
768
|
err_chunks: List[str] = []
|
|
@@ -722,6 +814,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
722
814
|
proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
|
|
723
815
|
except subprocess.TimeoutExpired:
|
|
724
816
|
timed_out()
|
|
817
|
+
_unwatch(proc)
|
|
725
818
|
err_thread.join()
|
|
726
819
|
err = "".join(err_chunks)
|
|
727
820
|
clear_line()
|
|
@@ -1020,12 +1113,17 @@ def escape_filter_path(path: str) -> str:
|
|
|
1020
1113
|
written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
|
|
1021
1114
|
ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
|
|
1022
1115
|
Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
|
|
1023
|
-
never has to be escaped itself;
|
|
1116
|
+
never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
|
|
1117
|
+
one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
|
|
1118
|
+
token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
|
|
1119
|
+
filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
|
|
1120
|
+
(measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
|
|
1024
1121
|
"""
|
|
1025
1122
|
p = str(Path(path))
|
|
1026
1123
|
p = p.replace("\\", "/")
|
|
1027
1124
|
p = p.replace(":", "\\\\:")
|
|
1028
|
-
|
|
1125
|
+
p = p.replace("'", "\\\\\\'")
|
|
1126
|
+
for ch in (",", ";", "[", "]"):
|
|
1029
1127
|
p = p.replace(ch, "\\" + ch)
|
|
1030
1128
|
return p
|
|
1031
1129
|
|
package/scripts/_contract.py
CHANGED
|
@@ -1040,7 +1040,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1040
1040
|
"json_output": {
|
|
1041
1041
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
1042
1042
|
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
|
|
1043
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1"},
|
|
1043
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1044
1044
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1045
1045
|
},
|
|
1046
1046
|
"capabilities": caps,
|
package/scripts/batch.py
CHANGED
|
@@ -144,7 +144,7 @@ def main() -> int:
|
|
|
144
144
|
args = ap.parse_args()
|
|
145
145
|
apply_common(args)
|
|
146
146
|
|
|
147
|
-
folder = Path(args.folder)
|
|
147
|
+
folder = Path(args.folder).resolve() # relative 'bdir' used to become bdir/bdir/out once joined with the default outdir
|
|
148
148
|
if not folder.is_dir():
|
|
149
149
|
die(f"not a folder: {folder}")
|
|
150
150
|
try:
|
package/scripts/loudness.py
CHANGED
|
@@ -50,7 +50,7 @@ def main() -> int:
|
|
|
50
50
|
ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
|
|
51
51
|
ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
|
|
52
52
|
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
53
|
-
ap.add_argument("--audio-bitrate", default=
|
|
53
|
+
ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
|
|
54
54
|
ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
|
|
55
55
|
add_common(ap)
|
|
56
56
|
args = ap.parse_args()
|
|
@@ -80,24 +80,79 @@ def main() -> int:
|
|
|
80
80
|
)
|
|
81
81
|
sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
|
|
82
82
|
ext = os.path.splitext(output)[1].lower()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
cmd += ["-vn"] + audio_codec_for(output, args.audio_bitrate)
|
|
86
|
-
else:
|
|
87
|
-
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", args.audio_bitrate]
|
|
88
|
-
cmd.append(output)
|
|
89
|
-
run(cmd)
|
|
83
|
+
bitrate_pinned = args.audio_bitrate is not None
|
|
84
|
+
bitrate = args.audio_bitrate or "192k"
|
|
90
85
|
|
|
86
|
+
def encode(tp: float, bitrate: str) -> None:
|
|
87
|
+
af = (
|
|
88
|
+
f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
|
|
89
|
+
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
90
|
+
f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
|
|
91
|
+
)
|
|
92
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
93
|
+
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
94
|
+
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
95
|
+
else:
|
|
96
|
+
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
97
|
+
cmd.append(output)
|
|
98
|
+
run(cmd)
|
|
99
|
+
|
|
100
|
+
encode(args.tp, bitrate)
|
|
91
101
|
if STATE.dry_run:
|
|
92
102
|
# pass 1 measured the input for real; there is no output to measure
|
|
93
103
|
emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
|
|
94
104
|
return 0
|
|
95
105
|
after = measure(output, args.lufs, args.tp, args.lra)
|
|
106
|
+
# loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
|
|
107
|
+
# its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
|
|
108
|
+
# from -2.4 dBFS into +3.7 dBFS, so the file measured +1.2 dBTP after "--tp -1" and
|
|
109
|
+
# check.py's fix hint pointed straight back here. Two remedies, in this order: more bits
|
|
110
|
+
# (256k, then 320k: the overshoot is quantisation noise and shrinks with bitrate, and the
|
|
111
|
+
# loudness target is untouched) when the caller did not pin the bitrate; then a lower
|
|
112
|
+
# loudnorm ceiling by the measured overshoot, which in linear mode also lowers the
|
|
113
|
+
# integrated loudness -- reported, never hidden.
|
|
114
|
+
ceiling, rounds = args.tp, 0
|
|
115
|
+
steps = [] if bitrate_pinned or ext in AUDIO_CODECS and "aac" not in AUDIO_CODECS[ext] else [b for b in ("256k", "320k") if _kbps(b) > _kbps(bitrate)]
|
|
116
|
+
while not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1 and rounds < 5:
|
|
117
|
+
rounds += 1
|
|
118
|
+
overshoot = float(after["input_tp"]) - args.tp
|
|
119
|
+
if steps:
|
|
120
|
+
bitrate = steps.pop(0)
|
|
121
|
+
info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
|
|
122
|
+
f"(codec overshoot); re-encoding at {bitrate}")
|
|
123
|
+
else:
|
|
124
|
+
ceiling -= overshoot + 0.2
|
|
125
|
+
info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
|
|
126
|
+
f"(codec overshoot); re-encoding with the loudnorm ceiling at {ceiling:.2f} dBTP")
|
|
127
|
+
encode(ceiling, bitrate)
|
|
128
|
+
after = measure(output, args.lufs, args.tp, args.lra)
|
|
96
129
|
if not after.get("silent"):
|
|
97
|
-
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
|
98
|
-
|
|
130
|
+
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS, TP <= {args.tp:g})")
|
|
131
|
+
result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
|
|
132
|
+
result["tp_ceiling_used"] = round(ceiling, 2)
|
|
133
|
+
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
134
|
+
result["audio_bitrate_used"] = bitrate
|
|
135
|
+
result["encodes"] = rounds + 1
|
|
136
|
+
if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > 1.0:
|
|
137
|
+
result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
|
|
138
|
+
f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
|
|
139
|
+
f"higher --audio-bitrate keeps both")
|
|
140
|
+
if not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1:
|
|
141
|
+
die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {args.tp:g}); "
|
|
142
|
+
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
143
|
+
kind="verification", output=output, result=result,
|
|
144
|
+
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
145
|
+
emit(output, result=result)
|
|
99
146
|
return 0
|
|
100
147
|
|
|
101
148
|
|
|
149
|
+
def _kbps(value: str) -> int:
|
|
150
|
+
v = value.lower().rstrip("k")
|
|
151
|
+
try:
|
|
152
|
+
return int(float(v)) if value.lower().endswith("k") else int(float(v)) // 1000
|
|
153
|
+
except ValueError:
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
102
157
|
if __name__ == "__main__":
|
|
103
158
|
sys.exit(main())
|