ffmpeg-skill 1.4.4 → 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/SKILL.md +5 -1
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/scripts/_common.py +56 -1
- package/scripts/background.py +2 -2
- package/scripts/broll.py +3 -2
- package/scripts/caption.py +2 -2
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- 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 +13 -5
- package/scripts/report.py +11 -4
- package/scripts/reverse.py +2 -2
- 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/waveform.py +2 -2
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,9 +262,13 @@ 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
|
|
|
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
|
+
|
|
268
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
|
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
|
@@ -159,7 +159,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
|
|
|
159
159
|
reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
|
|
160
160
|
`status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
|
|
161
161
|
read a failed delivery as a success."""
|
|
162
|
-
|
|
162
|
+
hint = extra.pop("hint", None)
|
|
163
|
+
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
163
164
|
if STATE.json:
|
|
164
165
|
doc: Dict[str, Any] = {
|
|
165
166
|
"status": "failed", "exit_code": code,
|
|
@@ -170,6 +171,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
|
|
|
170
171
|
},
|
|
171
172
|
"commands": list(STATE.commands),
|
|
172
173
|
}
|
|
174
|
+
if hint:
|
|
175
|
+
doc["error"]["hint"] = hint
|
|
173
176
|
doc.update(extra)
|
|
174
177
|
print_json(doc)
|
|
175
178
|
sys.exit(code)
|
|
@@ -268,6 +271,9 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
268
271
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
269
272
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
270
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}")
|
|
271
277
|
|
|
272
278
|
|
|
273
279
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
@@ -1078,6 +1084,55 @@ def db_to_linear(db: float) -> float:
|
|
|
1078
1084
|
return 10 ** (db / 20.0)
|
|
1079
1085
|
|
|
1080
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
|
+
|
|
1081
1136
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
1082
1137
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
1083
1138
|
|
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/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
|
|
|
@@ -406,7 +406,7 @@ def main() -> int:
|
|
|
406
406
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
407
407
|
enc = ap.add_argument_group("encoding")
|
|
408
408
|
enc.add_argument("--crf", type=int, default=18)
|
|
409
|
-
enc.add_argument("--preset", default="medium")
|
|
409
|
+
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
410
410
|
add_common(ap)
|
|
411
411
|
args = ap.parse_args()
|
|
412
412
|
apply_common(args)
|
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/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
|
@@ -80,18 +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 []) + child_args()
|
|
84
|
-
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])))
|
|
85
85
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
86
86
|
for line in proc.stderr.splitlines():
|
|
87
87
|
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
88
88
|
STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
|
|
89
89
|
elif line.strip():
|
|
90
90
|
info(" " + line)
|
|
91
|
+
try:
|
|
92
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
93
|
+
except ValueError:
|
|
94
|
+
doc = {}
|
|
91
95
|
if proc.returncode != 0:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 "")
|
|
95
103
|
|
|
96
104
|
|
|
97
105
|
def main() -> int:
|
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/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/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)
|