ffmpeg-skill 3.1.3 → 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 +27 -1
- package/scripts/_common/probe.py +68 -1
- package/scripts/_common/runner.py +2 -1
- package/scripts/_contract.py +2 -1
- package/scripts/batch.py +2 -2
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
|
@@ -137,6 +137,30 @@ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
|
|
|
137
137
|
ctx.notes.append(note_line)
|
|
138
138
|
|
|
139
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
|
+
|
|
140
164
|
def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
|
|
141
165
|
ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
|
|
142
166
|
"""die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
|
|
@@ -178,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
|
|
|
178
202
|
|
|
179
203
|
|
|
180
204
|
def note(msg: str, ctx: "Optional[Context]" = None) -> None:
|
|
181
|
-
"""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)."""
|
|
182
206
|
ctx = ctx or STATE
|
|
207
|
+
msg = neutralize(msg)
|
|
183
208
|
if msg not in ctx.notes:
|
|
184
209
|
ctx.notes.append(msg)
|
|
185
210
|
sys.stderr.write(f"{msg}\n")
|
|
@@ -682,6 +707,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
682
707
|
if output and not ctx.dry_run:
|
|
683
708
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
684
709
|
_report_dropped_audio_tracks(meta, ctx)
|
|
710
|
+
_report_dropped_hdr10_metadata(meta, ctx)
|
|
685
711
|
if ctx.json:
|
|
686
712
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
687
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,13 +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))
|
|
358
366
|
if role == "input" and out.get("audio_streams"):
|
|
359
367
|
STATE.audio_inputs.add(os.path.realpath(path))
|
|
360
368
|
if role == "input" and getattr(STATE, "first_input", None) is None:
|
|
361
|
-
STATE.first_input = (path, len(out.get("audio_streams") or [])
|
|
369
|
+
STATE.first_input = (path, len(out.get("audio_streams") or []),
|
|
370
|
+
(out.get("video") or {}).get("hdr10_static"))
|
|
362
371
|
return out
|
|
363
372
|
|
|
364
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
|
+
|
|
365
432
|
def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
366
433
|
"""Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
|
|
367
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", "first_input", "audio_inputs")
|
|
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()
|
|
@@ -333,6 +333,7 @@ class Context:
|
|
|
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
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)
|
|
336
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)
|
|
337
338
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
338
339
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
package/scripts/_contract.py
CHANGED
|
@@ -464,7 +464,8 @@ 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}"),
|
|
468
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"),
|
|
469
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"),
|
|
470
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"),
|
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 ""))
|