ffmpeg-skill 1.4.6 → 1.4.7
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/package.json +1 -1
- package/references/scripts.md +1 -1
- package/scripts/_common.py +25 -6
- package/scripts/_contract.py +4 -1
- package/scripts/batch.py +5 -2
- package/scripts/caption.py +27 -29
- package/scripts/check.py +4 -2
- package/scripts/cropdetect.py +9 -1
- package/scripts/loudness.py +10 -4
- package/scripts/render.py +3 -2
- package/scripts/report.py +4 -4
- package/scripts/silence.py +6 -3
- package/scripts/stabilize.py +14 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
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/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing)
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
package/scripts/_common.py
CHANGED
|
@@ -462,12 +462,17 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
462
462
|
return proc
|
|
463
463
|
|
|
464
464
|
|
|
465
|
-
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
|
|
466
|
-
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats
|
|
467
|
-
output to `-f null
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
465
|
+
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
|
|
466
|
+
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
|
|
467
|
+
silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
|
|
468
|
+
file, no deliverable written. These are not run() calls -- they run under --dry-run too,
|
|
469
|
+
since a plan built on a fake measurement is not a plan (silence.py used to report "0
|
|
470
|
+
silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
|
|
471
|
+
wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
|
|
472
|
+
failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
|
|
473
|
+
lists the command in the --json `commands` like run() does."""
|
|
474
|
+
if record:
|
|
475
|
+
STATE.commands.append(_cmdline(cmd))
|
|
471
476
|
limit = _limit_for(cmd)
|
|
472
477
|
try:
|
|
473
478
|
proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
|
|
@@ -479,6 +484,17 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
|
|
|
479
484
|
return proc
|
|
480
485
|
|
|
481
486
|
|
|
487
|
+
def dry_run_input_pending(path: str) -> bool:
|
|
488
|
+
"""True when a measurement cannot run because its input does not exist yet under --dry-run:
|
|
489
|
+
in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
|
|
490
|
+
run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
|
|
491
|
+
on a real file the measurement runs even under --dry-run."""
|
|
492
|
+
if STATE.dry_run and not os.path.exists(path):
|
|
493
|
+
info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
|
|
494
|
+
return True
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
|
|
482
498
|
def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
483
499
|
"""Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
|
|
484
500
|
stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
|
|
@@ -1142,6 +1158,9 @@ def db_to_linear(db: float) -> float:
|
|
|
1142
1158
|
def read_text_or_die(path: str, flag: str) -> str:
|
|
1143
1159
|
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
1144
1160
|
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
|
|
1161
|
+
if os.path.isdir(path):
|
|
1162
|
+
# checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
|
|
1163
|
+
die(f"{flag}: {path} is a directory, not a text file")
|
|
1145
1164
|
try:
|
|
1146
1165
|
with open(path, "r", encoding="utf-8") as fh:
|
|
1147
1166
|
return fh.read()
|
package/scripts/_contract.py
CHANGED
|
@@ -215,10 +215,13 @@ DRY_RUN_ANALYSIS = {
|
|
|
215
215
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
216
216
|
"report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
|
|
217
217
|
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
218
|
+
"silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
|
|
219
|
+
"loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
|
|
220
|
+
"check": "read-only tool; the loudness measurement runs under --dry-run too, so every row is present",
|
|
221
|
+
"stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
|
|
218
222
|
}
|
|
219
223
|
DRY_RUN_NOTES = {
|
|
220
224
|
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
221
|
-
"check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
|
|
222
225
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
223
226
|
}
|
|
224
227
|
|
package/scripts/batch.py
CHANGED
|
@@ -31,7 +31,7 @@ import time
|
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
from typing import Any, Dict, List
|
|
33
33
|
|
|
34
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool
|
|
34
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die
|
|
35
35
|
|
|
36
36
|
HERE = Path(__file__).resolve().parent
|
|
37
37
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
@@ -102,7 +102,10 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
|
|
|
102
102
|
final = final_path(src, recipe, outdir)
|
|
103
103
|
t0 = time.time()
|
|
104
104
|
if recipe.get("project"):
|
|
105
|
-
|
|
105
|
+
try:
|
|
106
|
+
proj = json.loads(read_text_or_die(str(recipe["project"]), "recipe.project"))
|
|
107
|
+
except ValueError as e:
|
|
108
|
+
die(f"recipe.project: {recipe['project']} is not valid JSON: {e}")
|
|
106
109
|
idx = int(recipe.get("clip_key", 0))
|
|
107
110
|
proj.setdefault("clips", [{}])
|
|
108
111
|
while len(proj["clips"]) <= idx:
|
package/scripts/caption.py
CHANGED
|
@@ -36,7 +36,7 @@ import sys
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import List, Optional, Tuple
|
|
38
38
|
|
|
39
|
-
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS
|
|
39
|
+
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die
|
|
40
40
|
|
|
41
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
42
42
|
|
|
@@ -48,33 +48,32 @@ TIME_RE = re.compile(
|
|
|
48
48
|
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
49
49
|
cues: List[Tuple[float, float, str]] = []
|
|
50
50
|
cursor = 0.0
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
|
|
69
|
-
else:
|
|
70
|
-
text = m.group("text").strip()
|
|
51
|
+
for raw in read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True):
|
|
52
|
+
line = raw.rstrip("\n")
|
|
53
|
+
if not line.strip():
|
|
54
|
+
continue
|
|
55
|
+
m = TIME_RE.match(line)
|
|
56
|
+
if m:
|
|
57
|
+
try:
|
|
58
|
+
start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
|
|
59
|
+
except MissingFpsError as e:
|
|
60
|
+
die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
|
|
61
|
+
except ValueError:
|
|
62
|
+
# TIME_RE matched (so m.group("text") is the real cue text, not the broken
|
|
63
|
+
# timestamp), but one of the two timestamps itself failed to parse (e.g. a
|
|
64
|
+
# malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
|
|
65
|
+
# burn the whole raw line, broken timestamp included, into the caption instead
|
|
66
|
+
# of just the text after it.
|
|
67
|
+
start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
|
|
71
68
|
else:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
text = m.group("text").strip()
|
|
70
|
+
else:
|
|
71
|
+
start, end, text = cursor, cursor + auto_seconds, line.strip()
|
|
72
|
+
if end <= start:
|
|
73
|
+
die(f"cue '{line}': end must be after start")
|
|
74
|
+
text = text.replace(" | ", "\n").replace("|", "\n")
|
|
75
|
+
cues.append((start, end, text))
|
|
76
|
+
cursor = end + gap
|
|
78
77
|
if not cues:
|
|
79
78
|
die(f"no cues found in {path}")
|
|
80
79
|
return cues
|
|
@@ -173,8 +172,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
173
172
|
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
174
173
|
cues: List[Tuple[float, float, str]] = []
|
|
175
174
|
block: List[str] = []
|
|
176
|
-
|
|
177
|
-
content = fh.read().replace("\r\n", "\n") + "\n\n"
|
|
175
|
+
content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
|
|
178
176
|
for line in content.split("\n"):
|
|
179
177
|
if line.strip():
|
|
180
178
|
block.append(line)
|
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 STATE, 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, run_analysis, dry_run_input_pending
|
|
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},
|
|
@@ -41,8 +41,10 @@ SPECS: Dict[str, Dict[str, Any]] = {
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def measure_loudness(path: str) -> Dict[str, float]:
|
|
44
|
+
if dry_run_input_pending(path):
|
|
45
|
+
return {}
|
|
44
46
|
ffmpeg = require_tool("ffmpeg")
|
|
45
|
-
proc =
|
|
47
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
|
|
46
48
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
47
49
|
if not m:
|
|
48
50
|
return {}
|
package/scripts/cropdetect.py
CHANGED
|
@@ -42,14 +42,22 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
42
42
|
ffmpeg = require_tool("ffmpeg")
|
|
43
43
|
per_window = max(0.5, seconds / max(1, samples))
|
|
44
44
|
rects: List[Tuple[int, int, int, int]] = []
|
|
45
|
+
failures: List[List[str]] = []
|
|
45
46
|
for i in range(samples):
|
|
46
47
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
47
48
|
start = max(0.0, start)
|
|
48
49
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
49
50
|
"-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
50
|
-
proc = run_analysis(cmd)
|
|
51
|
+
proc = run_analysis(cmd, check=False)
|
|
52
|
+
if proc.returncode != 0:
|
|
53
|
+
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
54
|
+
# still measure. Only when every window fails is there nothing to report.
|
|
55
|
+
failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
|
|
56
|
+
continue
|
|
51
57
|
for m in CROP_RE.finditer(proc.stderr):
|
|
52
58
|
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
59
|
+
if failures and len(failures) == samples:
|
|
60
|
+
die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
|
|
53
61
|
return rects
|
|
54
62
|
|
|
55
63
|
|
package/scripts/loudness.py
CHANGED
|
@@ -18,16 +18,18 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
26
|
-
if
|
|
27
|
-
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
|
|
26
|
+
if dry_run_input_pending(path):
|
|
27
|
+
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
|
|
28
28
|
ffmpeg = require_tool("ffmpeg")
|
|
29
29
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
30
|
-
|
|
30
|
+
# Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
|
|
31
|
+
# the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
|
|
32
|
+
proc = run_analysis(cmd, check=False, record=True)
|
|
31
33
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
32
34
|
if proc.returncode != 0 or not m:
|
|
33
35
|
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
@@ -86,6 +88,10 @@ def main() -> int:
|
|
|
86
88
|
cmd.append(output)
|
|
87
89
|
run(cmd)
|
|
88
90
|
|
|
91
|
+
if STATE.dry_run:
|
|
92
|
+
# pass 1 measured the input for real; there is no output to measure
|
|
93
|
+
emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
|
|
94
|
+
return 0
|
|
89
95
|
after = measure(output, args.lufs, args.tp, args.lra)
|
|
90
96
|
if not after.get("silent"):
|
|
91
97
|
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
package/scripts/render.py
CHANGED
|
@@ -358,7 +358,7 @@ def main() -> int:
|
|
|
358
358
|
check_result = None
|
|
359
359
|
exit_code = 0
|
|
360
360
|
if ck and ck.get("platform") and not STATE.dry_run:
|
|
361
|
-
proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"])
|
|
361
|
+
proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"] + child_args())
|
|
362
362
|
try:
|
|
363
363
|
check_result = json.loads(proc.stdout)
|
|
364
364
|
except ValueError:
|
|
@@ -386,7 +386,8 @@ def main() -> int:
|
|
|
386
386
|
# spec (or the check itself could not run): a failed delivery, reported as one.
|
|
387
387
|
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
388
388
|
die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
|
|
389
|
-
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result
|
|
389
|
+
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
|
|
390
|
+
probe=probe(output, role="output"))
|
|
390
391
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
391
392
|
emit(output, stages=stages_done, check=check_result)
|
|
392
393
|
return 0
|
package/scripts/report.py
CHANGED
|
@@ -18,7 +18,7 @@ import tempfile
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Dict, List, Optional
|
|
20
20
|
|
|
21
|
-
from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die, run_tool
|
|
21
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
|
|
22
22
|
|
|
23
23
|
HERE = Path(__file__).resolve().parent
|
|
24
24
|
|
|
@@ -26,14 +26,14 @@ HERE = Path(__file__).resolve().parent
|
|
|
26
26
|
def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
|
|
27
27
|
with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
|
|
28
28
|
png = os.path.join(tmp, "sheet.png")
|
|
29
|
-
proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png])
|
|
29
|
+
proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
|
|
30
30
|
if proc.returncode != 0 or not os.path.exists(png):
|
|
31
31
|
return None
|
|
32
32
|
return base64.b64encode(Path(png).read_bytes()).decode("ascii")
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def loudness(path: str) -> Dict[str, Any]:
|
|
36
|
-
proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"])
|
|
36
|
+
proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
|
|
37
37
|
try:
|
|
38
38
|
d = json.loads(proc.stdout)
|
|
39
39
|
return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
|
|
@@ -42,7 +42,7 @@ def loudness(path: str) -> Dict[str, Any]:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
|
|
45
|
-
proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
|
|
45
|
+
proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
|
|
46
46
|
try:
|
|
47
47
|
doc = json.loads(proc.stdout)
|
|
48
48
|
except ValueError:
|
package/scripts/silence.py
CHANGED
|
@@ -12,20 +12,23 @@ Examples:
|
|
|
12
12
|
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
13
13
|
"""
|
|
14
14
|
import argparse
|
|
15
|
+
import os
|
|
15
16
|
import re
|
|
16
17
|
import sys
|
|
17
18
|
from typing import List, Tuple
|
|
18
19
|
|
|
19
|
-
from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs
|
|
20
|
+
from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending
|
|
20
21
|
|
|
21
22
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
|
|
26
|
+
if dry_run_input_pending(path):
|
|
27
|
+
return []
|
|
25
28
|
ffmpeg = require_tool("ffmpeg")
|
|
26
29
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
27
30
|
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
28
|
-
proc =
|
|
31
|
+
proc = run_analysis(cmd, check=False, record=True)
|
|
29
32
|
if proc.returncode != 0:
|
|
30
33
|
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
|
|
31
34
|
silences: List[Tuple[float, float]] = []
|
|
@@ -86,7 +89,7 @@ def main() -> int:
|
|
|
86
89
|
"removed_seconds": round(removed, 3),
|
|
87
90
|
}
|
|
88
91
|
info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
|
|
89
|
-
if not silences and not STATE.dry_run:
|
|
92
|
+
if not silences and not (STATE.dry_run and not os.path.exists(args.input)):
|
|
90
93
|
# Nothing under the threshold is a valid result, not a failure -- but an agent that only
|
|
91
94
|
# sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
|
|
92
95
|
# what threshold would bite, so the retry is a flag change, not a workaround.
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,7 +27,7 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS
|
|
30
|
+
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -62,18 +62,20 @@ def main() -> int:
|
|
|
62
62
|
trf = str(Path(tmp) / "transforms.trf")
|
|
63
63
|
trf_arg = escape_filter_path(trf)
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
ffmpeg = require_tool("ffmpeg")
|
|
66
|
+
detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
|
|
67
|
+
if args.tripod:
|
|
68
|
+
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
69
|
+
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
70
|
+
detect_vf += ":tripod=1"
|
|
71
|
+
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
72
|
+
"-vf", detect_vf, "-f", "null", "-"]
|
|
73
|
+
# Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
|
|
74
|
+
# as well; only pass 2, the write, is skipped there.
|
|
75
|
+
if not dry_run_input_pending(args.input):
|
|
76
|
+
proc = run_analysis(detect_cmd, check=False, record=True)
|
|
75
77
|
if proc.returncode != 0:
|
|
76
|
-
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
|
|
78
|
+
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
77
79
|
|
|
78
80
|
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
79
81
|
transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|