ffmpeg-skill 2.20.0 → 2.21.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 +28 -0
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +60 -0
- package/scripts/audio.py +2 -1
- package/scripts/join.py +6 -2
- package/scripts/loudness.py +4 -3
- package/scripts/waveform.py +2 -1
package/README.md
CHANGED
|
@@ -412,6 +412,33 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
412
412
|
|
|
413
413
|
Each operation is a script whose flags are its argparse parser, and that parser is what `contract --json` and the MCP `inputSchema` are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, `-vf` string, or other ffmpeg option is not an argument any script accepts. `doctor` reads `ffmpeg -filters` (and `-encoders`, `-bsfs`) and reports a filter a tool needs before a job runs into it. MCP does accept an `argv` array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
|
|
414
414
|
|
|
415
|
+
### Why 42 tools? Isn't that overkill?
|
|
416
|
+
|
|
417
|
+
One tool per job, because each job promises something different, and the tool measures that promise after the run. `loudness.py` re-measures integrated loudness and true peak. `cut.py` reports how far a cut snapped to a keyframe. `silence.py` returns the ranges it kept. `export.py` checks the preset's maximum length. A value a tool cannot read fails that step as `not_measured`; it does not pass. One tool with a mode flag would need as large a schema, and its checks would be vaguer.
|
|
418
|
+
|
|
419
|
+
An agent does not read 42 schemas. Over MCP, `tools/list` advertises the core 12 (bold below). The other 30 are called by name through `tools/call` when a job needs one, and `FFMPEG_SKILL_MCP_FULL=1` lists all 42. The groups are the ones in [Tools](#tools).
|
|
420
|
+
|
|
421
|
+
<!-- why42:start -->
|
|
422
|
+
| Group | MCP default / all | Tools |
|
|
423
|
+
|---|---|---|
|
|
424
|
+
| Analysis and inspection | 2 / 3 | **probe**, **look**, scenes |
|
|
425
|
+
| Editing | 3 / 24 | **cut**, **fit**, **silence**, join, crop, cropdetect, deinterlace, denoise, redact, sphere, straighten, insert, background, reverse, stabilize, sequence, waveform, freeze, pad, speedramp, loop, broll, metadata, grid |
|
|
426
|
+
| Audio | 2 / 3 | **audio**, **loudness**, sync |
|
|
427
|
+
| Picture | 2 / 4 | **caption**, **graphics**, overlay, color |
|
|
428
|
+
| Delivery | 2 / 4 | **export**, **check**, proxy, report |
|
|
429
|
+
| Orchestration | 1 / 4 | **render**, batch, multicam, verify |
|
|
430
|
+
| **Total** | **12 / 42** | |
|
|
431
|
+
<!-- why42:end -->
|
|
432
|
+
|
|
433
|
+
Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake there often still exits 0:
|
|
434
|
+
|
|
435
|
+
- `stabilize.py` runs vidstab in two passes, and the second pass needs the first pass's transforms file.
|
|
436
|
+
- `color.py` builds the HDR-to-SDR chain. With a wrong colour tag the run still finishes, and the picture comes out washed out.
|
|
437
|
+
- `join.py` puts transitions between clips that differ in size, frame rate or audio layout. Done by hand, those clips have to be normalised first or xfade fails.
|
|
438
|
+
- `sync.py` and `multicam.py` find the offset between two recordings by audio cross-correlation. No ffmpeg filter measures it.
|
|
439
|
+
|
|
440
|
+
Every command a tool ran is in its `--json` `commands`, shell-quoted so it can be pasted and run again, and `--dry-run` prints the commands without running anything. In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
|
|
441
|
+
|
|
415
442
|
### How does ffmpeg-skill compare with Remotion?
|
|
416
443
|
|
|
417
444
|
ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). [`remotion-ffmpeg-skill`](https://github.com/kajisho5/remotion-ffmpeg-skill) joins the two. It measures the footage with this repo, draws the graphics in Remotion from those measurements, and checks and delivers the result with this repo again. Install both (`npx ffmpeg-skill`, then `npx remotion-ffmpeg-skill`). As with the other skills, the dependency runs one way: it calls these tools, and nothing here calls it. HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
|
|
@@ -614,6 +641,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
614
641
|
| [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
|
|
615
642
|
| [SKILL.md](SKILL.md) | what the agent reads: workflow, request → tool map, audio-only rules, report format, pitfalls |
|
|
616
643
|
| [references/scripts.md](references/scripts.md) | per-flag reference for every tool |
|
|
644
|
+
| [references/scripts.md § Verdict thresholds](references/scripts.md#verdict-thresholds-what-passes) | every number behind `verified` and `check.py`'s rows (tolerances, silence levels, per-platform loudness and length), each with the constant or flag it comes from |
|
|
617
645
|
| [references/devices.md](references/devices.md) | real-device notes (iPhone HDR, GoPro, DJI, screen recordings) |
|
|
618
646
|
| [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) | per-OS ffmpeg/CI behaviour differences already diagnosed once — read before re-diagnosing a Windows/macOS-only test failure |
|
|
619
647
|
| [references/process-pitfalls.md](references/process-pitfalls.md) | process mistakes already made once (breaking a pinned test by narrowing a capability list, retrying a git/GitHub operation this environment can't do, re-designing a fixture instead of recognising a real platform difference) — a living record, add to it whenever one recurs |
|
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 (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.21.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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.21.0", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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
|
@@ -14,7 +14,67 @@ yourself, which is the only way for a tool with no input file (`caption.py
|
|
|
14
14
|
anywhere is `kind: input` naming the flag. Nothing else about times differs
|
|
15
15
|
between tools.
|
|
16
16
|
|
|
17
|
+
## Verdict thresholds (what passes)
|
|
18
|
+
|
|
19
|
+
The numbers behind `verified`, `check.py`'s PASS/WARN/FAIL rows and the cuts the tools make. A
|
|
20
|
+
result also carries the tolerance it used in each `verification[]` step (`tolerance`, since 2.18).
|
|
21
|
+
Every row names the constant or flag it comes from, and a test holds the table to it
|
|
22
|
+
(`test_the_thresholds_table_matches_the_code`).
|
|
23
|
+
|
|
24
|
+
<!-- thresholds:start -->
|
|
25
|
+
| what | value | source | used by |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| true peak may exceed the dBTP ceiling by | 0.1 dB | `_platforms.TP_TOLERANCE_DB` | `loudness.py`, `check.py`, `export.py` |
|
|
28
|
+
| a normalised file is on its `-I` target within | 1.0 LU | `loudness.LUFS_TOLERANCE` | `loudness.py` |
|
|
29
|
+
| a levels pass keeps the sound's length to (or one AAC frame, 1024 samples, if longer) | 0.03 s | `loudness.AUDIO_LENGTH_TOLERANCE` | `loudness.py` |
|
|
30
|
+
| a joined picture is as long as the clips less the transitions, to (frames) | 1.5 | `join.DURATION_TOLERANCE_FRAMES` | `join.py` |
|
|
31
|
+
| ... and at least | 0.05 s | `join.DURATION_TOLERANCE_MIN` | `join.py` |
|
|
32
|
+
| a lossless cut may land this far from the asked time before it re-encodes | 0.5 s | `cut.py --tolerance` | `cut.py` |
|
|
33
|
+
| silence is sound at or below | -35 dBFS | `silence.py --threshold` | `silence.py` |
|
|
34
|
+
| ... lasting at least | 0.6 s | `silence.py --min-silence` | `silence.py` |
|
|
35
|
+
| a removed silence is digital silence when 99% of it sits at or below | -90 dBFS | `silence.DIGITAL_SILENCE_DB` | `silence.py` (`silence_kinds`) |
|
|
36
|
+
| a track is silent when its peak is at or below | -50 dBFS | `_common.probe.SILENT_PEAK_DBFS` | `check.py`; the `--silence-threshold` default of `join.py`, `audio.py`, `waveform.py` |
|
|
37
|
+
| a filler word longer than this is a held vowel, kept | 1.2 s | `_common.decision.FILLER_MAX_WORD` | `silence.py --filler` |
|
|
38
|
+
| a pause this long between two tokens of an unspaced script is a word boundary | 0.1 s | `_common.decision.FILLER_BOUNDARY_GAP` | `silence.py --filler` |
|
|
39
|
+
| `--content` black: WARN above this share of the duration | 0.10 | `check.BLACK_WARN` | `check.py --content` |
|
|
40
|
+
| `--content` black: FAIL at this share | 0.95 | `check.BLACK_FAIL` | `check.py --content` |
|
|
41
|
+
| `--content` black: a pixel is black below this share of the luma range | 0.10 | `_common.probe.CONTENT_BLACK_PIX_TH` | `check.py --content` |
|
|
42
|
+
| `--content` black: a black run shorter than this is not counted | 0.1 s | `_common.probe.CONTENT_MIN_SPAN` | `check.py --content` |
|
|
43
|
+
| `--content` frozen: WARN when the longest span is above this, or above 30% | 3.0 s | `check.FROZEN_WARN_S` | `check.py --content` |
|
|
44
|
+
| `--content` frozen: FAIL when one span covers this share | 0.95 | `check.FROZEN_FAIL` | `check.py --content` |
|
|
45
|
+
| `--content` frozen: a picture unchanged for at least this long is frozen | 0.5 s | `_common.probe.CONTENT_FREEZE_MIN` | `check.py --content` |
|
|
46
|
+
| `--content` frozen: frames this close count as unchanged (freezedetect noise) | -60 dB | `_common.probe.CONTENT_FREEZE_NOISE` | `check.py --content` |
|
|
47
|
+
| `--content` silence: WARN above this share below the silent level | 0.50 | `check.SILENCE_WARN` | `check.py --content` |
|
|
48
|
+
| `--content` silence: FAIL at this share | 0.95 | `check.SILENCE_FAIL` | `check.py --content` |
|
|
49
|
+
| `--content` silence: a silent run shorter than this is not counted | 0.5 s | `_common.probe.CONTENT_SILENCE_MIN` | `check.py --content` |
|
|
50
|
+
| `--content` dropout: a gap of sound at or below | -80 dBFS | `_common.probe.CONTENT_DROPOUT_DB` | `check.py --content` |
|
|
51
|
+
| ... between (s) and 1 s long, with sound on both sides | 0.02 s | `_common.probe.CONTENT_DROPOUT_MIN` | `check.py --content` |
|
|
52
|
+
| a beat grid below this confidence is not snapped to | 0.5 | `_common.decision.BEAT_MIN_CONFIDENCE` | `cut.py --snap beats`, `scenes.py --beats` |
|
|
53
|
+
<!-- thresholds:end -->
|
|
54
|
+
|
|
55
|
+
Delivery targets per platform (`scripts/_platforms.py`). A file passes loudness within the
|
|
56
|
+
platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB. `export.py`
|
|
57
|
+
holds the written length to the maximum with no tolerance, as `check.py` does.
|
|
58
|
+
|
|
59
|
+
<!-- platforms:start -->
|
|
60
|
+
| platform | loudness | tolerance | true peak | max duration |
|
|
61
|
+
|---|---|---|---|---|
|
|
62
|
+
| `tiktok` | -14 LUFS | ±2 LU | -1 dBTP | 600 s |
|
|
63
|
+
| `reels` | -14 LUFS | ±2 LU | -1 dBTP | 90 s |
|
|
64
|
+
| `shorts` | -14 LUFS | ±2 LU | -1 dBTP | 180 s |
|
|
65
|
+
| `youtube` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
|
|
66
|
+
| `youtube-hdr` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
|
|
67
|
+
| `youtube-av1` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
|
|
68
|
+
| `x` | -14 LUFS | ±3 LU | -1 dBTP | 140 s |
|
|
69
|
+
| `linkedin` | -14 LUFS | ±3 LU | -1 dBTP | 600 s |
|
|
70
|
+
| `facebook` | -14 LUFS | ±3 LU | -1 dBTP | 14400 s |
|
|
71
|
+
| `podcast` | -16 LUFS | ±1 LU | -1 dBTP | — |
|
|
72
|
+
| `broadcast` | -23 LUFS | ±1 LU | -1 dBTP | — |
|
|
73
|
+
| `custom` | — | ±2 LU | -1 dBTP | — |
|
|
74
|
+
<!-- platforms:end -->
|
|
75
|
+
|
|
17
76
|
## Contents
|
|
77
|
+
- verdict thresholds — what passes, and where each number lives
|
|
18
78
|
- probe.py — inspect
|
|
19
79
|
- cut.py — cut / join segments
|
|
20
80
|
- fit.py — target duration and/or aspect, rotate/flip
|
package/scripts/audio.py
CHANGED
|
@@ -25,6 +25,7 @@ import sys
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import Any, Dict, List
|
|
27
27
|
|
|
28
|
+
from _common.probe import SILENT_PEAK_DBFS
|
|
28
29
|
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
|
|
29
30
|
from _common.emit import warn
|
|
30
31
|
|
|
@@ -216,7 +217,7 @@ def main() -> int:
|
|
|
216
217
|
ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
|
|
217
218
|
help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
|
|
218
219
|
"--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
|
|
219
|
-
ap.add_argument("--silence-threshold", type=float, default
|
|
220
|
+
ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DBFS",
|
|
220
221
|
help="peak level in dBFS at or below which a track counts as silent (default -50)")
|
|
221
222
|
add_common(ap)
|
|
222
223
|
args = ap.parse_args()
|
package/scripts/join.py
CHANGED
|
@@ -39,6 +39,7 @@ import os
|
|
|
39
39
|
import sys
|
|
40
40
|
from typing import Any, Dict, List, Optional, Tuple
|
|
41
41
|
|
|
42
|
+
from _common.probe import SILENT_PEAK_DBFS
|
|
42
43
|
from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
43
44
|
from _common.emit import warn
|
|
44
45
|
|
|
@@ -50,6 +51,9 @@ STATE_SKIPPED: List[Dict[str, Any]] = []
|
|
|
50
51
|
# --dry-run: inputs that do not exist yet, planned on as an earlier step's output
|
|
51
52
|
STATE_PENDING: List[Dict[str, Any]] = []
|
|
52
53
|
STATE_NOTES: List[str] = []
|
|
54
|
+
# the joined picture's length against the clips' sum less the transitions: 1.5 frames, at least 0.05 s
|
|
55
|
+
DURATION_TOLERANCE_FRAMES = 1.5
|
|
56
|
+
DURATION_TOLERANCE_MIN = 0.05
|
|
53
57
|
# --on-silent warn: inputs whose audio peak is at or below --silence-threshold, joined anyway
|
|
54
58
|
STATE_SILENT: List[Dict[str, Any]] = []
|
|
55
59
|
# warnings, never refusals: a clip shorter than MIN_SEGMENT_FRAMES frames (audio-only: shorter than
|
|
@@ -316,7 +320,7 @@ def main() -> int:
|
|
|
316
320
|
ap.add_argument("--allow-silent", metavar="N[,N...]",
|
|
317
321
|
help="input numbers (1-based) whose silence is intended, e.g. a planned pause: always joined, "
|
|
318
322
|
"listed under `silent` with intended: true, no warning, whatever --on-silent says")
|
|
319
|
-
ap.add_argument("--silence-threshold", type=float, default
|
|
323
|
+
ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DB",
|
|
320
324
|
help="peak level in dBFS at or below which an input counts as silent (default -50)")
|
|
321
325
|
ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
|
|
322
326
|
ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
|
|
@@ -579,7 +583,7 @@ def main() -> int:
|
|
|
579
583
|
measured = (r.get("video") or {}).get("duration") or r.get("duration")
|
|
580
584
|
verification = []
|
|
581
585
|
if expected is not None and measured is not None and not STATE.dry_run:
|
|
582
|
-
tol = max(
|
|
586
|
+
tol = max(DURATION_TOLERANCE_FRAMES / fps, DURATION_TOLERANCE_MIN)
|
|
583
587
|
verification.append({"step": "duration", "property": "video_duration", "ok": abs(measured - expected) <= tol,
|
|
584
588
|
"measured": round(measured, 3), "expected": expected,
|
|
585
589
|
"delta": round(measured - expected, 3), "tolerance": round(tol, 3),
|
package/scripts/loudness.py
CHANGED
|
@@ -22,6 +22,7 @@ import sys
|
|
|
22
22
|
from _platforms import TP_TOLERANCE_DB
|
|
23
23
|
|
|
24
24
|
AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
25
|
+
LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
|
|
25
26
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
26
27
|
|
|
27
28
|
|
|
@@ -159,7 +160,7 @@ def main() -> int:
|
|
|
159
160
|
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
160
161
|
result["audio_bitrate_used"] = bitrate
|
|
161
162
|
result["encodes"] = rounds + 1
|
|
162
|
-
if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) >
|
|
163
|
+
if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > LUFS_TOLERANCE:
|
|
163
164
|
result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
|
|
164
165
|
f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
|
|
165
166
|
f"higher --audio-bitrate keeps both")
|
|
@@ -169,12 +170,12 @@ def main() -> int:
|
|
|
169
170
|
kind="verification", output=output, result=result,
|
|
170
171
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
171
172
|
verification = [{"step": "loudness",
|
|
172
|
-
"ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <=
|
|
173
|
+
"ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
|
|
173
174
|
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
|
|
174
175
|
"property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
|
|
175
176
|
"measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
|
|
176
177
|
"delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
|
|
177
|
-
"tolerance": {"lufs":
|
|
178
|
+
"tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
|
|
178
179
|
written = probe(output, role="output")
|
|
179
180
|
out_audio = audio_length(written)
|
|
180
181
|
if in_audio and out_audio:
|
package/scripts/waveform.py
CHANGED
|
@@ -35,6 +35,7 @@ import os
|
|
|
35
35
|
import sys
|
|
36
36
|
|
|
37
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
|
+
from _common.probe import SILENT_PEAK_DBFS
|
|
38
39
|
from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
39
40
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
40
41
|
fmt_secs, child_args, run_tool, measured_level_dbfs)
|
|
@@ -60,7 +61,7 @@ def main() -> int:
|
|
|
60
61
|
ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
|
|
61
62
|
help="input audio whose peak is at or below --silence-threshold (the render would be a flat "
|
|
62
63
|
"line): warn (default: render it, report `silent: true`) or fail (refuse before ffmpeg runs)")
|
|
63
|
-
ap.add_argument("--silence-threshold", type=float, default
|
|
64
|
+
ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DBFS",
|
|
64
65
|
help="peak level in dBFS at or below which the input counts as silent (default -50)")
|
|
65
66
|
ag = ap.add_argument_group("audiogram (1.16)",
|
|
66
67
|
"The visualisation over a picture, for an episode that has no video. The image is a "
|