ffmpeg-skill 1.14.0 → 1.15.1
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 +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Result documents: die(), info(), emit(), the brief and 2.0 shapes, and the plan file.
|
|
2
|
+
|
|
3
|
+
Every script ends in exactly one of these: emit() on success, die() on failure. Both print a
|
|
4
|
+
single JSON document when --json is on and record the Context the caller passed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
13
|
+
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None, **extra: Any) -> "None":
|
|
17
|
+
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
18
|
+
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
|
|
19
|
+
|
|
20
|
+
`extra` fields are added to the failure document: a tool whose *result* failed (check.py's
|
|
21
|
+
platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
|
|
22
|
+
reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
|
|
23
|
+
`status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
|
|
24
|
+
read a failed delivery as a success."""
|
|
25
|
+
hint = extra.pop("hint", None)
|
|
26
|
+
ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
|
|
27
|
+
_set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
|
|
28
|
+
ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
|
|
29
|
+
STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
|
|
30
|
+
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
31
|
+
if ctx.json:
|
|
32
|
+
doc: Dict[str, Any] = {
|
|
33
|
+
"status": "failed", "exit_code": code,
|
|
34
|
+
"error": {
|
|
35
|
+
"kind": kind, "message": msg,
|
|
36
|
+
"code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
|
|
37
|
+
"retryable": ERROR_RETRYABLE,
|
|
38
|
+
},
|
|
39
|
+
"commands": list(ctx.commands),
|
|
40
|
+
}
|
|
41
|
+
if hint:
|
|
42
|
+
doc["error"]["hint"] = hint
|
|
43
|
+
doc.update(extra)
|
|
44
|
+
print_json(doc)
|
|
45
|
+
sys.exit(code)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def info(msg: str, ctx: "Optional[Context]" = None) -> None:
|
|
49
|
+
# under --dry-run nothing is written; do not let scripts claim otherwise
|
|
50
|
+
ctx = ctx or STATE
|
|
51
|
+
if msg.startswith("wrote ") and ctx.dry_run:
|
|
52
|
+
msg = "[dry-run] would write " + msg[len("wrote "):]
|
|
53
|
+
sys.stderr.write(f"{msg}\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
|
|
57
|
+
# here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
|
|
58
|
+
_CURRENT_CTX: "Optional[Context]" = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _set_current_ctx(ctx: "Context") -> None:
|
|
62
|
+
global _CURRENT_CTX
|
|
63
|
+
_CURRENT_CTX = ctx
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
|
|
67
|
+
"""Final stdout line: the output path, or a JSON document with --json.
|
|
68
|
+
|
|
69
|
+
`ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
|
|
70
|
+
omitted, every read falls back to the process-global STATE as before."""
|
|
71
|
+
ctx = ctx or STATE
|
|
72
|
+
_set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
|
|
73
|
+
meta: Dict[str, Any] = {}
|
|
74
|
+
if output and not ctx.dry_run:
|
|
75
|
+
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
76
|
+
if ctx.json:
|
|
77
|
+
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
78
|
+
if meta:
|
|
79
|
+
doc["probe"] = meta
|
|
80
|
+
# What this tool itself verified about its artifact (issue #189 C, "verify as part of the
|
|
81
|
+
# contract"): the probe every writing tool runs, plus the measurements a tool adds
|
|
82
|
+
# (`verification` extra: loudness after the write, a platform check). `verified` is true
|
|
83
|
+
# only when the file was written, probed, and every self-check met its target; a dry run
|
|
84
|
+
# verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
|
|
85
|
+
# keep status completed and say verified: false, so a caller keys on one field.
|
|
86
|
+
steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
|
|
87
|
+
if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
|
|
88
|
+
steps.insert(0, {"step": "exists", "ok": True})
|
|
89
|
+
doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
|
|
90
|
+
doc["verification"] = steps
|
|
91
|
+
doc.update(extra)
|
|
92
|
+
if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
|
|
93
|
+
doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
|
|
94
|
+
if ctx.plan:
|
|
95
|
+
doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
|
|
96
|
+
print_json(_brief(doc, meta) if ctx.json_brief else doc)
|
|
97
|
+
elif ctx.plan:
|
|
98
|
+
print(write_plan(ctx.plan, output, extra, ctx=ctx))
|
|
99
|
+
elif output:
|
|
100
|
+
print(output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Keys the brief document replaces or drops: the full probe (summarised), the command lines
|
|
104
|
+
# (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
|
|
105
|
+
_BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
|
|
109
|
+
"""The handful of output facts a caller reports or branches on, from the probe this tool
|
|
110
|
+
already ran -- plus the measured loudness when the tool measured one. Keys whose value is
|
|
111
|
+
unknown are left out rather than emitted as null."""
|
|
112
|
+
video = (meta or {}).get("video") or {}
|
|
113
|
+
audio = (meta or {}).get("audio") or {}
|
|
114
|
+
summary: Dict[str, Any] = {}
|
|
115
|
+
duration = (meta or {}).get("duration")
|
|
116
|
+
if duration is not None:
|
|
117
|
+
summary["duration_s"] = round(float(duration), 3)
|
|
118
|
+
for key, value in (("width", video.get("width")), ("height", video.get("height")), ("fps", video.get("fps")),
|
|
119
|
+
("vcodec", video.get("codec")), ("acodec", audio.get("codec")), ("channels", audio.get("channels"))):
|
|
120
|
+
if value is not None:
|
|
121
|
+
summary[key] = value
|
|
122
|
+
lufs = None
|
|
123
|
+
for source, key in ((extra.get("result"), "input_i"), (extra.get("measured"), "input_i")):
|
|
124
|
+
if lufs is None and isinstance(source, dict):
|
|
125
|
+
lufs = _to_float(source.get(key))
|
|
126
|
+
for step in extra.get("verification") or []:
|
|
127
|
+
if lufs is None and isinstance(step, dict):
|
|
128
|
+
lufs = _to_float(step.get("lufs"))
|
|
129
|
+
# a silent file measures -inf, which json.dumps writes as the non-standard -Infinity: the
|
|
130
|
+
# brief document stays valid JSON by leaving the key out instead (the full document's own
|
|
131
|
+
# `measured`/`result` still carries whatever the tool reported).
|
|
132
|
+
if lufs is not None and math.isfinite(lufs):
|
|
133
|
+
summary["lufs"] = round(lufs, 2)
|
|
134
|
+
return summary
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
138
|
+
"""--json-brief: the same success document with the bulky parts replaced by what a caller
|
|
139
|
+
acts on. Same keys, same meanings -- `commands` becomes the count of the command lines,
|
|
140
|
+
`probe` becomes `summary` -- plus every tool-specific key the tool itself passed to emit().
|
|
141
|
+
Failures are untouched: die() prints the full failure document either way."""
|
|
142
|
+
brief: Dict[str, Any] = {"status": doc["status"], "output": doc["output"], "dry_run": doc["dry_run"],
|
|
143
|
+
"verified": doc.get("verified", False)}
|
|
144
|
+
summary = _brief_summary(meta, doc)
|
|
145
|
+
if summary:
|
|
146
|
+
brief["summary"] = summary
|
|
147
|
+
brief["commands"] = len(doc.get("commands") or [])
|
|
148
|
+
for key, value in doc.items():
|
|
149
|
+
if key not in brief and key not in _BRIEF_DROP:
|
|
150
|
+
brief[key] = value
|
|
151
|
+
# the emoji report is a full inventory in the long document; brief keeps the two fields a
|
|
152
|
+
# caller branches on (did colour happen, and how many)
|
|
153
|
+
if isinstance(brief.get("emoji"), dict):
|
|
154
|
+
brief["emoji"] = {k: v for k, v in brief["emoji"].items() if k in ("mode", "count")}
|
|
155
|
+
return brief
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
PLAN_VERSION = 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_PLAN_STRIP = ("--plan", "--dry-run", "--json")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _plan_at_exit() -> None:
|
|
165
|
+
ctx = _CURRENT_CTX or STATE
|
|
166
|
+
if ctx.plan and not ctx.plan_written:
|
|
167
|
+
try:
|
|
168
|
+
write_plan(ctx.plan, None, {}, ctx=ctx)
|
|
169
|
+
except SystemExit:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
|
|
174
|
+
"""Every existing file the plan depends on: the `-i` inputs of the planned commands, any
|
|
175
|
+
existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
|
|
176
|
+
inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
|
|
177
|
+
import shlex
|
|
178
|
+
seen: List[str] = []
|
|
179
|
+
for a in list(argv) + list((ctx or STATE).plan_inputs):
|
|
180
|
+
if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
|
|
181
|
+
seen.append(a)
|
|
182
|
+
for line in commands:
|
|
183
|
+
try:
|
|
184
|
+
toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
|
|
185
|
+
except ValueError:
|
|
186
|
+
continue
|
|
187
|
+
for i, tok in enumerate(toks[:-1]):
|
|
188
|
+
if tok == "-i" and os.path.isfile(toks[i + 1]) and toks[i + 1] not in seen:
|
|
189
|
+
seen.append(toks[i + 1])
|
|
190
|
+
return seen
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
|
|
194
|
+
"""The dry run as an artifact: what will run, on which exact inputs, producing what, checked
|
|
195
|
+
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
|
|
196
|
+
|
|
197
|
+
`ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
|
|
198
|
+
they were given); omitted, it is the process-global STATE as before."""
|
|
199
|
+
import datetime
|
|
200
|
+
ctx = ctx or STATE
|
|
201
|
+
argv = [a for a in sys.argv[1:]]
|
|
202
|
+
cleaned: List[str] = []
|
|
203
|
+
skip = False
|
|
204
|
+
for a in argv:
|
|
205
|
+
if skip:
|
|
206
|
+
skip = False
|
|
207
|
+
continue
|
|
208
|
+
if a in _PLAN_STRIP:
|
|
209
|
+
skip = a == "--plan"
|
|
210
|
+
continue
|
|
211
|
+
if a.startswith("--plan="):
|
|
212
|
+
continue
|
|
213
|
+
cleaned.append(a)
|
|
214
|
+
tool = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
215
|
+
verify: List[Dict[str, Any]] = [{"tool": "probe"}] if output else []
|
|
216
|
+
platform = None
|
|
217
|
+
if "--platform" in cleaned:
|
|
218
|
+
platform = cleaned[cleaned.index("--platform") + 1]
|
|
219
|
+
elif tool == "export" and "--preset" in cleaned:
|
|
220
|
+
platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
|
|
221
|
+
if platform and output and tool != "check":
|
|
222
|
+
verify.append({"tool": "check", "platform": platform})
|
|
223
|
+
doc = {
|
|
224
|
+
"plan_version": PLAN_VERSION,
|
|
225
|
+
"created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
226
|
+
"tool": tool,
|
|
227
|
+
"argv": cleaned,
|
|
228
|
+
"cwd": os.getcwd(),
|
|
229
|
+
"inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
|
|
230
|
+
"commands": list(ctx.commands),
|
|
231
|
+
"output": os.path.abspath(output) if output else None,
|
|
232
|
+
"verify": verify,
|
|
233
|
+
"notes": list(extra.get("notes") or []),
|
|
234
|
+
}
|
|
235
|
+
try:
|
|
236
|
+
tmp = f"{path}.tmp{os.getpid()}"
|
|
237
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
238
|
+
json.dump(doc, f, indent=2, ensure_ascii=False)
|
|
239
|
+
f.write("\n")
|
|
240
|
+
os.replace(tmp, path)
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
die(f"cannot write plan {path}: {exc}", kind="output")
|
|
243
|
+
ctx.plan_written = True
|
|
244
|
+
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
|
|
245
|
+
return path
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
|
|
252
|
+
"""The 2.0 success-document shape, previewed in 1.x as a parallel `result_v2` key when
|
|
253
|
+
FFMPEG_SKILL_RESULT_V2=1 (issue #189 B). Every tool gets the same six slots: `output`,
|
|
254
|
+
`probe`, `commands`, `metrics` (numbers a caller keys on: loudness's `result`/`measured`
|
|
255
|
+
dicts flattened, plus every top-level numeric extra such as `expected_duration` or
|
|
256
|
+
`offset_seconds`), `notes` (free text), `dropped` (what did not make it into the output),
|
|
257
|
+
and `details` (the tool's remaining extras, unchanged). The 1.x keys stay where they are;
|
|
258
|
+
this key is additive and its shape is what 2.0 promotes to the top level."""
|
|
259
|
+
metrics: Dict[str, Any] = {}
|
|
260
|
+
for key in ("measured", "result"):
|
|
261
|
+
if isinstance(extra.get(key), dict):
|
|
262
|
+
metrics.update(extra[key])
|
|
263
|
+
for key, value in extra.items():
|
|
264
|
+
if key not in _V2_HANDLED and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
265
|
+
metrics[key] = value
|
|
266
|
+
notes = extra.get("notes")
|
|
267
|
+
return {
|
|
268
|
+
"schema": 2,
|
|
269
|
+
"output": output,
|
|
270
|
+
"probe": meta or None,
|
|
271
|
+
"commands": list(STATE.commands),
|
|
272
|
+
"metrics": metrics,
|
|
273
|
+
"notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
|
|
274
|
+
"dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
|
|
275
|
+
"verified": bool(extra.get("verified", False)),
|
|
276
|
+
"verification": list(extra.get("verification") or []),
|
|
277
|
+
"details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def print_json(obj: Any) -> None:
|
|
282
|
+
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Deferred for the same reason as runner's import of this module: probe needs die() from here, and
|
|
286
|
+
# emit() needs verify_output() from there, but only ever at call time.
|
|
287
|
+
from _common.probe import MEDIA_EXT, _to_float, fingerprint, verify_output # noqa: E402
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""ffprobe and the measured facts read back off a file: the probe document every script starts
|
|
2
|
+
from, the verification every script ends with, and the level/waveform measurements.
|
|
3
|
+
|
|
4
|
+
Reading only -- the choices made from these facts live in decision.py.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
14
|
+
from _common.emit import die
|
|
15
|
+
from _common.runner import STATE, require_tool, run, run_analysis
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fingerprint(path: str) -> Dict[str, Any]:
|
|
19
|
+
"""Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
|
|
20
|
+
or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
|
|
21
|
+
planning slower than the edit)."""
|
|
22
|
+
import hashlib
|
|
23
|
+
st = os.stat(path)
|
|
24
|
+
h = hashlib.sha256()
|
|
25
|
+
chunk = 8 * 1024 * 1024
|
|
26
|
+
with open(path, "rb") as f:
|
|
27
|
+
h.update(f.read(chunk))
|
|
28
|
+
if st.st_size > 2 * chunk:
|
|
29
|
+
f.seek(-chunk, os.SEEK_END)
|
|
30
|
+
h.update(f.read(chunk))
|
|
31
|
+
elif st.st_size > chunk:
|
|
32
|
+
h.update(f.read())
|
|
33
|
+
return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
|
|
37
|
+
*, check: bool = True) -> List[float]:
|
|
38
|
+
"""Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
|
|
39
|
+
single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
|
|
40
|
+
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
|
|
41
|
+
ffmpeg = require_tool("ffmpeg")
|
|
42
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
43
|
+
if start:
|
|
44
|
+
cmd += ["-ss", f"{start:.3f}"]
|
|
45
|
+
cmd += ["-i", path]
|
|
46
|
+
if seconds is not None:
|
|
47
|
+
cmd += ["-t", f"{seconds:.3f}"]
|
|
48
|
+
cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
|
|
49
|
+
proc = run_analysis(cmd, check=False, text=False)
|
|
50
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
51
|
+
if check:
|
|
52
|
+
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
|
|
53
|
+
return []
|
|
54
|
+
n = len(proc.stdout) // 2
|
|
55
|
+
import struct
|
|
56
|
+
return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
|
|
60
|
+
"""RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
|
|
61
|
+
block must be the same length for the correlation); remove_mean subtracts the envelope's mean
|
|
62
|
+
(sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
|
|
63
|
+
step = max(1, int(step))
|
|
64
|
+
n = len(samples)
|
|
65
|
+
stop = n - step + 1 if full_blocks_only else n
|
|
66
|
+
env: List[float] = []
|
|
67
|
+
for i in range(0, max(0, stop), step):
|
|
68
|
+
block = samples[i:i + step]
|
|
69
|
+
env.append(math.sqrt(sum(x * x for x in block) / len(block)))
|
|
70
|
+
if remove_mean and env:
|
|
71
|
+
mean = sum(env) / len(env)
|
|
72
|
+
env = [e - mean for e in env]
|
|
73
|
+
return env
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
|
|
77
|
+
".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _output_failed(path: str, why: str) -> "None":
|
|
81
|
+
"""An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
|
|
82
|
+
0-byte file behind that a later step could mistake for a result."""
|
|
83
|
+
try:
|
|
84
|
+
if os.path.exists(path) and os.path.getsize(path) == 0:
|
|
85
|
+
os.remove(path)
|
|
86
|
+
why += " (empty file removed)"
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
die(f"output verification failed: {path}: {why}", kind="output")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def verify_output(path: str) -> Dict[str, Any]:
|
|
93
|
+
"""The success criterion for every writing tool: the file exists, is not empty and ffprobe
|
|
94
|
+
can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
|
|
95
|
+
exist and be non-empty. Returns the probe (empty dict for non-media)."""
|
|
96
|
+
if not os.path.exists(path):
|
|
97
|
+
_output_failed(path, "not written")
|
|
98
|
+
if os.path.getsize(path) == 0:
|
|
99
|
+
_output_failed(path, "0 bytes")
|
|
100
|
+
if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
|
|
101
|
+
return {}
|
|
102
|
+
meta = probe(path, role="output")
|
|
103
|
+
if not meta.get("video") and not meta.get("audio"):
|
|
104
|
+
_output_failed(path, "no video or audio stream")
|
|
105
|
+
return meta
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
109
|
+
"""Return a compact, script-friendly description of a media file.
|
|
110
|
+
|
|
111
|
+
role="output" marks a file this tool just wrote: a read failure is then reported as an
|
|
112
|
+
output-verification failure (kind "output") instead of an input problem."""
|
|
113
|
+
if not os.path.exists(path):
|
|
114
|
+
if role == "output" and not STATE.dry_run:
|
|
115
|
+
_output_failed(path, "not written")
|
|
116
|
+
if STATE.dry_run:
|
|
117
|
+
# width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
|
|
118
|
+
# below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
|
|
119
|
+
# probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
|
|
120
|
+
# which some tools' dry-run summary line echoed verbatim as if it were a real computed
|
|
121
|
+
# preview (#77). That was reverted once, because a couple of call sites divided by these
|
|
122
|
+
# values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
|
|
123
|
+
# sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
|
|
124
|
+
# by it, so the stub can finally report the honest, unknown value.
|
|
125
|
+
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
126
|
+
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
127
|
+
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
128
|
+
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
|
|
129
|
+
die(f"input not found: {path}")
|
|
130
|
+
ffprobe = require_tool("ffprobe")
|
|
131
|
+
proc = run(
|
|
132
|
+
[ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
|
|
133
|
+
quiet=True,
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
if proc.returncode != 0:
|
|
137
|
+
if role == "output":
|
|
138
|
+
_output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
|
|
139
|
+
die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
|
|
140
|
+
try:
|
|
141
|
+
raw = json.loads(proc.stdout or "{}")
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
if role == "output":
|
|
144
|
+
_output_failed(path, f"ffprobe printed unreadable JSON: {e}")
|
|
145
|
+
die(f"ffprobe printed unreadable JSON for {path}: {e}", kind="ffmpeg")
|
|
146
|
+
fmt = raw.get("format", {})
|
|
147
|
+
streams = raw.get("streams", [])
|
|
148
|
+
video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
|
|
149
|
+
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
150
|
+
subs = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
151
|
+
data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
|
|
152
|
+
|
|
153
|
+
duration = _to_float(fmt.get("duration"))
|
|
154
|
+
if duration is None and video:
|
|
155
|
+
duration = _to_float(video.get("duration"))
|
|
156
|
+
if duration is None and audio:
|
|
157
|
+
duration = _to_float(audio.get("duration"))
|
|
158
|
+
if duration and STATE.duration_hint is None:
|
|
159
|
+
STATE.duration_hint = duration
|
|
160
|
+
|
|
161
|
+
out: Dict[str, Any] = {
|
|
162
|
+
"file": path,
|
|
163
|
+
"format": fmt.get("format_name"),
|
|
164
|
+
"duration": duration,
|
|
165
|
+
"size_bytes": _to_int(fmt.get("size")),
|
|
166
|
+
"bitrate": _to_int(fmt.get("bit_rate")),
|
|
167
|
+
"video": None,
|
|
168
|
+
"audio": None,
|
|
169
|
+
"subtitle_streams": len(subs),
|
|
170
|
+
"data_streams": data_stream_count,
|
|
171
|
+
# container-level chapter markers and the common tags, so metadata.py's result is
|
|
172
|
+
# verifiable the same way every other tool's is (additive keys, 1.x-safe)
|
|
173
|
+
"chapters": [{
|
|
174
|
+
"index": n,
|
|
175
|
+
"start": _to_float(ch.get("start_time")),
|
|
176
|
+
"end": _to_float(ch.get("end_time")),
|
|
177
|
+
"title": (ch.get("tags") or {}).get("title"),
|
|
178
|
+
} for n, ch in enumerate(raw.get("chapters") or [])],
|
|
179
|
+
"tags": {k.lower(): v for k, v in (fmt.get("tags") or {}).items() if k.lower() in ("title", "artist", "album", "comment", "date", "genre")},
|
|
180
|
+
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
181
|
+
"subtitle_stream_details": [{
|
|
182
|
+
"index": n,
|
|
183
|
+
"codec": s.get("codec_name"),
|
|
184
|
+
"language": (s.get("tags") or {}).get("language"),
|
|
185
|
+
"title": (s.get("tags") or {}).get("title"),
|
|
186
|
+
} for n, s in enumerate(subs)],
|
|
187
|
+
}
|
|
188
|
+
if video:
|
|
189
|
+
r_rate = _fraction(video.get("r_frame_rate"))
|
|
190
|
+
avg_rate = _fraction(video.get("avg_frame_rate"))
|
|
191
|
+
fps = float(avg_rate) if avg_rate else (float(r_rate) if r_rate else None)
|
|
192
|
+
vfr = bool(r_rate and avg_rate and abs(float(r_rate) - float(avg_rate)) > 0.01)
|
|
193
|
+
w, h = _to_int(video.get("width")), _to_int(video.get("height"))
|
|
194
|
+
rotation = 0
|
|
195
|
+
for sd in video.get("side_data_list", []) or []:
|
|
196
|
+
if "rotation" in sd:
|
|
197
|
+
rotation = int(round(float(sd["rotation"])))
|
|
198
|
+
if "rotate" in (video.get("tags") or {}):
|
|
199
|
+
try:
|
|
200
|
+
rotation = int(video["tags"]["rotate"])
|
|
201
|
+
except ValueError:
|
|
202
|
+
pass
|
|
203
|
+
pix = video.get("pix_fmt") or ""
|
|
204
|
+
trc = video.get("color_transfer") or ""
|
|
205
|
+
prim = video.get("color_primaries") or ""
|
|
206
|
+
hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
|
|
207
|
+
dovi = None
|
|
208
|
+
for sd in video.get("side_data_list", []) or []:
|
|
209
|
+
if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
|
|
210
|
+
dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
|
|
211
|
+
if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
|
|
212
|
+
hdr = True
|
|
213
|
+
out["video"] = {
|
|
214
|
+
"codec": video.get("codec_name"),
|
|
215
|
+
"profile": video.get("profile"),
|
|
216
|
+
"width": w,
|
|
217
|
+
"height": h,
|
|
218
|
+
"display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
|
|
219
|
+
"fps": round(fps, 3) if fps else None,
|
|
220
|
+
"r_frame_rate": video.get("r_frame_rate"),
|
|
221
|
+
"avg_frame_rate": video.get("avg_frame_rate"),
|
|
222
|
+
"variable_frame_rate_suspected": vfr,
|
|
223
|
+
"pix_fmt": video.get("pix_fmt"),
|
|
224
|
+
"bit_depth": _bit_depth(pix),
|
|
225
|
+
"hdr": hdr,
|
|
226
|
+
# 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
|
|
227
|
+
# Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
|
|
228
|
+
# transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
|
|
229
|
+
"hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
|
|
230
|
+
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
231
|
+
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
232
|
+
"dolby_vision": dovi,
|
|
233
|
+
"color_space": video.get("color_space"),
|
|
234
|
+
"color_primaries": video.get("color_primaries"),
|
|
235
|
+
"color_transfer": video.get("color_transfer"),
|
|
236
|
+
"color_range": video.get("color_range"),
|
|
237
|
+
"rotation": rotation,
|
|
238
|
+
"nb_frames": _to_int(video.get("nb_frames")),
|
|
239
|
+
"bitrate": _to_int(video.get("bit_rate")),
|
|
240
|
+
}
|
|
241
|
+
if audio:
|
|
242
|
+
out["audio"] = {
|
|
243
|
+
"codec": audio.get("codec_name"),
|
|
244
|
+
"channels": _to_int(audio.get("channels")),
|
|
245
|
+
"channel_layout": audio.get("channel_layout"),
|
|
246
|
+
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
247
|
+
"bitrate": _to_int(audio.get("bit_rate")),
|
|
248
|
+
}
|
|
249
|
+
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
250
|
+
out["audio_streams"] = [{
|
|
251
|
+
"index": n,
|
|
252
|
+
"codec": a.get("codec_name"),
|
|
253
|
+
"channels": _to_int(a.get("channels")),
|
|
254
|
+
"channel_layout": a.get("channel_layout"),
|
|
255
|
+
"sample_rate": _to_int(a.get("sample_rate")),
|
|
256
|
+
"language": (a.get("tags") or {}).get("language"),
|
|
257
|
+
"title": (a.get("tags") or {}).get("title"),
|
|
258
|
+
} for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
263
|
+
"""Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
|
|
264
|
+
chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
|
|
265
|
+
yuv420p10le -> 10, gbrp12be -> 12, gray16le -> 16, yuv410p / yuv420p / rgb24 -> 8."""
|
|
266
|
+
m = re.search(r"(\d{1,2})(?:le|be)?$", pix_fmt or "")
|
|
267
|
+
if not m:
|
|
268
|
+
return 8
|
|
269
|
+
n = int(m.group(1))
|
|
270
|
+
if n in (24, 32): # packed 8-bit rgb24/bgr32/rgb0 etc.
|
|
271
|
+
return 8
|
|
272
|
+
if n in (48, 64): # packed 16-bit rgb48/rgba64
|
|
273
|
+
return 16
|
|
274
|
+
return n if 8 <= n <= 16 else 8
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
|
|
278
|
+
"""Video keyframe timestamps within +-window seconds of t, ascending. Read with
|
|
279
|
+
-read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
|
|
280
|
+
ffprobe = require_tool("ffprobe")
|
|
281
|
+
lo = max(0.0, t - window)
|
|
282
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
|
|
283
|
+
"-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
|
|
284
|
+
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
285
|
+
if proc.returncode != 0:
|
|
286
|
+
return []
|
|
287
|
+
out: List[float] = []
|
|
288
|
+
for line in proc.stdout.splitlines():
|
|
289
|
+
try:
|
|
290
|
+
out.append(round(float(line.strip().rstrip(",")), 3))
|
|
291
|
+
except ValueError:
|
|
292
|
+
continue
|
|
293
|
+
return sorted(set(out))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
|
|
297
|
+
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
298
|
+
Cheap enough to run once as a hint when a threshold-based tool found nothing."""
|
|
299
|
+
ffmpeg = require_tool("ffmpeg")
|
|
300
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
|
|
301
|
+
"-af", "volumedetect", "-f", "null", "-"], check=False)
|
|
302
|
+
m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
303
|
+
m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
304
|
+
if not (m_mean and m_max):
|
|
305
|
+
return None
|
|
306
|
+
return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
310
|
+
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
311
|
+
|
|
312
|
+
Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
|
|
313
|
+
white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
|
|
314
|
+
"""
|
|
315
|
+
ffmpeg = require_tool("ffmpeg")
|
|
316
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
317
|
+
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
318
|
+
proc = run_analysis(cmd, check=False)
|
|
319
|
+
vals: Dict[str, List[float]] = {}
|
|
320
|
+
for line in proc.stdout.splitlines():
|
|
321
|
+
if "lavfi.signalstats." in line and "=" in line:
|
|
322
|
+
key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
|
|
323
|
+
try:
|
|
324
|
+
vals.setdefault(key, []).append(float(val))
|
|
325
|
+
except ValueError:
|
|
326
|
+
pass
|
|
327
|
+
if not vals.get("YAVG"):
|
|
328
|
+
return {"error": "no frames analysed"}
|
|
329
|
+
def mean(k: str) -> float:
|
|
330
|
+
v = vals.get(k) or [0.0]
|
|
331
|
+
return sum(v) / len(v)
|
|
332
|
+
ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
|
|
333
|
+
# signalstats reports in the source bit depth; normalise everything to an 8-bit scale
|
|
334
|
+
scale = 1.0
|
|
335
|
+
if ymax > 255 or yavg > 255:
|
|
336
|
+
scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
|
|
337
|
+
ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
|
|
338
|
+
# 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
|
|
339
|
+
lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
|
|
340
|
+
highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
|
|
341
|
+
p_low = lows[len(lows) // 20]
|
|
342
|
+
p_high = highs[-1 - len(highs) // 20]
|
|
343
|
+
looks_log = p_low >= 64 and p_high <= 235 and sat < 40
|
|
344
|
+
return {
|
|
345
|
+
"scale": "8-bit equivalent",
|
|
346
|
+
"y_min": round(ymin, 1), "y_max": round(ymax, 1), "y_avg": round(yavg, 1), "y_low_p5": round(p_low, 1), "y_high_p95": round(p_high, 1),
|
|
347
|
+
"saturation_avg": round(sat, 1),
|
|
348
|
+
"looks_like_log": looks_log,
|
|
349
|
+
"note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
|
|
350
|
+
"Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _to_float(v: Any) -> Optional[float]:
|
|
355
|
+
try:
|
|
356
|
+
return float(v) if v is not None else None
|
|
357
|
+
except (TypeError, ValueError):
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _to_int(v: Any) -> Optional[int]:
|
|
362
|
+
try:
|
|
363
|
+
return int(v) if v is not None else None
|
|
364
|
+
except (TypeError, ValueError):
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _fraction(v: Optional[str]) -> Optional[Fraction]:
|
|
369
|
+
if not v or v in ("0/0", "0"):
|
|
370
|
+
return None
|
|
371
|
+
try:
|
|
372
|
+
f = Fraction(v)
|
|
373
|
+
return f if f > 0 else None
|
|
374
|
+
except (ValueError, ZeroDivisionError):
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
|
|
379
|
+
if not w or not h:
|
|
380
|
+
return None
|
|
381
|
+
f = Fraction(w, h)
|
|
382
|
+
return f"{f.numerator}:{f.denominator}"
|