ffmpeg-skill 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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