ffmpeg-skill 3.1.2 → 3.1.4
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 +7 -9
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/decision.py +40 -3
- package/scripts/_common/emit.py +109 -2
- package/scripts/_common/probe.py +71 -0
- package/scripts/_common/runner.py +4 -1
- package/scripts/_contract.py +5 -2
- package/scripts/batch.py +2 -2
- package/scripts/grid.py +9 -0
- package/scripts/scenes.py +10 -1
package/README.md
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="brand/
|
|
4
|
-
<img src="brand/
|
|
5
|
-
</picture>
|
|
6
|
-
</p>
|
|
7
|
-
|
|
8
|
-
<p align="center">
|
|
9
|
-
<picture>
|
|
10
|
-
<source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
|
|
11
|
-
<img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-horizontal-on-dark.svg">
|
|
4
|
+
<img src="brand/logo/ffmpeg-skill-horizontal.svg" height="72" alt="ffmpeg-skill">
|
|
12
5
|
</picture>
|
|
13
6
|
</p>
|
|
14
7
|
|
|
@@ -17,6 +10,7 @@ Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpe
|
|
|
17
10
|
No cloud · No API keys · Python standard library</p>
|
|
18
11
|
|
|
19
12
|
<p align="center">
|
|
13
|
+
<a href="https://kajisho5.github.io/ffmpeg-skill/"><b>Website</b></a> ·
|
|
20
14
|
<a href="#quick-start"><b>Quick start</b></a> ·
|
|
21
15
|
<a href="docs/demos.md">Demos</a> ·
|
|
22
16
|
<a href="#tools">Tools</a> ·
|
|
@@ -32,6 +26,10 @@ No cloud · No API keys · Python standard library</p>
|
|
|
32
26
|
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
33
27
|
</p>
|
|
34
28
|
|
|
29
|
+
<p align="center">
|
|
30
|
+
<a href="https://kajisho5.github.io/ffmpeg-skill/"><img src="brand/og.png" width="720" alt="ffmpeg-skill v3: give your coding agent a video editor. An agent runs probe, cut, fit, export and check, and the final file is measured: 59.98 s, 1080x1920, 30 fps."></a>
|
|
31
|
+
</p>
|
|
32
|
+
|
|
35
33
|
```bash
|
|
36
34
|
npx ffmpeg-skill
|
|
37
35
|
```
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.4`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.1.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.4", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.4",
|
|
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",
|
|
@@ -474,7 +474,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
474
474
|
return _x264_raw(crf, preset, keep_bt709)
|
|
475
475
|
if codec == "hevc":
|
|
476
476
|
if hdr:
|
|
477
|
-
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
477
|
+
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
478
478
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
479
479
|
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
480
480
|
params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
|
|
@@ -485,7 +485,8 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
485
485
|
if "libsvtav1" in ffmpeg_encoders():
|
|
486
486
|
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
|
|
487
487
|
if hdr:
|
|
488
|
-
|
|
488
|
+
svt = hdr10_svtav1_params(v)
|
|
489
|
+
args += (["-svtav1-params", svt] if svt else []) + hdr_tags
|
|
489
490
|
elif keep_bt709:
|
|
490
491
|
params, extra = _sdr_bt709("libsvtav1")
|
|
491
492
|
args += (["-svtav1-params", params] if params else []) + extra
|
|
@@ -508,6 +509,42 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
508
509
|
return []
|
|
509
510
|
|
|
510
511
|
|
|
512
|
+
def hdr10_x265_params(v: Dict[str, Any]) -> str:
|
|
513
|
+
"""The source's HDR10 static metadata as x265 options (":master-display=...:max-cll=..."), or "".
|
|
514
|
+
FFmpeg before 7.0 does not pass the decoded frames' mastering-display / content-light side data
|
|
515
|
+
to libx265, so without these a 6.1 re-encode wrote PQ video with neither (#401). Given on 7+ too,
|
|
516
|
+
where they restate what FFmpeg would pass. x265 units: chromaticity 0.00002, luminance 0.0001 cd/m2."""
|
|
517
|
+
md, cl = _hdr10_parts(v)
|
|
518
|
+
out = ""
|
|
519
|
+
if md:
|
|
520
|
+
c = lambda k: round(md[k] * 50000)
|
|
521
|
+
out += (f":master-display=G({c('green_x')},{c('green_y')})B({c('blue_x')},{c('blue_y')})R({c('red_x')},{c('red_y')})"
|
|
522
|
+
f"WP({c('white_point_x')},{c('white_point_y')})L({round(md['max_luminance'] * 10000)},{round(md['min_luminance'] * 10000)})")
|
|
523
|
+
if cl:
|
|
524
|
+
out += f":max-cll={cl['max_content']},{cl['max_average']}"
|
|
525
|
+
return out
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def hdr10_svtav1_params(v: Dict[str, Any]) -> str:
|
|
529
|
+
"""The same for SVT-AV1 (-svtav1-params), which takes chromaticities and cd/m2 as decimals."""
|
|
530
|
+
md, cl = _hdr10_parts(v)
|
|
531
|
+
parts = []
|
|
532
|
+
if md:
|
|
533
|
+
f = lambda k: f"{md[k]:.5f}".rstrip("0").rstrip(".")
|
|
534
|
+
parts.append(f"mastering-display=G({f('green_x')},{f('green_y')})B({f('blue_x')},{f('blue_y')})R({f('red_x')},{f('red_y')})"
|
|
535
|
+
f"WP({f('white_point_x')},{f('white_point_y')})L({f('max_luminance')},{f('min_luminance')})")
|
|
536
|
+
if cl:
|
|
537
|
+
parts.append(f"content-light={cl['max_content']},{cl['max_average']}")
|
|
538
|
+
return ":".join(parts)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _hdr10_parts(v: Dict[str, Any]) -> "Tuple[Optional[Dict[str, float]], Optional[Dict[str, int]]]":
|
|
542
|
+
st = v.get("hdr10_static") or {}
|
|
543
|
+
if v.get("color_transfer") != "smpte2084":
|
|
544
|
+
return None, None
|
|
545
|
+
return st.get("mastering_display"), st.get("content_light")
|
|
546
|
+
|
|
547
|
+
|
|
511
548
|
def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
|
|
512
549
|
"""`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
|
|
513
550
|
bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
|
|
@@ -667,7 +704,7 @@ def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
|
667
704
|
cs = v.get("color_space") or "bt2020nc"
|
|
668
705
|
prim = v.get("color_primaries") or "bt2020"
|
|
669
706
|
trc = v.get("color_transfer") or "arib-std-b67"
|
|
670
|
-
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
707
|
+
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
671
708
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
672
709
|
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
673
710
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -13,6 +13,59 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
13
13
|
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# Characters that never belong in a message a calling agent reads (#400): C0 controls (a line
|
|
17
|
+
# break is one), DEL and C1, the Unicode line/paragraph separators and the bidi embedding,
|
|
18
|
+
# override and isolate marks. A path or other argument carrying one would otherwise reach
|
|
19
|
+
# error.message / a diagnostic verbatim, where a newline can start what reads as a new line of
|
|
20
|
+
# instructions and a bidi override can reorder what is shown.
|
|
21
|
+
_UNSAFE = frozenset([chr(c) for c in range(0x20) if c not in (0x09, 0x0A)] + [chr(0x7F)]
|
|
22
|
+
+ [chr(c) for c in range(0x80, 0xA0)]
|
|
23
|
+
+ ["\u2028", "\u2029", "\u200e", "\u200f"] + [chr(c) for c in range(0x202A, 0x202F)]
|
|
24
|
+
+ [chr(c) for c in range(0x2066, 0x206A)])
|
|
25
|
+
_LINE_BREAKS = frozenset("\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def shown(value: Any) -> str:
|
|
29
|
+
"""`value` as text with every unsafe character (and any line break) written as an escape
|
|
30
|
+
(\\n, \\x1b, \\u202e). A clean value comes back unchanged, so an ordinary path, a Windows one
|
|
31
|
+
with backslashes included, reads exactly as before."""
|
|
32
|
+
text = str(value)
|
|
33
|
+
if not any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in text):
|
|
34
|
+
return text
|
|
35
|
+
return "".join((ch.encode("unicode_escape").decode("ascii") if (ch in _UNSAFE or ch in _LINE_BREAKS) else ch)
|
|
36
|
+
for ch in text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _argument_forms(arg: str) -> List[str]:
|
|
40
|
+
"""The spellings a message may print an argument in: as given, resolved, and its file name;
|
|
41
|
+
for `--opt=value`, the same for the value, which is what a tool prints back."""
|
|
42
|
+
given = [arg] + ([arg.split("=", 1)[1]] if arg.startswith("-") and "=" in arg else [])
|
|
43
|
+
forms = set(given)
|
|
44
|
+
for a in given:
|
|
45
|
+
try:
|
|
46
|
+
forms.update({os.path.abspath(a), os.path.realpath(a), os.path.basename(a)})
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
pass
|
|
49
|
+
return sorted((f for f in forms if f), key=len, reverse=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def neutralize(msg: Any) -> Any:
|
|
53
|
+
"""A message with caller-supplied text made inert (#400): every command-line argument that
|
|
54
|
+
carries a line break or an unsafe character is replaced by its shown() form wherever it
|
|
55
|
+
appears (as given, resolved or as a file name), then any unsafe character left is escaped.
|
|
56
|
+
Line breaks the tool itself wrote (an ffmpeg stderr tail) stay; one inside an argument does not."""
|
|
57
|
+
if not isinstance(msg, str):
|
|
58
|
+
return msg
|
|
59
|
+
for arg in sys.argv[1:]:
|
|
60
|
+
if isinstance(arg, str) and any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in arg):
|
|
61
|
+
for form in _argument_forms(arg):
|
|
62
|
+
if form in msg and (form != os.path.basename(arg) or any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in form)):
|
|
63
|
+
msg = msg.replace(form, shown(form))
|
|
64
|
+
if any(ch in _UNSAFE for ch in msg):
|
|
65
|
+
msg = "".join(ch.encode("unicode_escape").decode("ascii") if ch in _UNSAFE else ch for ch in msg)
|
|
66
|
+
return msg
|
|
67
|
+
|
|
68
|
+
|
|
16
69
|
def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None,
|
|
17
70
|
error_extra: "Optional[Dict[str, Any]]" = None, **extra: Any) -> "None":
|
|
18
71
|
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
@@ -26,7 +79,8 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
|
|
|
26
79
|
|
|
27
80
|
`error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
|
|
28
81
|
collector's records (warn() and friends) are merged into the document as emit() merges them."""
|
|
29
|
-
hint = extra.pop("hint", None)
|
|
82
|
+
hint = neutralize(extra.pop("hint", None))
|
|
83
|
+
msg = neutralize(msg)
|
|
30
84
|
ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
|
|
31
85
|
_set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
|
|
32
86
|
ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
|
|
@@ -61,6 +115,52 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
|
|
|
61
115
|
sys.exit(code)
|
|
62
116
|
|
|
63
117
|
|
|
118
|
+
def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
|
|
119
|
+
"""The tools write one audio track: a source with several must say how many did not come
|
|
120
|
+
along (#397, every tool, not only cut.py). Only when the output kept sound at all: a tool told
|
|
121
|
+
to drop it (--no-audio, a picture-only output) left nothing out by surprise."""
|
|
122
|
+
first = getattr(STATE, "first_input", None)
|
|
123
|
+
if not first or first[1] < 2 or not meta:
|
|
124
|
+
return
|
|
125
|
+
if len(getattr(STATE, "audio_inputs", None) or ()) > 1:
|
|
126
|
+
return # several sources with sound: which one's tracks the output carries is the tool's own choice
|
|
127
|
+
kept = len(meta.get("audio_streams") or [])
|
|
128
|
+
if not kept or kept >= first[1]:
|
|
129
|
+
return
|
|
130
|
+
if any(w.get("code") == "audio_tracks_dropped" for w in ctx.warnings):
|
|
131
|
+
return # the tool said it already (cut.py, with its own field)
|
|
132
|
+
warn(f"the source has {first[1]} audio tracks; this output kept {kept} and left {first[1] - kept} out "
|
|
133
|
+
"(the tools write one audio track; --audio-stream N picks which, where a tool takes it)",
|
|
134
|
+
"audio_tracks_dropped", ctx=ctx, source=first[1], kept=kept, dropped=first[1] - kept)
|
|
135
|
+
note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
|
|
136
|
+
if note_line and note_line not in ctx.notes:
|
|
137
|
+
ctx.notes.append(note_line)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None:
|
|
141
|
+
"""A PQ source's mastering-display / content-light metadata that a PQ output came out without
|
|
142
|
+
(#401): measured on the written file, so whatever encoder or FFmpeg version dropped it is said.
|
|
143
|
+
An output that is no longer PQ (color.py --to-sdr, an HLG conversion) was converted on purpose."""
|
|
144
|
+
first = getattr(STATE, "first_input", None)
|
|
145
|
+
src = first[2] if first and len(first) > 2 else None
|
|
146
|
+
v = (meta or {}).get("video") or {}
|
|
147
|
+
if not src or v.get("color_transfer") != "smpte2084":
|
|
148
|
+
return
|
|
149
|
+
if len(getattr(STATE, "video_inputs", None) or ()) > 1:
|
|
150
|
+
return # several pictures: the first input's metadata need not be the output's source
|
|
151
|
+
have = v.get("hdr10_static") or {}
|
|
152
|
+
lost = [k for k in ("mastering_display", "content_light") if src.get(k) and not have.get(k)]
|
|
153
|
+
if not lost:
|
|
154
|
+
return
|
|
155
|
+
names = {"mastering_display": "mastering display", "content_light": "content light level"}
|
|
156
|
+
warn("the source's HDR10 " + " and ".join(names[k] for k in lost) + " metadata did not come through to this output "
|
|
157
|
+
"(the encoder or this FFmpeg build did not write it); players may tone-map it differently",
|
|
158
|
+
"hdr10_metadata_dropped", ctx=ctx, dropped=lost, codec=v.get("codec"))
|
|
159
|
+
note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
|
|
160
|
+
if note_line and note_line not in ctx.notes:
|
|
161
|
+
ctx.notes.append(note_line)
|
|
162
|
+
|
|
163
|
+
|
|
64
164
|
def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
|
|
65
165
|
ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
|
|
66
166
|
"""die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
|
|
@@ -72,6 +172,7 @@ def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code
|
|
|
72
172
|
|
|
73
173
|
def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Dict[str, Any], prefix: str) -> Dict[str, Any]:
|
|
74
174
|
ctx = ctx or STATE
|
|
175
|
+
msg = neutralize(msg)
|
|
75
176
|
entry: Dict[str, Any] = {"code": code, "message": msg}
|
|
76
177
|
entry.update(data)
|
|
77
178
|
# once per run: a decision taken for every encode of a job (an AV1 encoder fallback, a CRF
|
|
@@ -101,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
|
|
|
101
202
|
|
|
102
203
|
|
|
103
204
|
def note(msg: str, ctx: "Optional[Context]" = None) -> None:
|
|
104
|
-
"""A plain `notes` line that also goes to stderr."""
|
|
205
|
+
"""A plain `notes` line that also goes to stderr, made inert as messages are (#400)."""
|
|
105
206
|
ctx = ctx or STATE
|
|
207
|
+
msg = neutralize(msg)
|
|
106
208
|
if msg not in ctx.notes:
|
|
107
209
|
ctx.notes.append(msg)
|
|
108
210
|
sys.stderr.write(f"{msg}\n")
|
|
@@ -116,6 +218,9 @@ def _merge_collector(doc: Dict[str, Any], ctx: "Context") -> None:
|
|
|
116
218
|
extra_notes = [n for n in ctx.notes if n not in (doc.get("notes") or [])]
|
|
117
219
|
if extra_notes:
|
|
118
220
|
doc["notes"] = list(doc.get("notes") or []) + extra_notes
|
|
221
|
+
if doc.get("notes"):
|
|
222
|
+
# a tool's own notes can quote an input path (join.py's duplicates): made inert as messages are (#400)
|
|
223
|
+
doc["notes"] = [neutralize(n) for n in doc["notes"]]
|
|
119
224
|
diag = {k: list(getattr(ctx, k)) for k in ("warnings", "fallbacks", "skipped") if getattr(ctx, k)}
|
|
120
225
|
if diag:
|
|
121
226
|
merged = dict(doc.get("diagnostics") or {})
|
|
@@ -601,6 +706,8 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
601
706
|
meta: Dict[str, Any] = {}
|
|
602
707
|
if output and not ctx.dry_run:
|
|
603
708
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
709
|
+
_report_dropped_audio_tracks(meta, ctx)
|
|
710
|
+
_report_dropped_hdr10_metadata(meta, ctx)
|
|
604
711
|
if ctx.json:
|
|
605
712
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
606
713
|
if meta:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -317,6 +317,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
317
317
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
318
318
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
319
319
|
"dolby_vision": dovi,
|
|
320
|
+
# HDR10 static metadata (mastering display, content light level) as the encoders take it:
|
|
321
|
+
# FFmpeg before 7 does not hand it from the decoded frames to libx265 / libsvtav1, so the
|
|
322
|
+
# tools pass it explicitly (decision.hdr10_x265_params). None when the source has none.
|
|
323
|
+
"hdr10_static": _hdr10_static(ffprobe, path, video) if trc == "smpte2084" else None,
|
|
320
324
|
"color_space": video.get("color_space"),
|
|
321
325
|
"color_primaries": video.get("color_primaries"),
|
|
322
326
|
"color_transfer": video.get("color_transfer"),
|
|
@@ -355,9 +359,76 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
355
359
|
"language": (a.get("tags") or {}).get("language"),
|
|
356
360
|
"title": (a.get("tags") or {}).get("title"),
|
|
357
361
|
} for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
|
|
362
|
+
if role == "input" and out.get("video") and out["video"].get("codec") \
|
|
363
|
+
and os.path.splitext(path)[1].lower() not in _STILL_EXT:
|
|
364
|
+
# a still (an overlay's logo, an insert card) is not a second picture source (#412 review)
|
|
365
|
+
STATE.video_inputs.add(os.path.realpath(path))
|
|
366
|
+
if role == "input" and out.get("audio_streams"):
|
|
367
|
+
STATE.audio_inputs.add(os.path.realpath(path))
|
|
368
|
+
if role == "input" and getattr(STATE, "first_input", None) is None:
|
|
369
|
+
STATE.first_input = (path, len(out.get("audio_streams") or []),
|
|
370
|
+
(out.get("video") or {}).get("hdr10_static"))
|
|
358
371
|
return out
|
|
359
372
|
|
|
360
373
|
|
|
374
|
+
def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
|
|
375
|
+
"""A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
|
|
376
|
+
a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
|
|
377
|
+
frame is decoded when either part is missing or incomplete in the stream. Each part is taken
|
|
378
|
+
from wherever it is complete first, the stream before the frame. Chromaticities are CIE 1931
|
|
379
|
+
x/y, luminances cd/m2. None when it has neither."""
|
|
380
|
+
out = _hdr10_parts(stream.get("side_data_list") or [])
|
|
381
|
+
if len(out) < 2:
|
|
382
|
+
# V:0, not v:0: an attached cover picture before the motion video has no HDR side data
|
|
383
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-read_intervals", "%+#1", "-show_frames",
|
|
384
|
+
"-show_entries", "frame=side_data_list", "-of", "json", path], quiet=True, check=False)
|
|
385
|
+
frames: List[Dict[str, Any]] = []
|
|
386
|
+
if proc.returncode == 0:
|
|
387
|
+
try:
|
|
388
|
+
frames = json.loads(proc.stdout or "{}").get("frames") or []
|
|
389
|
+
except ValueError:
|
|
390
|
+
pass
|
|
391
|
+
for k, v in _hdr10_parts((frames[0].get("side_data_list") or []) if frames else []).items():
|
|
392
|
+
out.setdefault(k, v)
|
|
393
|
+
return out or None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _hdr10_parts(sides: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
|
397
|
+
"""The complete parts in one side-data list: a mastering display with every field, a content
|
|
398
|
+
light level with both values; an incomplete record counts as absent."""
|
|
399
|
+
out: Dict[str, Any] = {}
|
|
400
|
+
for sd in sides:
|
|
401
|
+
kind = _side_kind(sd)
|
|
402
|
+
if kind == "mastering" and "mastering_display" not in out:
|
|
403
|
+
vals = {k: _ratio(sd.get(k)) for k in ("red_x", "red_y", "green_x", "green_y", "blue_x", "blue_y",
|
|
404
|
+
"white_point_x", "white_point_y", "min_luminance", "max_luminance")}
|
|
405
|
+
if all(v is not None for v in vals.values()):
|
|
406
|
+
out["mastering_display"] = {k: float(v) for k, v in vals.items()}
|
|
407
|
+
elif kind == "light" and "content_light" not in out:
|
|
408
|
+
mc, ma = _to_int(sd.get("max_content")), _to_int(sd.get("max_average"))
|
|
409
|
+
if mc is not None and ma is not None:
|
|
410
|
+
out["content_light"] = {"max_content": mc, "max_average": ma}
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_STILL_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif", ".heic")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _ratio(v: Any) -> "Optional[Fraction]":
|
|
418
|
+
"""A side-data value such as "34000/50000" or "0/10000". Unlike _fraction() a zero is a value
|
|
419
|
+
(a mastering display's minimum luminance may be 0 cd/m2); only a missing or malformed one is None."""
|
|
420
|
+
try:
|
|
421
|
+
f = Fraction(str(v))
|
|
422
|
+
except (ValueError, ZeroDivisionError, TypeError):
|
|
423
|
+
return None
|
|
424
|
+
return f if f >= 0 else None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _side_kind(sd: Dict[str, Any]) -> "Optional[str]":
|
|
428
|
+
t = str(sd.get("side_data_type") or "")
|
|
429
|
+
return "mastering" if t == "Mastering display metadata" else "light" if t == "Content light level metadata" else None
|
|
430
|
+
|
|
431
|
+
|
|
361
432
|
def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
362
433
|
"""Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
|
|
363
434
|
chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
|
|
@@ -324,7 +324,7 @@ class Context:
|
|
|
324
324
|
|
|
325
325
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
326
326
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
327
|
-
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
|
|
327
|
+
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
|
|
328
328
|
|
|
329
329
|
def __init__(self) -> None:
|
|
330
330
|
self.reset()
|
|
@@ -332,6 +332,9 @@ class Context:
|
|
|
332
332
|
def reset(self) -> None:
|
|
333
333
|
self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
|
|
334
334
|
self.json = False # emit() prints a JSON document instead of the output path
|
|
335
|
+
self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
|
|
336
|
+
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
337
|
+
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
335
338
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
336
339
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
337
340
|
self.fast = False # x264 preset forced to veryfast
|
package/scripts/_contract.py
CHANGED
|
@@ -109,7 +109,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
109
109
|
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
110
110
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
111
111
|
"grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
|
|
112
|
-
required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
|
|
112
|
+
required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default; without drawtext the cells are not labelled, fallback grid_no_drawtext)"}, {"capability": AAC, "when": "--audio-from"}],
|
|
113
113
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
114
114
|
"straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
|
|
115
115
|
required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
|
|
@@ -464,7 +464,10 @@ DIAGNOSTIC_CODES = {
|
|
|
464
464
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
|
465
465
|
# fonts and text
|
|
466
466
|
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
467
|
-
"
|
|
467
|
+
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
468
|
+
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
469
|
+
"grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
|
|
470
|
+
"scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
468
471
|
"look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
469
472
|
"font_substituted": ("fallbacks", "the asked font family is not installed: fontconfig or the Windows Fonts folder gave another (font_used names it); {asked, file, family, via}"),
|
|
470
473
|
"font_lacks_script": ("warnings", "an explicit --font does not cover the text's script and is kept as asked; {font, script}"),
|
package/scripts/batch.py
CHANGED
|
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
|
|
36
36
|
from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
|
|
37
|
-
from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
|
|
37
|
+
from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
|
|
38
38
|
|
|
39
39
|
HERE = Path(__file__).resolve().parent
|
|
40
40
|
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
@@ -722,7 +722,7 @@ def main() -> int:
|
|
|
722
722
|
why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
|
|
723
723
|
info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
|
|
724
724
|
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
|
|
725
|
-
unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
|
|
725
|
+
unverified = [shown(os.path.basename(r["file"])) for r in results if r.get("ok") and not r.get("verified")]
|
|
726
726
|
if unverified and not STATE.dry_run:
|
|
727
727
|
note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
|
|
728
728
|
+ ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))
|
package/scripts/grid.py
CHANGED
|
@@ -29,6 +29,8 @@ from _common import add_common, apply_common, aac_args, cfr_args, default_output
|
|
|
29
29
|
draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
|
+
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
|
+
from _common.emit import note, record_fallback # noqa: E402
|
|
32
34
|
|
|
33
35
|
LABEL_MARGIN = 10
|
|
34
36
|
|
|
@@ -56,6 +58,13 @@ def main() -> int:
|
|
|
56
58
|
add_common(ap)
|
|
57
59
|
args = ap.parse_args()
|
|
58
60
|
apply_common(args)
|
|
61
|
+
if args.label == "auto" and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
|
|
62
|
+
# the labels are the only drawtext in the grid: without the filter (Homebrew's plain
|
|
63
|
+
# ffmpeg) draw the grid unlabelled and say so, as look.py does for its stamps (#394)
|
|
64
|
+
args.label = "none"
|
|
65
|
+
record_fallback("this ffmpeg build has no drawtext filter, so the cells are not labelled "
|
|
66
|
+
"(the same as --label none)", "grid_no_drawtext")
|
|
67
|
+
note("no cell labels: this ffmpeg build has no drawtext filter (" + filter_missing_hint("drawtext") + ")")
|
|
59
68
|
|
|
60
69
|
n = args.cols * args.rows
|
|
61
70
|
if args.cols <= 0 or args.rows <= 0:
|
package/scripts/scenes.py
CHANGED
|
@@ -27,6 +27,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
|
|
|
27
27
|
ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
|
|
28
28
|
rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
|
|
29
29
|
from _common import drawtext_fontfile # noqa: E402
|
|
30
|
+
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
31
|
+
from _common.emit import note, record_fallback # noqa: E402
|
|
30
32
|
|
|
31
33
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
32
34
|
SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
|
|
@@ -287,6 +289,13 @@ def main() -> int:
|
|
|
287
289
|
fps = meta["video"].get("fps") or 30.0
|
|
288
290
|
expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
|
|
289
291
|
stamp = ""
|
|
292
|
+
if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
|
|
293
|
+
# as look.py (#394): the sheet needs drawtext only for its stamps
|
|
294
|
+
args.no_timecode = True
|
|
295
|
+
record_fallback("this ffmpeg build has no drawtext filter, so the --sheet has no timecode stamps "
|
|
296
|
+
"(the same as --no-timecode)", "scenes_sheet_no_drawtext")
|
|
297
|
+
note("--sheet without timecode stamps: this ffmpeg build has no drawtext filter ("
|
|
298
|
+
+ filter_missing_hint("drawtext") + ")")
|
|
290
299
|
if not args.no_timecode:
|
|
291
300
|
default_font = default_font_file("DejaVu Sans")
|
|
292
301
|
font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
|
|
@@ -297,7 +306,7 @@ def main() -> int:
|
|
|
297
306
|
info(f"wrote {args.sheet}")
|
|
298
307
|
result["sheet"] = args.sheet
|
|
299
308
|
|
|
300
|
-
if
|
|
309
|
+
if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
|
|
301
310
|
emit(None, **result)
|
|
302
311
|
else:
|
|
303
312
|
print_json(result)
|