ffmpeg-skill 1.4.3 → 1.4.5
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 +6 -2
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/scripts/_common.py +103 -6
- package/scripts/_contract.py +1 -1
- package/scripts/background.py +2 -2
- package/scripts/batch.py +8 -8
- package/scripts/broll.py +3 -2
- package/scripts/caption.py +31 -7
- package/scripts/check.py +7 -3
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- package/scripts/cropdetect.py +2 -3
- package/scripts/cut.py +16 -4
- package/scripts/deinterlace.py +2 -2
- package/scripts/denoise.py +2 -2
- package/scripts/fit.py +2 -2
- package/scripts/freeze.py +2 -2
- package/scripts/graphics.py +2 -2
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +2 -2
- package/scripts/join.py +2 -2
- package/scripts/loop.py +2 -2
- package/scripts/metadata.py +2 -2
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +2 -2
- package/scripts/pad.py +2 -2
- package/scripts/redact.py +2 -2
- package/scripts/render.py +25 -15
- package/scripts/report.py +11 -4
- package/scripts/reverse.py +2 -2
- package/scripts/scenes.py +5 -7
- package/scripts/sequence.py +2 -2
- package/scripts/silence.py +15 -2
- package/scripts/speedramp.py +2 -2
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +2 -2
- package/scripts/straighten.py +2 -2
- package/scripts/sync.py +2 -3
- package/scripts/verify.py +8 -5
- package/scripts/waveform.py +2 -2
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
|
@@ -95,7 +95,7 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
95
95
|
|
|
96
96
|
## Before you run anything: what to ask, what to assume
|
|
97
97
|
|
|
98
|
-
Ask one short question only when the answer changes the output materially and the request does not imply it:
|
|
98
|
+
Ask one short question only when the answer changes the output materially and the request does not imply it. When several things are open at once (a vague "make it for social media" leaves destination, aspect method, length and captions unresolved), do not ask them one per turn: propose one bundle with your defaults and let the user change any part ("Reels: 9:16 with padding, trimmed to 60 s, -14 LUFS, no captions — OK, or change something?"). One question, one answer, then the run.
|
|
99
99
|
|
|
100
100
|
- **Destination** decides aspect, length limit, loudness and codec. "For Reels" answers all four. If no destination is named and the edit is a plain cut/caption, keep the source format and say so; if the user asks to "export", "post" or "deliver", ask where.
|
|
101
101
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
|
|
@@ -262,10 +262,14 @@ When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
|
|
|
262
262
|
```
|
|
263
263
|
Failed: color.py --lut grade.cube exited 1 — ffmpeg: "Unable to parse LUT file" (the .cube is not a valid LUT)
|
|
264
264
|
Steps: probe -> color (failed); nothing written
|
|
265
|
+
Check: nothing to verify
|
|
266
|
+
Look: not needed (nothing written)
|
|
265
267
|
Notes: send a valid .cube, or say if you want the clip left as is
|
|
266
268
|
```
|
|
267
269
|
|
|
268
|
-
|
|
270
|
+
A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
|
|
271
|
+
|
|
272
|
+
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
273
|
|
|
270
274
|
## Things that look right but are wrong
|
|
271
275
|
|
package/mcp/server.py
CHANGED
|
@@ -102,7 +102,13 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
102
102
|
if proc.returncode != 0:
|
|
103
103
|
err = proc.stderr.strip().splitlines()
|
|
104
104
|
tail = "\n".join(err[-12:])
|
|
105
|
-
|
|
105
|
+
failed: Dict[str, Any] = {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
|
|
106
|
+
# The child's own failure document (status, error.kind/code/hint, commands) is the
|
|
107
|
+
# machine-readable half of the contract; dropping it here left an MCP caller regex-
|
|
108
|
+
# parsing prose to tell a timeout from a missing binary.
|
|
109
|
+
if isinstance(structured, dict):
|
|
110
|
+
failed["structuredContent"] = structured
|
|
111
|
+
return failed
|
|
106
112
|
if structured is None:
|
|
107
113
|
text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
|
|
108
114
|
result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
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,19 @@ 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
|
-
|
|
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."""
|
|
162
|
+
hint = extra.pop("hint", None)
|
|
163
|
+
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
156
164
|
if STATE.json:
|
|
157
|
-
|
|
165
|
+
doc: Dict[str, Any] = {
|
|
158
166
|
"status": "failed", "exit_code": code,
|
|
159
167
|
"error": {
|
|
160
168
|
"kind": kind, "message": msg,
|
|
@@ -162,7 +170,11 @@ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
|
162
170
|
"retryable": ERROR_RETRYABLE,
|
|
163
171
|
},
|
|
164
172
|
"commands": list(STATE.commands),
|
|
165
|
-
}
|
|
173
|
+
}
|
|
174
|
+
if hint:
|
|
175
|
+
doc["error"]["hint"] = hint
|
|
176
|
+
doc.update(extra)
|
|
177
|
+
print_json(doc)
|
|
166
178
|
sys.exit(code)
|
|
167
179
|
|
|
168
180
|
|
|
@@ -259,6 +271,9 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
259
271
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
260
272
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
261
273
|
args.preset = "veryfast"
|
|
274
|
+
crf = getattr(args, "crf", None)
|
|
275
|
+
if crf is not None and not 0 <= int(crf) <= 51:
|
|
276
|
+
die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
|
|
262
277
|
|
|
263
278
|
|
|
264
279
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
@@ -461,6 +476,39 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
461
476
|
return proc
|
|
462
477
|
|
|
463
478
|
|
|
479
|
+
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
|
|
480
|
+
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
|
|
481
|
+
output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
|
|
482
|
+
--dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
|
|
483
|
+
limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
|
|
484
|
+
instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
|
|
485
|
+
limit = _limit_for(cmd)
|
|
486
|
+
try:
|
|
487
|
+
proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
|
|
488
|
+
except subprocess.TimeoutExpired:
|
|
489
|
+
_timed_out(cmd, limit or 0)
|
|
490
|
+
if check and proc.returncode != 0:
|
|
491
|
+
err = proc.stderr if text else proc.stderr.decode(errors="replace")
|
|
492
|
+
_fail(cmd, proc.returncode, err)
|
|
493
|
+
return proc
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def child_args() -> List[str]:
|
|
497
|
+
"""The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
|
|
498
|
+
so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
|
|
499
|
+
stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
|
|
500
|
+
stopped at render.py."""
|
|
501
|
+
args: List[str] = []
|
|
502
|
+
if STATE.fast:
|
|
503
|
+
args.append("--fast")
|
|
504
|
+
if STATE.dry_run:
|
|
505
|
+
args.append("--dry-run")
|
|
506
|
+
if STATE.overwrite:
|
|
507
|
+
args.append("--overwrite")
|
|
508
|
+
args += ["--timeout", f"{STATE.timeout:g}"]
|
|
509
|
+
return args
|
|
510
|
+
|
|
511
|
+
|
|
464
512
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
465
513
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
466
514
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -1036,6 +1084,55 @@ def db_to_linear(db: float) -> float:
|
|
|
1036
1084
|
return 10 ** (db / 20.0)
|
|
1037
1085
|
|
|
1038
1086
|
|
|
1087
|
+
def read_text_or_die(path: str, flag: str) -> str:
|
|
1088
|
+
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
1089
|
+
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
|
|
1090
|
+
try:
|
|
1091
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
1092
|
+
return fh.read()
|
|
1093
|
+
except FileNotFoundError:
|
|
1094
|
+
die(f"{flag}: {path} does not exist")
|
|
1095
|
+
except IsADirectoryError:
|
|
1096
|
+
die(f"{flag}: {path} is a directory, not a text file")
|
|
1097
|
+
except UnicodeDecodeError as e:
|
|
1098
|
+
die(f"{flag}: {path} is not UTF-8 text ({e.reason} at byte {e.start}); save it as UTF-8")
|
|
1099
|
+
except OSError as e:
|
|
1100
|
+
die(f"{flag}: cannot read {path}: {e.strerror}")
|
|
1101
|
+
return "" # unreachable
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
|
|
1105
|
+
"""Video keyframe timestamps within +-window seconds of t, ascending. Read with
|
|
1106
|
+
-read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
|
|
1107
|
+
ffprobe = require_tool("ffprobe")
|
|
1108
|
+
lo = max(0.0, t - window)
|
|
1109
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
|
|
1110
|
+
"-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
|
|
1111
|
+
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
1112
|
+
if proc.returncode != 0:
|
|
1113
|
+
return []
|
|
1114
|
+
out: List[float] = []
|
|
1115
|
+
for line in proc.stdout.splitlines():
|
|
1116
|
+
try:
|
|
1117
|
+
out.append(round(float(line.strip().rstrip(",")), 3))
|
|
1118
|
+
except ValueError:
|
|
1119
|
+
continue
|
|
1120
|
+
return sorted(set(out))
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
|
|
1124
|
+
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
1125
|
+
Cheap enough to run once as a hint when a threshold-based tool found nothing."""
|
|
1126
|
+
ffmpeg = require_tool("ffmpeg")
|
|
1127
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
|
|
1128
|
+
"-af", "volumedetect", "-f", "null", "-"], check=False)
|
|
1129
|
+
m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
1130
|
+
m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
1131
|
+
if not (m_mean and m_max):
|
|
1132
|
+
return None
|
|
1133
|
+
return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
|
|
1134
|
+
|
|
1135
|
+
|
|
1039
1136
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
1040
1137
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
1041
1138
|
|
|
@@ -1045,7 +1142,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
1045
1142
|
ffmpeg = require_tool("ffmpeg")
|
|
1046
1143
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
1047
1144
|
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
1048
|
-
proc =
|
|
1145
|
+
proc = run_analysis(cmd, check=False)
|
|
1049
1146
|
vals: Dict[str, List[float]] = {}
|
|
1050
1147
|
for line in proc.stdout.splitlines():
|
|
1051
1148
|
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/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -29,7 +29,7 @@ def main() -> int:
|
|
|
29
29
|
src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
|
|
30
30
|
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
31
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
32
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
33
|
add_common(ap)
|
|
34
34
|
args = ap.parse_args()
|
|
35
35
|
apply_common(args)
|
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/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -36,10 +36,11 @@ def main() -> int:
|
|
|
36
36
|
ap.add_argument("--audio", choices=["a", "b", "mix"], default="a", help="under a cutaway: A's audio (default), B's audio, or both mixed")
|
|
37
37
|
ap.add_argument("--pad-color", default="black", help="pad colour when B's aspect differs from A's (default black)")
|
|
38
38
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
39
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
39
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
40
40
|
add_common(ap)
|
|
41
41
|
args = ap.parse_args()
|
|
42
42
|
apply_common(args)
|
|
43
|
+
validate_color(args.pad_color, "--pad-color")
|
|
43
44
|
|
|
44
45
|
n = len(args.insert)
|
|
45
46
|
if len(args.at) != n:
|
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
|
|
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
|
|
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
|
|
|
@@ -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")
|
|
@@ -382,7 +406,7 @@ def main() -> int:
|
|
|
382
406
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
383
407
|
enc = ap.add_argument_group("encoding")
|
|
384
408
|
enc.add_argument("--crf", type=int, default=18)
|
|
385
|
-
enc.add_argument("--preset", default="medium")
|
|
409
|
+
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
386
410
|
add_common(ap)
|
|
387
411
|
args = ap.parse_args()
|
|
388
412
|
apply_common(args)
|
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/color.py
CHANGED
|
@@ -22,7 +22,7 @@ import os
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import List
|
|
24
24
|
|
|
25
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
28
28
|
|
|
@@ -204,7 +204,7 @@ def main() -> int:
|
|
|
204
204
|
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
205
205
|
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
206
206
|
ap.add_argument("--crf", type=int, default=18)
|
|
207
|
-
ap.add_argument("--preset", default="medium")
|
|
207
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
208
208
|
add_common(ap)
|
|
209
209
|
args = ap.parse_args()
|
|
210
210
|
apply_common(args)
|
package/scripts/crop.py
CHANGED
|
@@ -21,7 +21,7 @@ Examples:
|
|
|
21
21
|
import argparse
|
|
22
22
|
import sys
|
|
23
23
|
|
|
24
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
24
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -33,7 +33,7 @@ def main() -> int:
|
|
|
33
33
|
ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
|
|
34
34
|
ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
|
|
35
35
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
36
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
36
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
37
37
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
38
38
|
add_common(ap)
|
|
39
39
|
args = ap.parse_args()
|
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/cut.py
CHANGED
|
@@ -30,7 +30,11 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near
|
|
34
|
+
|
|
35
|
+
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
|
+
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
37
|
+
NEAREST_KEYFRAMES: list = []
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
@@ -116,8 +120,15 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
116
120
|
if not reencode and tolerance >= 0 and not STATE["dry_run"]:
|
|
117
121
|
got = probe(dst).get("duration") or 0.0
|
|
118
122
|
if abs(got - dur) > tolerance:
|
|
123
|
+
near = keyframes_near(src, start)
|
|
124
|
+
alt = ""
|
|
125
|
+
if near:
|
|
126
|
+
closest = min(near, key=lambda k: abs(k - start))
|
|
127
|
+
alt = (f"; for a lossless cut move --start to a keyframe (nearest: {closest:.3f}s"
|
|
128
|
+
+ (f", others within 5 s: {', '.join(f'{k:.3f}' for k in near if k != closest)}" if len(near) > 1 else "") + ")")
|
|
129
|
+
NEAREST_KEYFRAMES.extend(k for k in near if k not in NEAREST_KEYFRAMES)
|
|
119
130
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
120
|
-
f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
|
|
131
|
+
f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
|
|
121
132
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
122
133
|
return reencode
|
|
123
134
|
|
|
@@ -134,7 +145,7 @@ def main() -> int:
|
|
|
134
145
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
135
146
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
|
|
136
147
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
137
|
-
ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
|
|
148
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
|
|
138
149
|
add_common(ap)
|
|
139
150
|
args = ap.parse_args()
|
|
140
151
|
apply_common(args)
|
|
@@ -211,7 +222,8 @@ def main() -> int:
|
|
|
211
222
|
requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
|
|
212
223
|
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
213
224
|
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
214
|
-
mode=mode, keyframe_snapped=keyframe_snapped
|
|
225
|
+
mode=mode, keyframe_snapped=keyframe_snapped,
|
|
226
|
+
nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None)
|
|
215
227
|
return 0
|
|
216
228
|
|
|
217
229
|
|
package/scripts/deinterlace.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
MODES = {"frame": 0, "field": 1}
|
|
28
28
|
PARITIES = {"auto": -1, "tff": 0, "bff": 1}
|
|
@@ -41,7 +41,7 @@ def main() -> int:
|
|
|
41
41
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
42
42
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
43
43
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
44
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
44
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
45
45
|
add_common(ap)
|
|
46
46
|
args = ap.parse_args()
|
|
47
47
|
apply_common(args)
|
package/scripts/denoise.py
CHANGED
|
@@ -18,7 +18,7 @@ Examples:
|
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
21
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
22
22
|
|
|
23
23
|
# hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
|
|
24
24
|
# starting points its own documentation and common usage recommend (spatial then temporal,
|
|
@@ -43,7 +43,7 @@ def main() -> int:
|
|
|
43
43
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
44
44
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
45
45
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
46
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
46
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
47
47
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
48
48
|
add_common(ap)
|
|
49
49
|
args = ap.parse_args()
|
package/scripts/fit.py
CHANGED
|
@@ -38,7 +38,7 @@ import sys
|
|
|
38
38
|
from fractions import Fraction
|
|
39
39
|
from typing import List
|
|
40
40
|
|
|
41
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args
|
|
41
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS
|
|
42
42
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
43
43
|
|
|
44
44
|
|
|
@@ -101,7 +101,7 @@ def main() -> int:
|
|
|
101
101
|
r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
|
|
102
102
|
e = ap.add_argument_group("encoding")
|
|
103
103
|
e.add_argument("--crf", type=int, default=18)
|
|
104
|
-
e.add_argument("--preset", default="medium")
|
|
104
|
+
e.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
105
105
|
e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
106
106
|
add_common(ap)
|
|
107
107
|
args = ap.parse_args()
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
22
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def main() -> int:
|
|
@@ -31,7 +31,7 @@ def main() -> int:
|
|
|
31
31
|
ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
|
|
32
32
|
help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
|
|
33
33
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
34
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
34
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
35
35
|
add_common(ap)
|
|
36
36
|
args = ap.parse_args()
|
|
37
37
|
apply_common(args)
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -62,7 +62,7 @@ def main() -> int:
|
|
|
62
62
|
ap.add_argument("--font-file")
|
|
63
63
|
ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
|
|
64
64
|
ap.add_argument("--crf", type=int, default=18)
|
|
65
|
-
ap.add_argument("--preset", default="medium")
|
|
65
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
66
66
|
add_common(ap)
|
|
67
67
|
args = ap.parse_args()
|
|
68
68
|
apply_common(args)
|
package/scripts/grid.py
CHANGED
|
@@ -25,7 +25,7 @@ import argparse
|
|
|
25
25
|
import os
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, \
|
|
28
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, \
|
|
29
29
|
escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
|
|
30
30
|
|
|
31
31
|
LABEL_MARGIN = 10
|
|
@@ -50,7 +50,7 @@ def main() -> int:
|
|
|
50
50
|
ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
|
|
51
51
|
ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
|
|
52
52
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
53
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
53
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
54
54
|
add_common(ap)
|
|
55
55
|
args = ap.parse_args()
|
|
56
56
|
apply_common(args)
|
package/scripts/insert.py
CHANGED
|
@@ -27,7 +27,7 @@ import argparse
|
|
|
27
27
|
import math
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
30
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def even(n: float) -> int:
|
|
@@ -47,7 +47,7 @@ def main() -> int:
|
|
|
47
47
|
ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
|
|
48
48
|
ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
|
|
49
49
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
50
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
50
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
51
51
|
add_common(ap)
|
|
52
52
|
args = ap.parse_args()
|
|
53
53
|
apply_common(args)
|
package/scripts/join.py
CHANGED
|
@@ -23,7 +23,7 @@ import argparse
|
|
|
23
23
|
import sys
|
|
24
24
|
from typing import List
|
|
25
25
|
|
|
26
|
-
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color
|
|
26
|
+
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS
|
|
27
27
|
|
|
28
28
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
29
29
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -94,7 +94,7 @@ def main() -> int:
|
|
|
94
94
|
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
|
|
95
95
|
ap.add_argument("--pad-color", default="black")
|
|
96
96
|
ap.add_argument("--crf", type=int, default=18)
|
|
97
|
-
ap.add_argument("--preset", default="medium")
|
|
97
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
98
98
|
aud = ap.add_argument_group("audio-only inputs")
|
|
99
99
|
aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
|
|
100
100
|
aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
|
package/scripts/loop.py
CHANGED
|
@@ -18,7 +18,7 @@ import argparse
|
|
|
18
18
|
import math
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
21
|
+
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def main() -> int:
|
|
@@ -29,7 +29,7 @@ def main() -> int:
|
|
|
29
29
|
group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
|
|
30
30
|
group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
|
|
31
31
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
32
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
33
|
add_common(ap)
|
|
34
34
|
args = ap.parse_args()
|
|
35
35
|
apply_common(args)
|
package/scripts/metadata.py
CHANGED
|
@@ -30,7 +30,7 @@ import tempfile
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Dict, List, Optional
|
|
32
32
|
|
|
33
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE
|
|
33
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE, read_text_or_die
|
|
34
34
|
|
|
35
35
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
36
36
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -39,7 +39,7 @@ TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
|
39
39
|
def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
40
40
|
"""`TIME TITLE` per line -> [{"start", "end", "title"}], validated: ascending starts, every
|
|
41
41
|
start inside the file, the last chapter running to the file's end."""
|
|
42
|
-
text =
|
|
42
|
+
text = read_text_or_die(path, "--chapters")
|
|
43
43
|
entries: List[Dict[str, Any]] = []
|
|
44
44
|
for n, raw in enumerate(text.splitlines(), start=1):
|
|
45
45
|
line = raw.strip()
|
package/scripts/multicam.py
CHANGED
|
@@ -29,7 +29,7 @@ import argparse
|
|
|
29
29
|
import sys
|
|
30
30
|
from typing import List, Tuple
|
|
31
31
|
|
|
32
|
-
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
32
|
+
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS
|
|
33
33
|
from sync import measure_offset
|
|
34
34
|
|
|
35
35
|
|
|
@@ -69,7 +69,7 @@ def main() -> int:
|
|
|
69
69
|
ap.add_argument("--height", type=int, help="output height (default: reference)")
|
|
70
70
|
ap.add_argument("--fps", type=float, help="output fps (default: reference)")
|
|
71
71
|
ap.add_argument("--crf", type=int, default=18)
|
|
72
|
-
ap.add_argument("--preset", default="medium")
|
|
72
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
73
73
|
add_common(ap)
|
|
74
74
|
args = ap.parse_args()
|
|
75
75
|
apply_common(args)
|
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
|
|
27
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -119,7 +119,7 @@ def main() -> int:
|
|
|
119
119
|
txt.add_argument("--box-color", default="black@0.5")
|
|
120
120
|
enc = ap.add_argument_group("encoding")
|
|
121
121
|
enc.add_argument("--crf", type=int, default=18)
|
|
122
|
-
enc.add_argument("--preset", default="medium")
|
|
122
|
+
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
123
123
|
add_common(ap)
|
|
124
124
|
args = ap.parse_args()
|
|
125
125
|
apply_common(args)
|
package/scripts/pad.py
CHANGED
|
@@ -16,7 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
@@ -27,7 +27,7 @@ def main() -> int:
|
|
|
27
27
|
ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
|
|
28
28
|
ap.add_argument("--color", default="black", help="padding colour (default black)")
|
|
29
29
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
30
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
30
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
31
31
|
add_common(ap)
|
|
32
32
|
args = ap.parse_args()
|
|
33
33
|
apply_common(args)
|
package/scripts/redact.py
CHANGED
|
@@ -20,7 +20,7 @@ Examples:
|
|
|
20
20
|
import argparse
|
|
21
21
|
import sys
|
|
22
22
|
|
|
23
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
23
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def main() -> int:
|
|
@@ -37,7 +37,7 @@ def main() -> int:
|
|
|
37
37
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
38
38
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
39
39
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
40
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
40
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
41
41
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
42
42
|
add_common(ap)
|
|
43
43
|
args = ap.parse_args()
|
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,22 +80,26 @@ 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
|
|
85
|
-
cmd.append("--fast")
|
|
86
|
-
if STATE["dry_run"]:
|
|
87
|
-
cmd.append("--dry-run")
|
|
88
|
-
info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
83
|
+
cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
|
|
84
|
+
info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd[:-1])))
|
|
89
85
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
90
86
|
for line in proc.stderr.splitlines():
|
|
91
87
|
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
92
88
|
STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
|
|
93
89
|
elif line.strip():
|
|
94
90
|
info(" " + line)
|
|
91
|
+
try:
|
|
92
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
93
|
+
except ValueError:
|
|
94
|
+
doc = {}
|
|
95
95
|
if proc.returncode != 0:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
# Re-raise the stage's own failure: its kind, exit code and hint are what the caller
|
|
97
|
+
# needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
|
|
98
|
+
err = doc.get("error") or {}
|
|
99
|
+
extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
|
|
100
|
+
die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
|
|
101
|
+
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
|
|
102
|
+
return str(doc.get("output") or "")
|
|
99
103
|
|
|
100
104
|
|
|
101
105
|
def main() -> int:
|
|
@@ -360,12 +364,12 @@ def main() -> int:
|
|
|
360
364
|
check_result = json.loads(proc.stdout)
|
|
361
365
|
except ValueError:
|
|
362
366
|
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"):
|
|
367
|
+
if check_result.get("failed"):
|
|
367
368
|
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
369
|
exit_code = 1
|
|
370
|
+
elif check_result.get("error") or check_result.get("status") == "failed":
|
|
371
|
+
info(f"check: could not run check.py — {check_result.get('error')}")
|
|
372
|
+
exit_code = 1
|
|
369
373
|
else:
|
|
370
374
|
info(f"check: OK for {ck['platform']}")
|
|
371
375
|
stages_done.append("check")
|
|
@@ -378,9 +382,15 @@ def main() -> int:
|
|
|
378
382
|
# to before the PID suffix was added.
|
|
379
383
|
import shutil
|
|
380
384
|
shutil.rmtree(work, ignore_errors=True)
|
|
385
|
+
if exit_code:
|
|
386
|
+
# The deliverable is written and verified, but it does not meet the requested platform
|
|
387
|
+
# spec (or the check itself could not run): a failed delivery, reported as one.
|
|
388
|
+
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
389
|
+
die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
|
|
390
|
+
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
|
|
381
391
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
382
392
|
emit(output, stages=stages_done, check=check_result)
|
|
383
|
-
return
|
|
393
|
+
return 0
|
|
384
394
|
|
|
385
395
|
|
|
386
396
|
if __name__ == "__main__":
|
package/scripts/report.py
CHANGED
|
@@ -19,7 +19,7 @@ import tempfile
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any, Dict, List, Optional
|
|
21
21
|
|
|
22
|
-
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
22
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die
|
|
23
23
|
|
|
24
24
|
HERE = Path(__file__).resolve().parent
|
|
25
25
|
|
|
@@ -46,9 +46,16 @@ def loudness(path: str) -> Dict[str, Any]:
|
|
|
46
46
|
def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
|
|
47
47
|
proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
48
48
|
try:
|
|
49
|
-
|
|
49
|
+
doc = json.loads(proc.stdout)
|
|
50
50
|
except ValueError:
|
|
51
|
+
doc = None
|
|
52
|
+
if not isinstance(doc, dict) or "checks" not in doc:
|
|
53
|
+
# check.py could not run at all (missing ffmpeg, unreadable file): its failure document
|
|
54
|
+
# has no rows to render. A failed *verification* still carries its rows and is shown.
|
|
55
|
+
reason = ((doc or {}).get("error") or {}).get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1]
|
|
56
|
+
info(f"check.py could not run: {reason[:200]}")
|
|
51
57
|
return None
|
|
58
|
+
return doc
|
|
52
59
|
|
|
53
60
|
|
|
54
61
|
def fmt_dur(sec: Optional[float]) -> str:
|
|
@@ -99,8 +106,8 @@ def main() -> int:
|
|
|
99
106
|
sheets["before"] = sheet_b64(args.before)
|
|
100
107
|
if after.get("video"):
|
|
101
108
|
sheets["after"] = sheet_b64(args.after)
|
|
102
|
-
commands =
|
|
103
|
-
notes =
|
|
109
|
+
commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
|
|
110
|
+
notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
|
|
104
111
|
title = args.title or f"Delivery report — {Path(args.after).name}"
|
|
105
112
|
output = args.output or str(Path(args.after).with_name(Path(args.after).stem + "_report.html"))
|
|
106
113
|
|
package/scripts/reverse.py
CHANGED
|
@@ -14,7 +14,7 @@ Examples:
|
|
|
14
14
|
import argparse
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
17
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -23,7 +23,7 @@ def main() -> int:
|
|
|
23
23
|
ap.add_argument("-o", "--output", help="output file (default: <name>_reverse.<ext>)")
|
|
24
24
|
ap.add_argument("--no-audio", action="store_true", help="drop audio instead of reversing it")
|
|
25
25
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
26
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
26
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
27
27
|
add_common(ap)
|
|
28
28
|
args = ap.parse_args()
|
|
29
29
|
apply_common(args)
|
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/sequence.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
import tempfile
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
22
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def even(n: float) -> int:
|
|
@@ -43,7 +43,7 @@ def main() -> int:
|
|
|
43
43
|
ap.add_argument("--width", type=int, help="output width in px; with --height also given, both are used directly")
|
|
44
44
|
ap.add_argument("--height", type=int, help="output height in px; with --width also given, both are used directly")
|
|
45
45
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
46
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
46
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
47
47
|
add_common(ap)
|
|
48
48
|
args = ap.parse_args()
|
|
49
49
|
apply_common(args)
|
package/scripts/silence.py
CHANGED
|
@@ -16,7 +16,7 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import 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
|
|
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
20
|
|
|
21
21
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
22
|
|
|
@@ -65,7 +65,7 @@ def main() -> int:
|
|
|
65
65
|
ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
|
|
66
66
|
ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
|
|
67
67
|
ap.add_argument("--crf", type=int, default=18)
|
|
68
|
-
ap.add_argument("--preset", default="medium")
|
|
68
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
69
69
|
add_common(ap)
|
|
70
70
|
args = ap.parse_args()
|
|
71
71
|
apply_common(args)
|
|
@@ -86,6 +86,19 @@ def main() -> int:
|
|
|
86
86
|
"removed_seconds": round(removed, 3),
|
|
87
87
|
}
|
|
88
88
|
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:
|
|
90
|
+
# Nothing under the threshold is a valid result, not a failure -- but an agent that only
|
|
91
|
+
# sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
|
|
92
|
+
# what threshold would bite, so the retry is a flag change, not a workaround.
|
|
93
|
+
level = measured_level_dbfs(args.input)
|
|
94
|
+
if level:
|
|
95
|
+
suggested = min(-5.0, round(level["mean_dbfs"] + 6.0))
|
|
96
|
+
summary["hint"] = (f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; the track's mean level is "
|
|
97
|
+
f"{level['mean_dbfs']:.1f} dBFS (peak {level['peak_dbfs']:.1f}). For a quiet-room recording try "
|
|
98
|
+
f"--threshold {suggested:g}, or a shorter --min-silence")
|
|
99
|
+
else:
|
|
100
|
+
summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
|
|
101
|
+
info("hint: " + summary["hint"])
|
|
89
102
|
|
|
90
103
|
if args.edl:
|
|
91
104
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
23
23
|
|
|
24
24
|
MAX_SPEED = 20.0
|
|
25
25
|
MIN_SPEED = 0.05
|
|
@@ -60,7 +60,7 @@ def main() -> int:
|
|
|
60
60
|
ap.add_argument("--segment", action="append", required=True, dest="segments",
|
|
61
61
|
help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
|
|
62
62
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
63
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
63
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
64
64
|
add_common(ap)
|
|
65
65
|
args = ap.parse_args()
|
|
66
66
|
apply_common(args)
|
package/scripts/sphere.py
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
import argparse
|
|
32
32
|
import sys
|
|
33
33
|
|
|
34
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
34
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
35
35
|
|
|
36
36
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
37
|
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
@@ -65,7 +65,7 @@ def main() -> int:
|
|
|
65
65
|
out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
66
66
|
out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
|
|
67
67
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
68
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
68
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
69
69
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
70
70
|
add_common(ap)
|
|
71
71
|
args = ap.parse_args()
|
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
|
|
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
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -40,7 +40,7 @@ def main() -> int:
|
|
|
40
40
|
ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
|
|
41
41
|
ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
|
|
42
42
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
43
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
43
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
44
44
|
add_common(ap)
|
|
45
45
|
args = ap.parse_args()
|
|
46
46
|
apply_common(args)
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def main() -> int:
|
|
@@ -36,7 +36,7 @@ def main() -> int:
|
|
|
36
36
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
37
37
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
38
38
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
39
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
39
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
40
40
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
41
41
|
add_common(ap)
|
|
42
42
|
args = ap.parse_args()
|
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__":
|
package/scripts/waveform.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color
|
|
25
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
28
|
|
|
@@ -42,7 +42,7 @@ def main() -> int:
|
|
|
42
42
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
43
43
|
help="which audio stream of the input to render, 0-based in file order (default 0)")
|
|
44
44
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
45
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
45
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
46
46
|
add_common(ap)
|
|
47
47
|
args = ap.parse_args()
|
|
48
48
|
apply_common(args)
|