ffmpeg-skill 1.4.2 → 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/references/process-pitfalls.md +37 -0
- package/scripts/_common.py +165 -29
- 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",
|
|
@@ -190,3 +190,40 @@ that merge -- no tests, no CodeQL, no release -- and the release only happened w
|
|
|
190
190
|
merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
|
|
191
191
|
or wrap it so it does not match, and after any merge that touches CI check that the push
|
|
192
192
|
actually triggered the expected runs.
|
|
193
|
+
|
|
194
|
+
### Two merges minutes apart: the first release run bumps on a stale main and its push is rejected
|
|
195
|
+
|
|
196
|
+
Found on 1.4.2 (2026-09-11). #167 (fix) merged, then #171 (docs) a minute later while the
|
|
197
|
+
release run for #167 was still bumping. The concurrency group serialises the runs, but a run
|
|
198
|
+
checks out the SHA that triggered it, so the first run's bump commit sat behind #171's merge
|
|
199
|
+
and `git push origin HEAD:main` was rejected as non-fast-forward. The second run (for #171)
|
|
200
|
+
then found both PRs unreleased and published 1.4.2 correctly, so nothing was lost -- one red
|
|
201
|
+
run and a confusing timeline. release.yml now checks out `ref: main` and rebases the bump on
|
|
202
|
+
main right before pushing. Lesson: a workflow that pushes to the branch that triggered it must
|
|
203
|
+
start from the branch tip, not from the triggering commit.
|
|
204
|
+
|
|
205
|
+
### "Clean up the partial output on failure" deleted the user's file
|
|
206
|
+
|
|
207
|
+
Found by the 1.4.2 review (2026-09-12), present since #78 (2026-09-07). The cleanup that removes a
|
|
208
|
+
0-byte stray after a failed encode keyed on "output path exists after failure", which is also
|
|
209
|
+
true of a deliverable that was there before the run and that ffmpeg never opened (a bad filter
|
|
210
|
+
argument fails at graph init, before the muxer touches the output -- on 6.1+). The --overwrite
|
|
211
|
+
consent added in #163 guards the success path only; the failure path had its own delete. The
|
|
212
|
+
first fix (snapshot size/mtime, leave an unchanged file alone) passed on 6.1 and failed in the
|
|
213
|
+
5.1.1 CI job: FFmpeg 5.x opens (truncates) the output during option parsing, before any filter
|
|
214
|
+
initialises, so ffmpeg itself had already destroyed the file. The fix that holds on every
|
|
215
|
+
version is to never let ffmpeg write to an existing path: run against a hidden sibling temp
|
|
216
|
+
file and os.replace() it over the original on success only. Lessons: a destructive step must
|
|
217
|
+
know whether it created the thing it is about to destroy, "exists" is not that knowledge; and
|
|
218
|
+
"the tool fails before touching the file" is a version-specific fact, never a guarantee. And the second review found what the first one -- which had
|
|
219
|
+
just written the overwrite guard next to this code -- did not: a reviewer who wrote the fix
|
|
220
|
+
reads the file they fixed, not the one beside it.
|
|
221
|
+
|
|
222
|
+
### --timeout only worked when ffmpeg was talking
|
|
223
|
+
|
|
224
|
+
Same review. The --progress runner iterated the progress pipe and compared the clock per
|
|
225
|
+
line, so the one case the timeout exists for (a deadlocked ffmpeg, which prints nothing)
|
|
226
|
+
never reached the comparison. The non-progress path used subprocess.run(timeout=) and was
|
|
227
|
+
fine, and the test only exercised that path. Lesson: a deadline belongs on a clock the loop
|
|
228
|
+
wakes up to check, never on the arrival of the thing you are waiting for; and a test for
|
|
229
|
+
"hang" must use a shim that actually hangs silently, not one that fails fast.
|
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
|
|
|
@@ -199,8 +208,8 @@ class Context:
|
|
|
199
208
|
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
200
209
|
"""
|
|
201
210
|
|
|
202
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
203
|
-
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
211
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
212
|
+
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
204
213
|
|
|
205
214
|
def __init__(self) -> None:
|
|
206
215
|
self.reset()
|
|
@@ -215,6 +224,7 @@ class Context:
|
|
|
215
224
|
self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
|
|
216
225
|
self.overwrite = False # --overwrite: an existing output may be replaced
|
|
217
226
|
self.written: set = set() # output paths this process has written itself
|
|
227
|
+
self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
|
|
218
228
|
|
|
219
229
|
# mapping-style access kept for backwards compatibility
|
|
220
230
|
def __getitem__(self, key: str) -> Any:
|
|
@@ -300,8 +310,19 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
300
310
|
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
301
311
|
return
|
|
302
312
|
try:
|
|
303
|
-
if os.path.exists(output):
|
|
304
|
-
|
|
313
|
+
if not os.path.exists(output):
|
|
314
|
+
return
|
|
315
|
+
# A file that was already there before this command ran is someone's deliverable, not
|
|
316
|
+
# our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
|
|
317
|
+
# the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
|
|
318
|
+
# did open and truncate it (size or mtime changed) is what remains a partial of ours,
|
|
319
|
+
# and the original is already gone either way; then removing it is still right.
|
|
320
|
+
before = STATE.preexisting.get(os.path.realpath(output))
|
|
321
|
+
if before is not None:
|
|
322
|
+
st = os.stat(output)
|
|
323
|
+
if (st.st_size, st.st_mtime_ns) == before:
|
|
324
|
+
return
|
|
325
|
+
os.remove(output)
|
|
305
326
|
except OSError:
|
|
306
327
|
pass
|
|
307
328
|
|
|
@@ -348,7 +369,7 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
348
369
|
2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
|
|
349
370
|
process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
|
|
350
371
|
output = cmd[-1]
|
|
351
|
-
if
|
|
372
|
+
if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
|
|
352
373
|
return
|
|
353
374
|
try:
|
|
354
375
|
exists = os.path.isfile(output)
|
|
@@ -357,6 +378,13 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
357
378
|
return
|
|
358
379
|
if not exists or real in STATE.written:
|
|
359
380
|
return
|
|
381
|
+
try:
|
|
382
|
+
st = os.stat(output)
|
|
383
|
+
STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
|
|
384
|
+
except OSError:
|
|
385
|
+
pass
|
|
386
|
+
if STATE.overwrite:
|
|
387
|
+
return
|
|
360
388
|
if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
|
|
361
389
|
die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
|
|
362
390
|
info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
|
|
@@ -380,12 +408,40 @@ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
|
380
408
|
code=124, kind="timeout")
|
|
381
409
|
|
|
382
410
|
|
|
411
|
+
def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
|
|
412
|
+
"""When the output path already holds someone's file, run ffmpeg against a hidden sibling
|
|
413
|
+
temp path and move it over the original only on success.
|
|
414
|
+
|
|
415
|
+
ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
|
|
416
|
+
the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
|
|
417
|
+
touched), 5.x opens the output during option parsing, before any filter runs, so the same
|
|
418
|
+
bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
|
|
419
|
+
can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
|
|
420
|
+
never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
|
|
421
|
+
name, so nothing else changes for the encoder. Returns (command to execute, final path,
|
|
422
|
+
temp path); (cmd, None, None) when no staging is needed."""
|
|
423
|
+
output = cmd[-1]
|
|
424
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
425
|
+
return list(cmd), None, None
|
|
426
|
+
try:
|
|
427
|
+
if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
|
|
428
|
+
return list(cmd), None, None
|
|
429
|
+
except OSError:
|
|
430
|
+
return list(cmd), None, None
|
|
431
|
+
d, base = os.path.split(output)
|
|
432
|
+
stem, ext = os.path.splitext(base)
|
|
433
|
+
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
434
|
+
return list(cmd[:-1]) + [tmp], output, tmp
|
|
435
|
+
|
|
436
|
+
|
|
383
437
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
384
438
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
385
439
|
|
|
386
440
|
ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
|
|
387
441
|
(a fake successful CompletedProcess is returned so scripts can keep planning), and run
|
|
388
|
-
with a progress readout under --progress. ffprobe and other tools always run.
|
|
442
|
+
with a progress readout under --progress. ffprobe and other tools always run. An output
|
|
443
|
+
path that already exists is written through a temp file and replaced only on success
|
|
444
|
+
(see _stage_existing_output), so a failed run never costs the caller the file that was there.
|
|
389
445
|
"""
|
|
390
446
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
391
447
|
if is_ffmpeg:
|
|
@@ -396,9 +452,55 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
396
452
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
397
453
|
if STATE.dry_run and is_ffmpeg:
|
|
398
454
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
455
|
+
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
456
|
+
if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
|
|
457
|
+
proc = _run_with_progress(exec_cmd, check)
|
|
458
|
+
else:
|
|
459
|
+
proc = _run_captured(exec_cmd, check)
|
|
460
|
+
if final and tmp:
|
|
461
|
+
if proc.returncode == 0:
|
|
462
|
+
try:
|
|
463
|
+
os.replace(tmp, final)
|
|
464
|
+
except OSError as e:
|
|
465
|
+
_cleanup_partial_output(exec_cmd)
|
|
466
|
+
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
467
|
+
_remember_output(cmd)
|
|
468
|
+
else:
|
|
469
|
+
_cleanup_partial_output(exec_cmd)
|
|
470
|
+
return proc
|
|
471
|
+
|
|
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
|
|
402
504
|
|
|
403
505
|
|
|
404
506
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
@@ -455,22 +557,57 @@ def _progress_line(done: float, total: float, elapsed: float) -> str:
|
|
|
455
557
|
|
|
456
558
|
|
|
457
559
|
def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
458
|
-
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
|
|
560
|
+
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
|
|
561
|
+
|
|
562
|
+
The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
|
|
563
|
+
case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
|
|
564
|
+
line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
|
|
565
|
+
least twice a second to compare the clock against the limit."""
|
|
566
|
+
import queue
|
|
567
|
+
import threading
|
|
459
568
|
import time
|
|
460
569
|
total = STATE.duration_hint or 0.0
|
|
461
570
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
462
571
|
t0 = time.time()
|
|
463
572
|
limit = _limit_for(cmd)
|
|
464
573
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
574
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
575
|
+
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|
|
576
|
+
err_chunks: List[str] = []
|
|
577
|
+
|
|
578
|
+
def pump_out() -> None:
|
|
579
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
580
|
+
lines.put(line)
|
|
581
|
+
lines.put(None)
|
|
582
|
+
|
|
583
|
+
def pump_err() -> None:
|
|
584
|
+
err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
|
|
585
|
+
|
|
586
|
+
threading.Thread(target=pump_out, daemon=True).start()
|
|
587
|
+
err_thread = threading.Thread(target=pump_err, daemon=True)
|
|
588
|
+
err_thread.start()
|
|
465
589
|
last = ""
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
590
|
+
|
|
591
|
+
def clear_line() -> None:
|
|
592
|
+
if last:
|
|
593
|
+
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
594
|
+
|
|
595
|
+
def timed_out() -> None:
|
|
596
|
+
proc.kill()
|
|
597
|
+
proc.wait()
|
|
598
|
+
clear_line()
|
|
599
|
+
_timed_out(cmd, limit or 0)
|
|
600
|
+
|
|
601
|
+
while True:
|
|
602
|
+
remaining = (limit - (time.time() - t0)) if limit else None
|
|
603
|
+
if remaining is not None and remaining <= 0:
|
|
604
|
+
timed_out()
|
|
605
|
+
try:
|
|
606
|
+
line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
|
|
607
|
+
except queue.Empty:
|
|
608
|
+
continue
|
|
609
|
+
if line is None:
|
|
610
|
+
break
|
|
474
611
|
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
|
|
475
612
|
try:
|
|
476
613
|
done = int(line.split("=")[1]) / 1_000_000
|
|
@@ -482,13 +619,12 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
482
619
|
sys.stderr.flush()
|
|
483
620
|
last = msg
|
|
484
621
|
try:
|
|
485
|
-
|
|
622
|
+
proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
|
|
486
623
|
except subprocess.TimeoutExpired:
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
624
|
+
timed_out()
|
|
625
|
+
err_thread.join()
|
|
626
|
+
err = "".join(err_chunks)
|
|
627
|
+
clear_line()
|
|
492
628
|
if proc.returncode == 0:
|
|
493
629
|
_remember_output(cmd)
|
|
494
630
|
if proc.returncode != 0:
|
|
@@ -951,7 +1087,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
951
1087
|
ffmpeg = require_tool("ffmpeg")
|
|
952
1088
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
953
1089
|
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
954
|
-
proc =
|
|
1090
|
+
proc = run_analysis(cmd, check=False)
|
|
955
1091
|
vals: Dict[str, List[float]] = {}
|
|
956
1092
|
for line in proc.stdout.splitlines():
|
|
957
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__":
|