ffmpeg-skill 1.10.0 → 1.11.0
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 +13 -6
- package/SKILL.md +111 -275
- package/docs/contract.md +12 -10
- package/package.json +2 -1
- package/references/gotchas.md +172 -0
- package/references/scripts.md +4 -2
- package/scripts/_common.py +119 -29
- package/scripts/_contract.py +20 -6
- package/scripts/batch.py +3 -0
- package/scripts/render.py +54 -0
package/scripts/render.py
CHANGED
|
@@ -49,6 +49,7 @@ Examples:
|
|
|
49
49
|
python3 render.py project.json --fast # preview quality
|
|
50
50
|
"""
|
|
51
51
|
import argparse
|
|
52
|
+
import difflib
|
|
52
53
|
import re
|
|
53
54
|
import json
|
|
54
55
|
import os
|
|
@@ -79,6 +80,58 @@ TEMPLATE = {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
# Every key render.py reads, per object. Anything else is a refusal rather than a silent no-op:
|
|
84
|
+
# a clip "start"/"end" (the spelling titles, graphics and overlays use) rendered the whole clip
|
|
85
|
+
# untrimmed, and a mistyped stage name dropped the stage -- both reported as a success (review 9).
|
|
86
|
+
OBJECT_KEYS: Dict[str, frozenset] = {
|
|
87
|
+
"project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
|
|
88
|
+
"graphics", "overlays", "audio", "loudness", "fit", "export", "check"}),
|
|
89
|
+
"clips[]": frozenset({"src", "in", "out", "speed"}),
|
|
90
|
+
"frame": frozenset({"aspect", "width", "height", "fps"}),
|
|
91
|
+
"transition": frozenset({"type", "duration"}),
|
|
92
|
+
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
93
|
+
"captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
|
|
94
|
+
"animate", "highlight_color", "outline", "karaoke", "bold", "box"}),
|
|
95
|
+
"graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
|
|
96
|
+
"from", "scale", "primary", "text_color"}),
|
|
97
|
+
"overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
|
|
98
|
+
"scale", "font_size", "font", "font_file", "margin", "box"}),
|
|
99
|
+
"audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
100
|
+
"gain", "duck_amount", "voice", "denoise", "duck", "music_loop", "stereo",
|
|
101
|
+
"mono", "downmix"}),
|
|
102
|
+
"loudness": frozenset({"lufs", "tp"}),
|
|
103
|
+
"fit": frozenset({"duration", "method", "aspect", "fit", "width", "height", "fps", "smooth"}),
|
|
104
|
+
"export": frozenset({"preset", "fit", "crf", "normalize"}),
|
|
105
|
+
"check": frozenset({"platform"}),
|
|
106
|
+
}
|
|
107
|
+
# Typos difflib cannot see: a clip is trimmed with in/out, not the start/end that time a title.
|
|
108
|
+
NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"}}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
112
|
+
"""Refuse an unrecognised key, naming the object, the key and the nearest valid one."""
|
|
113
|
+
if not isinstance(obj, dict):
|
|
114
|
+
return
|
|
115
|
+
valid = OBJECT_KEYS[schema]
|
|
116
|
+
for key in obj:
|
|
117
|
+
if key in valid:
|
|
118
|
+
continue
|
|
119
|
+
near = NEAR_KEYS.get(schema, {}).get(str(key)) or next(iter(difflib.get_close_matches(str(key), sorted(valid), n=1, cutoff=0.6)), None)
|
|
120
|
+
die(f"{label}: unknown key {key!r}" + (f" (did you mean {near!r}?)" if near
|
|
121
|
+
else f" (valid keys: {', '.join(sorted(valid))})"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_project(proj: Dict[str, Any]) -> None:
|
|
125
|
+
check_keys(proj, "project", "project")
|
|
126
|
+
for name in ("frame", "transition", "silence", "captions", "audio", "loudness", "fit", "export", "check"):
|
|
127
|
+
check_keys(proj.get(name), name, name)
|
|
128
|
+
for name in ("clips", "graphics", "overlays"):
|
|
129
|
+
items = proj.get(name)
|
|
130
|
+
if isinstance(items, list):
|
|
131
|
+
for i, item in enumerate(items):
|
|
132
|
+
check_keys(item, f"{name}[]", f"{name}[{i}]")
|
|
133
|
+
|
|
134
|
+
|
|
82
135
|
def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
83
136
|
"""Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
|
|
84
137
|
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
|
|
@@ -226,6 +279,7 @@ def main() -> int:
|
|
|
226
279
|
die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
|
|
227
280
|
if "plan_version" in proj:
|
|
228
281
|
return execute_plan(proj, os.path.abspath(args.project))
|
|
282
|
+
validate_project(proj)
|
|
229
283
|
base = Path(args.project).resolve().parent
|
|
230
284
|
|
|
231
285
|
def rel(p: Any) -> str:
|