ffmpeg-skill 1.4.3 → 1.4.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 +1 -1
- package/SKILL.md +1 -1
- package/package.json +1 -1
- package/scripts/_common.py +47 -5
- package/scripts/_contract.py +1 -1
- package/scripts/batch.py +8 -8
- package/scripts/caption.py +29 -5
- package/scripts/check.py +7 -3
- package/scripts/cropdetect.py +2 -3
- package/scripts/render.py +13 -11
- package/scripts/scenes.py +5 -7
- package/scripts/sync.py +2 -3
- package/scripts/verify.py +8 -5
package/README.md
CHANGED
|
@@ -293,7 +293,7 @@ The contract is generated from the code that runs, not maintained beside it. For
|
|
|
293
293
|
| `mutates_input` | always `false` |
|
|
294
294
|
| `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
|
|
295
295
|
|
|
296
|
-
`contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | missing_tool", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
|
|
296
|
+
`contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
|
|
297
297
|
|
|
298
298
|
### MCP
|
|
299
299
|
|
package/SKILL.md
CHANGED
|
@@ -265,7 +265,7 @@ Steps: probe -> color (failed); nothing written
|
|
|
265
265
|
Notes: send a valid .cube, or say if you want the clip left as is
|
|
266
266
|
```
|
|
267
267
|
|
|
268
|
-
Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
|
|
268
|
+
Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
|
|
269
269
|
|
|
270
270
|
## Things that look right but are wrong
|
|
271
271
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.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",
|
package/scripts/_common.py
CHANGED
|
@@ -63,6 +63,7 @@ ERROR_CODE = {
|
|
|
63
63
|
"ffmpeg": "FFMPEG_EXECUTION_FAILED",
|
|
64
64
|
"output": "OUTPUT_INVALID",
|
|
65
65
|
"timeout": "TIMEOUT",
|
|
66
|
+
"verification": "VERIFICATION_FAILED",
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
# Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
|
|
@@ -149,12 +150,18 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
|
|
|
149
150
|
parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
|
|
150
151
|
|
|
151
152
|
|
|
152
|
-
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
153
|
+
def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
|
|
153
154
|
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
154
|
-
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
|
|
155
|
+
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
|
|
156
|
+
|
|
157
|
+
`extra` fields are added to the failure document: a tool whose *result* failed (check.py's
|
|
158
|
+
platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
|
|
159
|
+
reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
|
|
160
|
+
`status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
|
|
161
|
+
read a failed delivery as a success."""
|
|
155
162
|
sys.stderr.write(f"error: {msg}\n")
|
|
156
163
|
if STATE.json:
|
|
157
|
-
|
|
164
|
+
doc: Dict[str, Any] = {
|
|
158
165
|
"status": "failed", "exit_code": code,
|
|
159
166
|
"error": {
|
|
160
167
|
"kind": kind, "message": msg,
|
|
@@ -162,7 +169,9 @@ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
|
162
169
|
"retryable": ERROR_RETRYABLE,
|
|
163
170
|
},
|
|
164
171
|
"commands": list(STATE.commands),
|
|
165
|
-
}
|
|
172
|
+
}
|
|
173
|
+
doc.update(extra)
|
|
174
|
+
print_json(doc)
|
|
166
175
|
sys.exit(code)
|
|
167
176
|
|
|
168
177
|
|
|
@@ -461,6 +470,39 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
461
470
|
return proc
|
|
462
471
|
|
|
463
472
|
|
|
473
|
+
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
|
|
474
|
+
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
|
|
475
|
+
output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
|
|
476
|
+
--dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
|
|
477
|
+
limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
|
|
478
|
+
instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
|
|
479
|
+
limit = _limit_for(cmd)
|
|
480
|
+
try:
|
|
481
|
+
proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
|
|
482
|
+
except subprocess.TimeoutExpired:
|
|
483
|
+
_timed_out(cmd, limit or 0)
|
|
484
|
+
if check and proc.returncode != 0:
|
|
485
|
+
err = proc.stderr if text else proc.stderr.decode(errors="replace")
|
|
486
|
+
_fail(cmd, proc.returncode, err)
|
|
487
|
+
return proc
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def child_args() -> List[str]:
|
|
491
|
+
"""The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
|
|
492
|
+
so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
|
|
493
|
+
stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
|
|
494
|
+
stopped at render.py."""
|
|
495
|
+
args: List[str] = []
|
|
496
|
+
if STATE.fast:
|
|
497
|
+
args.append("--fast")
|
|
498
|
+
if STATE.dry_run:
|
|
499
|
+
args.append("--dry-run")
|
|
500
|
+
if STATE.overwrite:
|
|
501
|
+
args.append("--overwrite")
|
|
502
|
+
args += ["--timeout", f"{STATE.timeout:g}"]
|
|
503
|
+
return args
|
|
504
|
+
|
|
505
|
+
|
|
464
506
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
465
507
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
466
508
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -1045,7 +1087,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
1045
1087
|
ffmpeg = require_tool("ffmpeg")
|
|
1046
1088
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
1047
1089
|
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
1048
|
-
proc =
|
|
1090
|
+
proc = run_analysis(cmd, check=False)
|
|
1049
1091
|
vals: Dict[str, List[float]] = {}
|
|
1050
1092
|
for line in proc.stdout.splitlines():
|
|
1051
1093
|
if "lavfi.signalstats." in line and "=" in line:
|
package/scripts/_contract.py
CHANGED
|
@@ -1037,7 +1037,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1037
1037
|
"json_output": {
|
|
1038
1038
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
1039
1039
|
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
|
|
1040
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124"},
|
|
1040
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1"},
|
|
1041
1041
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1042
1042
|
},
|
|
1043
1043
|
"capabilities": caps,
|
package/scripts/batch.py
CHANGED
|
@@ -32,7 +32,7 @@ import time
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Any, Dict, List
|
|
34
34
|
|
|
35
|
-
from _common import STATE, add_common, apply_common, die, emit, info
|
|
35
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info
|
|
36
36
|
|
|
37
37
|
HERE = Path(__file__).resolve().parent
|
|
38
38
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
@@ -78,11 +78,7 @@ def run_step(argv: List[str]) -> bool:
|
|
|
78
78
|
if script not in ALLOWED_STEP_SCRIPTS:
|
|
79
79
|
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
80
80
|
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
81
|
-
cmd = [sys.executable, str(HERE / script)] + argv[1:]
|
|
82
|
-
if STATE["fast"]:
|
|
83
|
-
cmd.append("--fast")
|
|
84
|
-
if STATE["dry_run"]:
|
|
85
|
-
cmd.append("--dry-run")
|
|
81
|
+
cmd = [sys.executable, str(HERE / script)] + argv[1:] + child_args()
|
|
86
82
|
info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
87
83
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
88
84
|
if proc.returncode != 0:
|
|
@@ -219,11 +215,15 @@ def main() -> int:
|
|
|
219
215
|
pass
|
|
220
216
|
done = sum(1 for r in results if r["ok"])
|
|
221
217
|
info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
|
|
222
|
-
emit(None, results=results, processed=done, total=len(results))
|
|
223
218
|
if not args.json:
|
|
224
219
|
for r in results:
|
|
225
220
|
print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
|
|
226
|
-
|
|
221
|
+
if done != len(results):
|
|
222
|
+
failed_files = [r["file"] for r in results if not r["ok"]]
|
|
223
|
+
die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
|
|
224
|
+
kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
|
|
225
|
+
emit(None, results=results, processed=done, total=len(results))
|
|
226
|
+
return 0
|
|
227
227
|
|
|
228
228
|
|
|
229
229
|
if __name__ == "__main__":
|
package/scripts/caption.py
CHANGED
|
@@ -87,12 +87,36 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
|
|
|
87
87
|
import shutil
|
|
88
88
|
import subprocess
|
|
89
89
|
import tempfile
|
|
90
|
-
from _common import require_tool
|
|
90
|
+
from _common import require_tool, run_analysis, STATE
|
|
91
91
|
ffmpeg = require_tool("ffmpeg")
|
|
92
92
|
tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
|
|
93
|
+
try:
|
|
94
|
+
return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
|
|
95
|
+
finally:
|
|
96
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
|
|
100
|
+
"""Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
|
|
101
|
+
from _common import STATE, die
|
|
102
|
+
limit = STATE.timeout or None
|
|
103
|
+
try:
|
|
104
|
+
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
|
|
107
|
+
code=124, kind="timeout")
|
|
108
|
+
return None # unreachable
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
|
|
112
|
+
ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
|
|
113
|
+
from _common import run_analysis
|
|
93
114
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
# A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
|
|
116
|
+
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
117
|
+
# reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
|
|
118
|
+
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
119
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
96
120
|
# 1. whisper.cpp
|
|
97
121
|
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
|
|
98
122
|
if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
|
|
@@ -106,7 +130,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
|
|
|
106
130
|
cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
|
|
107
131
|
if language:
|
|
108
132
|
cmd += ["-l", language]
|
|
109
|
-
proc =
|
|
133
|
+
proc = _asr_run(cmd, subprocess, "whisper.cpp")
|
|
110
134
|
if proc.returncode == 0 and os.path.exists(base + ".srt"):
|
|
111
135
|
info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
|
|
112
136
|
cues = parse_srt(base + ".srt")
|
|
@@ -130,7 +154,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
|
|
|
130
154
|
cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
|
|
131
155
|
if language:
|
|
132
156
|
cmd += ["--language", language]
|
|
133
|
-
proc =
|
|
157
|
+
proc = _asr_run(cmd, subprocess, "openai-whisper")
|
|
134
158
|
srt = os.path.join(tmpdir, "audio.srt")
|
|
135
159
|
if proc.returncode == 0 and os.path.exists(srt):
|
|
136
160
|
info("transcribed with openai-whisper")
|
package/scripts/check.py
CHANGED
|
@@ -25,7 +25,7 @@ import sys
|
|
|
25
25
|
from fractions import Fraction
|
|
26
26
|
from typing import Any, Dict, List
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, die, emit, info, probe, require_tool, run
|
|
28
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run
|
|
29
29
|
|
|
30
30
|
SPECS: Dict[str, Dict[str, Any]] = {
|
|
31
31
|
"youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
|
|
@@ -190,8 +190,12 @@ def main() -> int:
|
|
|
190
190
|
line += f" -> {r['fix']}"
|
|
191
191
|
print(line)
|
|
192
192
|
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
if failed:
|
|
194
|
+
die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
|
|
195
|
+
kind="verification", output=None, dry_run=STATE.dry_run,
|
|
196
|
+
platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
|
|
197
|
+
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
|
|
198
|
+
return 0
|
|
195
199
|
|
|
196
200
|
|
|
197
201
|
if __name__ == "__main__":
|
package/scripts/cropdetect.py
CHANGED
|
@@ -29,12 +29,11 @@ Examples:
|
|
|
29
29
|
"""
|
|
30
30
|
import argparse
|
|
31
31
|
import re
|
|
32
|
-
import subprocess
|
|
33
32
|
import sys
|
|
34
33
|
from collections import Counter
|
|
35
34
|
from typing import Dict, List, Tuple
|
|
36
35
|
|
|
37
|
-
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool
|
|
36
|
+
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis
|
|
38
37
|
|
|
39
38
|
CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
40
39
|
|
|
@@ -48,7 +47,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
48
47
|
start = max(0.0, start)
|
|
49
48
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
50
49
|
"-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
51
|
-
proc =
|
|
50
|
+
proc = run_analysis(cmd)
|
|
52
51
|
for m in CROP_RE.finditer(proc.stderr):
|
|
53
52
|
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
54
53
|
return rects
|
package/scripts/render.py
CHANGED
|
@@ -56,7 +56,7 @@ import sys
|
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
from typing import Any, Dict, List
|
|
58
58
|
|
|
59
|
-
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
59
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe
|
|
60
60
|
|
|
61
61
|
HERE = Path(__file__).resolve().parent
|
|
62
62
|
|
|
@@ -80,11 +80,7 @@ TEMPLATE = {
|
|
|
80
80
|
|
|
81
81
|
def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
82
82
|
"""Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
|
|
83
|
-
cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or [])
|
|
84
|
-
if STATE["fast"]:
|
|
85
|
-
cmd.append("--fast")
|
|
86
|
-
if STATE["dry_run"]:
|
|
87
|
-
cmd.append("--dry-run")
|
|
83
|
+
cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args()
|
|
88
84
|
info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
89
85
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
90
86
|
for line in proc.stderr.splitlines():
|
|
@@ -360,12 +356,12 @@ def main() -> int:
|
|
|
360
356
|
check_result = json.loads(proc.stdout)
|
|
361
357
|
except ValueError:
|
|
362
358
|
check_result = {"error": proc.stderr.strip()[-300:]}
|
|
363
|
-
if check_result.get("
|
|
364
|
-
info(f"check: could not run check.py — {check_result['error']}")
|
|
365
|
-
exit_code = 1
|
|
366
|
-
elif check_result.get("failed"):
|
|
359
|
+
if check_result.get("failed"):
|
|
367
360
|
info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
|
|
368
361
|
exit_code = 1
|
|
362
|
+
elif check_result.get("error") or check_result.get("status") == "failed":
|
|
363
|
+
info(f"check: could not run check.py — {check_result.get('error')}")
|
|
364
|
+
exit_code = 1
|
|
369
365
|
else:
|
|
370
366
|
info(f"check: OK for {ck['platform']}")
|
|
371
367
|
stages_done.append("check")
|
|
@@ -378,9 +374,15 @@ def main() -> int:
|
|
|
378
374
|
# to before the PID suffix was added.
|
|
379
375
|
import shutil
|
|
380
376
|
shutil.rmtree(work, ignore_errors=True)
|
|
377
|
+
if exit_code:
|
|
378
|
+
# The deliverable is written and verified, but it does not meet the requested platform
|
|
379
|
+
# spec (or the check itself could not run): a failed delivery, reported as one.
|
|
380
|
+
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
381
|
+
die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
|
|
382
|
+
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
|
|
381
383
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
382
384
|
emit(output, stages=stages_done, check=check_result)
|
|
383
|
-
return
|
|
385
|
+
return 0
|
|
384
386
|
|
|
385
387
|
|
|
386
388
|
if __name__ == "__main__":
|
package/scripts/scenes.py
CHANGED
|
@@ -22,11 +22,10 @@ import math
|
|
|
22
22
|
import os
|
|
23
23
|
import re
|
|
24
24
|
import struct
|
|
25
|
-
import subprocess
|
|
26
25
|
import sys
|
|
27
26
|
from typing import Dict, List, Tuple
|
|
28
27
|
|
|
29
|
-
from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
28
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis
|
|
30
29
|
|
|
31
30
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
32
31
|
|
|
@@ -38,9 +37,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
38
37
|
a real cut is a one-frame spike. On real footage this roughly doubles precision at
|
|
39
38
|
equal recall compared with the raw scdet threshold."""
|
|
40
39
|
ffmpeg = require_tool("ffmpeg")
|
|
41
|
-
proc =
|
|
42
|
-
|
|
43
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
40
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
41
|
+
"scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
|
|
44
42
|
# No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
|
|
45
43
|
# score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
|
|
46
44
|
# card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
|
|
@@ -90,8 +88,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
90
88
|
|
|
91
89
|
def audio_envelope(path: str, step_s: float) -> List[float]:
|
|
92
90
|
ffmpeg = require_tool("ffmpeg")
|
|
93
|
-
proc =
|
|
94
|
-
|
|
91
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
|
|
92
|
+
check=False, text=False)
|
|
95
93
|
n = len(proc.stdout) // 2
|
|
96
94
|
if n == 0:
|
|
97
95
|
return []
|
package/scripts/sync.py
CHANGED
|
@@ -30,11 +30,10 @@ import json
|
|
|
30
30
|
import math
|
|
31
31
|
import os
|
|
32
32
|
import struct
|
|
33
|
-
import subprocess
|
|
34
33
|
import sys
|
|
35
34
|
from typing import List
|
|
36
35
|
|
|
37
|
-
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
36
|
+
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args
|
|
38
37
|
|
|
39
38
|
SR = 8000 # decode sample rate
|
|
40
39
|
|
|
@@ -59,7 +58,7 @@ def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
|
59
58
|
ffmpeg = require_tool("ffmpeg")
|
|
60
59
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
|
|
61
60
|
"-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
|
|
62
|
-
proc =
|
|
61
|
+
proc = run_analysis(cmd, check=False, text=False)
|
|
63
62
|
if proc.returncode != 0 or not proc.stdout:
|
|
64
63
|
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
|
|
65
64
|
n = len(proc.stdout) // 2
|
package/scripts/verify.py
CHANGED
|
@@ -24,7 +24,7 @@ import time
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import Dict, List
|
|
26
26
|
|
|
27
|
-
from _common import add_common, apply_common, die, emit, info, probe
|
|
27
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
28
28
|
|
|
29
29
|
HERE = Path(__file__).resolve().parent
|
|
30
30
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
|
|
@@ -176,13 +176,16 @@ def main() -> int:
|
|
|
176
176
|
if args.report:
|
|
177
177
|
Path(args.report).write_text(report, encoding="utf-8")
|
|
178
178
|
info(f"wrote {args.report}")
|
|
179
|
-
if args.json:
|
|
180
|
-
emit(None, report=args.report, files=results, failed=failed, total=total)
|
|
181
|
-
else:
|
|
179
|
+
if not args.json:
|
|
182
180
|
print(report)
|
|
183
181
|
if tmp and not args.keep:
|
|
184
182
|
tmp.cleanup()
|
|
185
|
-
|
|
183
|
+
if failed:
|
|
184
|
+
die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
|
|
185
|
+
report=args.report, files=results, failed=failed, total=total)
|
|
186
|
+
if args.json:
|
|
187
|
+
emit(None, report=args.report, files=results, failed=failed, total=total)
|
|
188
|
+
return 0
|
|
186
189
|
|
|
187
190
|
|
|
188
191
|
if __name__ == "__main__":
|