kinescript 1.1.0__py3-none-any.whl
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.
- kinescript/__init__.py +6 -0
- kinescript/cli.py +167 -0
- kinescript/color.py +89 -0
- kinescript/components.py +272 -0
- kinescript/docs.py +345 -0
- kinescript/ease.py +143 -0
- kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- kinescript/mcp.py +148 -0
- kinescript/media.py +220 -0
- kinescript/mesh.py +390 -0
- kinescript/models.py +339 -0
- kinescript/output.py +370 -0
- kinescript/presets.py +170 -0
- kinescript/props.py +185 -0
- kinescript/render.py +1204 -0
- kinescript/render3d.py +393 -0
- kinescript/scene.py +362 -0
- kinescript/schema.py +701 -0
- kinescript/shaders.py +79 -0
- kinescript/space.py +81 -0
- kinescript/svgpath.py +80 -0
- kinescript/timing.py +169 -0
- kinescript/verify.py +261 -0
- kinescript-1.1.0.dist-info/METADATA +192 -0
- kinescript-1.1.0.dist-info/RECORD +40 -0
- kinescript-1.1.0.dist-info/WHEEL +5 -0
- kinescript-1.1.0.dist-info/entry_points.txt +2 -0
- kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/top_level.txt +1 -0
kinescript/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Kinescript: motion graphics and video edits written as data, checked and rendered by agents."""
|
|
2
|
+
from .output import render, still
|
|
3
|
+
from .scene import Scene, SceneError, load
|
|
4
|
+
|
|
5
|
+
__version__ = "1.1.0"
|
|
6
|
+
__all__ = ["Scene", "SceneError", "load", "render", "still", "__version__"]
|
kinescript/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""kine: command line for Kinescript."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
|
|
12
|
+
STARTER = {
|
|
13
|
+
"canvas": {"w": 1920, "h": 1080, "fps": 30, "bg": "#0a0c10"},
|
|
14
|
+
"grade": {"vignette": 0.3},
|
|
15
|
+
"layers": [
|
|
16
|
+
{"id": "bg", "type": "shader", "preset": "mesh", "opacity": 0.35},
|
|
17
|
+
{"use": "titleCard", "id": "title", "title": "Your **title**", "subtitle": "a line of context",
|
|
18
|
+
"at": 0.3, "dur": 3.5},
|
|
19
|
+
{"use": "lowerThird", "id": "who", "title": "Name Surname", "subtitle": "Role", "at": "title.out+0.2", "dur": 3},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _emit(obj):
|
|
25
|
+
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv=None) -> int:
|
|
29
|
+
ap = argparse.ArgumentParser(prog="kine", description="Kinescript: motion graphics and video edits as data.")
|
|
30
|
+
ap.add_argument("--version", action="version", version=f"kinescript {__version__}")
|
|
31
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
32
|
+
|
|
33
|
+
r = sub.add_parser("render", help="render a scene to .mp4/.mov/.webm/.gif or a .png sequence")
|
|
34
|
+
r.add_argument("scene")
|
|
35
|
+
r.add_argument("-o", "--out")
|
|
36
|
+
r.add_argument("-q", "--quality", choices=["draft", "standard", "high"], default="standard")
|
|
37
|
+
r.add_argument("--scale", type=float, default=1.0, help="resolution multiplier, e.g. 0.5 for a fast preview")
|
|
38
|
+
r.add_argument("--from", dest="start", type=float)
|
|
39
|
+
r.add_argument("--to", dest="end", type=float)
|
|
40
|
+
r.add_argument("-j", "--workers", type=int)
|
|
41
|
+
r.add_argument("--transparent", action="store_true", help="drop the background (use .mov or .webm)")
|
|
42
|
+
|
|
43
|
+
s = sub.add_parser("still", help="render one frame to PNG/JPG")
|
|
44
|
+
s.add_argument("scene")
|
|
45
|
+
s.add_argument("--at", required=True, help='seconds; negative counts from the end; "end" is the last frame')
|
|
46
|
+
s.add_argument("-o", "--out")
|
|
47
|
+
s.add_argument("--scale", type=float, default=1.0)
|
|
48
|
+
|
|
49
|
+
lk = sub.add_parser("look", help="contact sheet: many frames in one image, for review")
|
|
50
|
+
lk.add_argument("scene")
|
|
51
|
+
lk.add_argument("-n", "--count", type=int, default=12)
|
|
52
|
+
lk.add_argument("--cols", type=int)
|
|
53
|
+
lk.add_argument("--width", type=int, default=480, help="width of each thumbnail")
|
|
54
|
+
lk.add_argument("--at", type=float, nargs="*", help="explicit times instead of even spacing")
|
|
55
|
+
lk.add_argument("--boxes", action="store_true", help="outline every layer with its id")
|
|
56
|
+
lk.add_argument("-o", "--out")
|
|
57
|
+
|
|
58
|
+
ck = sub.add_parser("check", help="lint layout, timing, contrast and reading time; prints JSON")
|
|
59
|
+
ck.add_argument("scene")
|
|
60
|
+
ck.add_argument("--step", type=float, default=0.25)
|
|
61
|
+
|
|
62
|
+
pr = sub.add_parser("probe", help="resolved timeline and layer boxes at --at; or, for a media file, "
|
|
63
|
+
"its duration, size, fps and audio")
|
|
64
|
+
pr.add_argument("scene")
|
|
65
|
+
pr.add_argument("--at", type=float)
|
|
66
|
+
|
|
67
|
+
sub.add_parser("docs", help="print the scene reference")
|
|
68
|
+
nw = sub.add_parser("new", help="write a starter scene")
|
|
69
|
+
nw.add_argument("path", nargs="?", default="scene.json")
|
|
70
|
+
|
|
71
|
+
w = sub.add_parser("watch", help="re-render a contact sheet whenever the scene file changes")
|
|
72
|
+
w.add_argument("scene")
|
|
73
|
+
w.add_argument("-n", "--count", type=int, default=12)
|
|
74
|
+
|
|
75
|
+
sub.add_parser("mcp", help="run the MCP server on stdio")
|
|
76
|
+
sub.add_parser("doctor", help="report ffmpeg, GPU and font availability")
|
|
77
|
+
|
|
78
|
+
a = ap.parse_args(argv)
|
|
79
|
+
try:
|
|
80
|
+
return _run(a)
|
|
81
|
+
except (ValueError, RuntimeError, FileNotFoundError) as e:
|
|
82
|
+
_emit({"ok": False, "error": str(e)})
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_out(scene: str, suffix: str) -> str:
|
|
87
|
+
p = Path(scene)
|
|
88
|
+
return str(p.with_name(p.stem + suffix))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run(a) -> int:
|
|
92
|
+
if a.cmd == "docs":
|
|
93
|
+
from .docs import reference
|
|
94
|
+
sys.stdout.write(reference())
|
|
95
|
+
return 0
|
|
96
|
+
if a.cmd == "new":
|
|
97
|
+
p = Path(a.path)
|
|
98
|
+
if p.exists():
|
|
99
|
+
raise ValueError(f"{p} exists; pick another name")
|
|
100
|
+
p.write_text(json.dumps(STARTER, indent=2), encoding="utf-8")
|
|
101
|
+
_emit({"ok": True, "out": str(p)})
|
|
102
|
+
return 0
|
|
103
|
+
if a.cmd == "mcp":
|
|
104
|
+
from .mcp import serve
|
|
105
|
+
serve()
|
|
106
|
+
return 0
|
|
107
|
+
if a.cmd == "doctor":
|
|
108
|
+
from . import media
|
|
109
|
+
from .render import gpu_context, Renderer
|
|
110
|
+
from .scene import Scene
|
|
111
|
+
info = {"version": __version__, "ffmpeg": media.FFMPEG, "gpu": gpu_context() is not None}
|
|
112
|
+
r = Renderer(Scene({"canvas": {"w": 64, "h": 64}, "layers": [{"type": "text", "text": "kine"}]}))
|
|
113
|
+
info["font"] = r._typeface(r.s.theme["font"], 600, False).getFamilyName()
|
|
114
|
+
_emit(info)
|
|
115
|
+
return 0
|
|
116
|
+
if a.cmd == "render":
|
|
117
|
+
from . import output
|
|
118
|
+
from .output import render
|
|
119
|
+
output.WARM = 0 # one render, then the process exits: shut the workers down inside it, as timed
|
|
120
|
+
out = a.out or _default_out(a.scene, ".mp4")
|
|
121
|
+
_emit(dict(ok=True, **render(a.scene, out, a.quality, a.workers, a.scale, a.start, a.end, a.transparent)))
|
|
122
|
+
return 0
|
|
123
|
+
if a.cmd == "still":
|
|
124
|
+
from .output import still
|
|
125
|
+
at = str(a.at)
|
|
126
|
+
out = a.out or _default_out(a.scene, f"_{at.lstrip('-')}{'s' if at != 'end' else ''}.png")
|
|
127
|
+
_emit(dict(ok=True, **still(a.scene, at, out, a.scale)))
|
|
128
|
+
return 0
|
|
129
|
+
if a.cmd == "look":
|
|
130
|
+
from .verify import contact_sheet
|
|
131
|
+
out = a.out or _default_out(a.scene, "_sheet.png")
|
|
132
|
+
_emit(dict(ok=True, **contact_sheet(a.scene, out, a.count, a.cols, a.width, a.at, a.boxes)))
|
|
133
|
+
return 0
|
|
134
|
+
if a.cmd == "check":
|
|
135
|
+
from .verify import check
|
|
136
|
+
res = check(a.scene, a.step)
|
|
137
|
+
_emit(res)
|
|
138
|
+
return 0 if res["ok"] else 1
|
|
139
|
+
if a.cmd == "probe":
|
|
140
|
+
from .verify import probe
|
|
141
|
+
if Path(a.scene).suffix.lower() != ".json" and Path(a.scene).exists():
|
|
142
|
+
from . import media
|
|
143
|
+
_emit(dict(ok=True, src=a.scene, **media.probe(a.scene)))
|
|
144
|
+
return 0
|
|
145
|
+
_emit(probe(a.scene, a.at))
|
|
146
|
+
return 0
|
|
147
|
+
if a.cmd == "watch":
|
|
148
|
+
from .verify import contact_sheet
|
|
149
|
+
p = Path(a.scene)
|
|
150
|
+
last = 0.0
|
|
151
|
+
out = _default_out(a.scene, "_sheet.png")
|
|
152
|
+
print(f"watching {p} -> {out} (Ctrl+C to stop)", file=sys.stderr)
|
|
153
|
+
while True:
|
|
154
|
+
m = p.stat().st_mtime
|
|
155
|
+
if m != last:
|
|
156
|
+
last = m
|
|
157
|
+
try:
|
|
158
|
+
contact_sheet(a.scene, out, a.count)
|
|
159
|
+
print(f"{time.strftime('%H:%M:%S')} updated", file=sys.stderr)
|
|
160
|
+
except (ValueError, RuntimeError) as e:
|
|
161
|
+
print(f"{time.strftime('%H:%M:%S')} error: {e}", file=sys.stderr)
|
|
162
|
+
time.sleep(0.5)
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
kinescript/color.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Colour parsing and perceptual (OKLab) interpolation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
RGBA = tuple[float, float, float, float]
|
|
8
|
+
|
|
9
|
+
NAMED = {
|
|
10
|
+
"black": "#000000", "white": "#ffffff", "transparent": "#00000000", "red": "#ff3b30",
|
|
11
|
+
"green": "#34c759", "blue": "#0a84ff", "yellow": "#ffd60a", "orange": "#ff9f0a",
|
|
12
|
+
"gray": "#8e8e93", "grey": "#8e8e93", "purple": "#bf5af2", "pink": "#ff375f", "cyan": "#64d2ff",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_color(v) -> bool:
|
|
17
|
+
return isinstance(v, str) and (v.startswith("#") or v.startswith("rgb") or v in NAMED)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@lru_cache(maxsize=4096)
|
|
21
|
+
def parse(c: str) -> RGBA:
|
|
22
|
+
c = NAMED.get(c, c).strip()
|
|
23
|
+
if c.startswith("#"):
|
|
24
|
+
h = c[1:]
|
|
25
|
+
if len(h) in (3, 4):
|
|
26
|
+
h = "".join(ch * 2 for ch in h)
|
|
27
|
+
if len(h) == 6:
|
|
28
|
+
h += "ff"
|
|
29
|
+
if len(h) != 8:
|
|
30
|
+
raise ValueError(f"bad colour {c!r}")
|
|
31
|
+
return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4, 6)) # type: ignore[return-value]
|
|
32
|
+
m = re.fullmatch(r"rgba?\(([^)]*)\)", c)
|
|
33
|
+
if m:
|
|
34
|
+
parts = [float(p) for p in m.group(1).replace("/", ",").split(",") if p.strip()]
|
|
35
|
+
r, g, b = (p / 255 for p in parts[:3])
|
|
36
|
+
a = parts[3] if len(parts) > 3 else 1.0
|
|
37
|
+
return (r, g, b, a)
|
|
38
|
+
raise ValueError(f"bad colour {c!r} (use #rrggbb, #rrggbbaa, rgb(), rgba() or a name)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_argb_int(c: RGBA, alpha_mul: float = 1.0) -> int:
|
|
42
|
+
r, g, b, a = c
|
|
43
|
+
a = max(0.0, min(1.0, a * alpha_mul))
|
|
44
|
+
return (round(a * 255) << 24) | (round(r * 255) << 16) | (round(g * 255) << 8) | round(b * 255)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _lin(x: float) -> float:
|
|
48
|
+
return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gam(x: float) -> float:
|
|
52
|
+
x = max(0.0, min(1.0, x))
|
|
53
|
+
return 12.92 * x if x <= 0.0031308 else 1.055 * x ** (1 / 2.4) - 0.055
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_oklab(c: RGBA):
|
|
57
|
+
r, g, b = (_lin(v) for v in c[:3])
|
|
58
|
+
l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
|
|
59
|
+
m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
|
|
60
|
+
s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
|
|
61
|
+
return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
62
|
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
63
|
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _from_oklab(L: float, A: float, B: float):
|
|
67
|
+
l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
|
|
68
|
+
m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
|
|
69
|
+
s = (L - 0.0894841775 * A - 1.2914855480 * B) ** 3
|
|
70
|
+
return (_gam(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
71
|
+
_gam(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
72
|
+
_gam(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mix(a: RGBA, b: RGBA, t: float) -> RGBA:
|
|
76
|
+
"""Interpolate in OKLab so mid-tones stay vivid instead of going muddy."""
|
|
77
|
+
la, lb = _to_oklab(a), _to_oklab(b)
|
|
78
|
+
r, g, bl = _from_oklab(*(x + (y - x) * t for x, y in zip(la, lb)))
|
|
79
|
+
return (r, g, bl, a[3] + (b[3] - a[3]) * t)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def luminance(c: RGBA) -> float:
|
|
83
|
+
r, g, b = (_lin(v) for v in c[:3])
|
|
84
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def contrast(l1: float, l2: float) -> float:
|
|
88
|
+
hi, lo = max(l1, l2), min(l1, l2)
|
|
89
|
+
return (hi + 0.05) / (lo + 0.05)
|
kinescript/components.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Reusable building blocks: {"use": "lowerThird", "title": "...", ...} expands into plain layers.
|
|
2
|
+
|
|
3
|
+
Scene files may also declare their own under "components" with {{param}} placeholders.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
THEME = {
|
|
12
|
+
# Brand palette ("Kinetic Score"), plus the short names components use.
|
|
13
|
+
"void": "#0a0c10", "sheet": "#f2f0e8", "track": "#242832", "cue": "#ff633f", "key": "#b9f45d",
|
|
14
|
+
"annotation": "#9299a8",
|
|
15
|
+
"ink": "#0a0c10", "paper": "#f2f0e8", "accent": "#ff633f", "accent2": "#b9f45d", "muted": "#9299a8",
|
|
16
|
+
"font": "Space Grotesk", "mono": "IBM Plex Mono",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _lower_third(p, th):
|
|
21
|
+
title, sub = p.get("title", "Title"), p.get("subtitle", "")
|
|
22
|
+
x, y = p.get("x", "6%"), p.get("y", "78%")
|
|
23
|
+
accent = p.get("accent", th["accent"])
|
|
24
|
+
plate = p.get("plate", "#0a0c10d0")
|
|
25
|
+
box = {"fill": plate, "pad": [8, 16], "radius": 6} if plate else None
|
|
26
|
+
kids = [
|
|
27
|
+
{"id": f"{p['id']}-bar", "type": "rect", "x": 0, "y": 0, "w": 8, "h": 124 if sub else 72,
|
|
28
|
+
"anchor": "topleft", "fill": accent, "radius": 4, "enter": {"name": "wipe-down", "dur": 0.35},
|
|
29
|
+
"exit": {"name": "fade", "dur": 0.3}},
|
|
30
|
+
{"id": f"{p['id']}-title", "type": "text", "text": title, "x": 28, "y": 0, "anchor": "topleft",
|
|
31
|
+
"align": "left", "size": p.get("size", 54), "weight": 700, "fill": p.get("color", th["paper"]),
|
|
32
|
+
"at": 0.12, "enter": {"name": "left", "dur": 0.45, "dist": 30}, "exit": {"name": "fade", "dur": 0.3},
|
|
33
|
+
"shadow": {"y": 3, "blur": 12, "color": "#00000088"}, "box": box},
|
|
34
|
+
]
|
|
35
|
+
if sub:
|
|
36
|
+
kids.append({"id": f"{p['id']}-sub", "type": "text", "text": sub, "x": 28, "y": 76, "anchor": "topleft",
|
|
37
|
+
"align": "left", "size": round(p.get("size", 54) * 0.55), "weight": 500,
|
|
38
|
+
"fill": p.get("subColor", th["muted"]), "at": 0.3,
|
|
39
|
+
"enter": {"name": "left", "dur": 0.45, "dist": 20}, "exit": {"name": "fade", "dur": 0.3},
|
|
40
|
+
"box": box})
|
|
41
|
+
return {"type": "group", "x": x, "y": y, "children": kids}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _title_card(p, th):
|
|
45
|
+
kids = []
|
|
46
|
+
if p.get("kicker"):
|
|
47
|
+
kids.append({"id": f"{p['id']}-kicker", "type": "text", "text": p["kicker"], "y": -110, "size": 30,
|
|
48
|
+
"weight": 600, "fill": p.get("accent", th["accent"]), "letterSpacing": 6,
|
|
49
|
+
"enter": {"name": "track", "dur": 0.8}, "exit": "fade"})
|
|
50
|
+
kids.append({"id": f"{p['id']}-title", "type": "text", "text": p.get("title", "Title"), "size": p.get("size", 128),
|
|
51
|
+
"weight": 800, "fill": p.get("color", th["paper"]), "split": "chars", "at": 0.15,
|
|
52
|
+
"enter": {"name": "rise+blur", "dur": 0.6, "stagger": 0.035, "dist": 60},
|
|
53
|
+
"exit": {"name": "fade", "dur": 0.4}, "maxWidth": "86%"})
|
|
54
|
+
if p.get("subtitle"):
|
|
55
|
+
kids.append({"id": f"{p['id']}-sub", "type": "text", "text": p["subtitle"], "y": 120, "size": 40,
|
|
56
|
+
"weight": 400, "fill": th["muted"], "at": 0.6, "enter": "rise", "exit": "fade"})
|
|
57
|
+
kids.append({"id": f"{p['id']}-rule", "type": "line", "points": [[-160, 0], [160, 0]], "y": 70 if not p.get("subtitle") else 76,
|
|
58
|
+
"stroke": p.get("accent", th["accent"]), "strokeWidth": 6, "cap": "round", "at": 0.45,
|
|
59
|
+
"enter": {"name": "draw", "dur": 0.7}, "exit": "fade"})
|
|
60
|
+
return {"type": "group", "x": p.get("x", "50%"), "y": p.get("y", "50%"), "children": kids}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _callout(p, th):
|
|
64
|
+
r = p.get("r", 60)
|
|
65
|
+
accent = p.get("accent", th["accent"])
|
|
66
|
+
side = p.get("side", "right")
|
|
67
|
+
lx = r + 40 if side == "right" else -(r + 40)
|
|
68
|
+
kids = [
|
|
69
|
+
{"id": f"{p['id']}-ring", "type": "circle", "r": r, "stroke": accent, "strokeWidth": 6,
|
|
70
|
+
"enter": {"name": "draw", "dur": 0.5}, "exit": "fade"},
|
|
71
|
+
{"id": f"{p['id']}-pulse", "type": "circle", "r": r, "stroke": accent, "strokeWidth": 3,
|
|
72
|
+
"scale": "=1 + (t % 1.2) / 1.2 * 0.5", "opacity": "=0.8 * (1 - (t % 1.2) / 1.2)", "at": 0.5},
|
|
73
|
+
]
|
|
74
|
+
if p.get("label"):
|
|
75
|
+
kids.append({"id": f"{p['id']}-label", "type": "text", "text": p["label"], "x": lx, "size": p.get("size", 36),
|
|
76
|
+
"weight": 700, "fill": th["ink"], "anchor": "left" if side == "right" else "right",
|
|
77
|
+
"align": "left" if side == "right" else "right",
|
|
78
|
+
"box": {"fill": p.get("boxColor", th["paper"]), "pad": [14, 22], "radius": 10},
|
|
79
|
+
"at": 0.25, "enter": {"name": "pop", "dur": 0.45}, "exit": "fade"})
|
|
80
|
+
return {"type": "group", "x": p.get("x", "50%"), "y": p.get("y", "50%"), "children": kids}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _focus(p, th):
|
|
84
|
+
"""Highlight a region of footage: dim everything else and draw a frame around it."""
|
|
85
|
+
x, y, w, h = p.get("x", 0), p.get("y", 0), p.get("w", 400), p.get("h", 300)
|
|
86
|
+
return {"type": "group", "children": [
|
|
87
|
+
{"id": f"{p['id']}-frame", "type": "rect", "x": x, "y": y, "w": w, "h": h, "anchor": "topleft",
|
|
88
|
+
"radius": p.get("radius", 12), "stroke": p.get("accent", th["accent"]), "strokeWidth": 5,
|
|
89
|
+
"enter": {"name": "draw", "dur": 0.6}, "exit": "fade", "dimOutside": p.get("dim", 0.55)},
|
|
90
|
+
]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _progress(p, th):
|
|
94
|
+
h = p.get("h", 8)
|
|
95
|
+
return {"type": "rect", "x": 0, "y": p.get("y", "100%"), "anchor": "bottomleft", "h": h,
|
|
96
|
+
"w": "=W * p", "fill": p.get("color", th["accent"])}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _kinetic(p, th):
|
|
100
|
+
"""Big words, one after another: {"use": "kinetic", "words": ["Write", "Check", "Render"], "every": 0.6}."""
|
|
101
|
+
every = float(p.get("every", 0.6))
|
|
102
|
+
kids = []
|
|
103
|
+
for i, word in enumerate(p.get("words", [])):
|
|
104
|
+
kids.append({"id": f"{p['id']}-w{i}", "type": "text", "text": word, "at": round(i * every, 4), "dur": every,
|
|
105
|
+
"size": p.get("size", 180), "weight": 900, "fill": p.get("color", th["paper"]),
|
|
106
|
+
"enter": {"name": p.get("motion", "pop"), "dur": min(0.35, every * 0.6)},
|
|
107
|
+
"exit": {"name": "fade", "dur": min(0.12, every * 0.2)}})
|
|
108
|
+
return {"type": "group", "x": p.get("x", "50%"), "y": p.get("y", "50%"), "dur": every * len(kids) or 1,
|
|
109
|
+
"children": kids}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Parameters a builder reads only in some branches (so a single call may not touch them).
|
|
113
|
+
PARAMS = {"lowerThird": ("title", "subtitle", "x", "y", "accent", "size", "plate", "color", "subColor"),
|
|
114
|
+
"titleCard": ("title", "subtitle", "kicker", "size", "accent", "color", "x", "y"),
|
|
115
|
+
"callout": ("x", "y", "r", "label", "side", "accent", "size", "boxColor"),
|
|
116
|
+
"focus": ("x", "y", "w", "h", "dim", "accent", "radius"),
|
|
117
|
+
"progress": ("color", "h", "y"),
|
|
118
|
+
"kinetic": ("words", "every", "size", "motion", "color", "x", "y")}
|
|
119
|
+
|
|
120
|
+
BUILTIN = {"lowerThird": _lower_third, "titleCard": _title_card, "callout": _callout, "focus": _focus,
|
|
121
|
+
"progress": _progress, "kinetic": _kinetic}
|
|
122
|
+
COMPONENT_DOCS = {
|
|
123
|
+
"lowerThird": "title, subtitle, x [\"6%\"], y [\"78%\"] (top-left of its accent bar), accent, size [54], "
|
|
124
|
+
"color, subColor, plate (box colour, or false for none)",
|
|
125
|
+
"titleCard": "title, subtitle, kicker (small line above), x, y [\"50%\"] (centre), size [128], accent, color",
|
|
126
|
+
"callout": "x, y (centre of the ring), r [60], label, side [right] (left|right), accent, size, boxColor",
|
|
127
|
+
"focus": "x, y, w, h (top-left and size of the region to highlight), dim [0.55], accent, radius",
|
|
128
|
+
"progress": "color, h [8], y [\"100%\"] (a bar along the bottom edge that fills over the whole scene)",
|
|
129
|
+
"kinetic": "words [list], every [0.6] (seconds per word), size [180], motion [pop], color, x, y [\"50%\"] (centre)",
|
|
130
|
+
}
|
|
131
|
+
_PASS = ("id", "at", "dur", "until", "enter", "exit", "opacity", "scale", "rotate", "hidden", "z", "rotateX", "rotateY",
|
|
132
|
+
"fixed")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Params(dict):
|
|
136
|
+
"""Component parameters that remember which names the builder asked for, so a parameter
|
|
137
|
+
nobody reads (a typo such as "subtitel") is reported instead of silently dropped."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, *a, **kw):
|
|
140
|
+
super().__init__(*a, **kw)
|
|
141
|
+
self.read: set[str] = set()
|
|
142
|
+
|
|
143
|
+
def get(self, k, default=None):
|
|
144
|
+
self.read.add(k)
|
|
145
|
+
return super().get(k, default)
|
|
146
|
+
|
|
147
|
+
def __getitem__(self, k):
|
|
148
|
+
self.read.add(k)
|
|
149
|
+
return super().__getitem__(k)
|
|
150
|
+
|
|
151
|
+
def __contains__(self, k):
|
|
152
|
+
self.read.add(k)
|
|
153
|
+
return super().__contains__(k)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _unused(name: str, raw: dict, known: set[str], scene):
|
|
157
|
+
from .schema import COMMENT, suggest
|
|
158
|
+
for k in raw:
|
|
159
|
+
if k in known or k in _PASS or k in COMMENT or k in ("use", "type", "x", "y") or str(k).startswith(("_", "//")):
|
|
160
|
+
continue
|
|
161
|
+
close = suggest(k, sorted(known - set(_PASS) - {"id"}))
|
|
162
|
+
msg = (f"component '{name}' has no parameter '{k}'." + (f" Did you mean '{close}'?" if close else "")
|
|
163
|
+
+ f" Its parameters: {', '.join(sorted(known - {'id'})) or 'none'}")
|
|
164
|
+
if scene is None:
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
scene.note("error", "unknown-param", raw.get("id"), msg, f"rename or remove '{k}'")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _fill_template(v, params):
|
|
170
|
+
if isinstance(v, str):
|
|
171
|
+
m = re.fullmatch(r"\{\{(\w+)\}\}", v)
|
|
172
|
+
if m:
|
|
173
|
+
return copy.deepcopy(params.get(m.group(1), v))
|
|
174
|
+
return re.sub(r"\{\{(\w+)\}\}", lambda mm: str(params.get(mm.group(1), mm.group(0))), v)
|
|
175
|
+
if isinstance(v, list):
|
|
176
|
+
return [_fill_template(x, params) for x in v]
|
|
177
|
+
if isinstance(v, dict):
|
|
178
|
+
return {k: _fill_template(x, params) for k, x in v.items()}
|
|
179
|
+
return v
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_counter = [0]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def expand(raw, user: dict, theme: dict, scene=None):
|
|
186
|
+
if not isinstance(raw, dict):
|
|
187
|
+
return raw
|
|
188
|
+
if raw.get("type") == "sequence":
|
|
189
|
+
return _sequence(raw)
|
|
190
|
+
name = raw.get("use")
|
|
191
|
+
if not name:
|
|
192
|
+
return raw
|
|
193
|
+
_counter[0] += 1
|
|
194
|
+
params = Params(raw)
|
|
195
|
+
params.setdefault("id", f"{name}{_counter[0]}")
|
|
196
|
+
if name in user:
|
|
197
|
+
comp = user[name]
|
|
198
|
+
merged = dict(comp.get("params", {}), **params)
|
|
199
|
+
body = _fill_template(copy.deepcopy(comp.get("layers", [])), merged)
|
|
200
|
+
names = set(comp.get("params", {})) | set(re.findall(r"\{\{(\w+)\}\}", repr(comp.get("layers", []))))
|
|
201
|
+
_unused(name, raw, names, scene)
|
|
202
|
+
for i, k in enumerate(body):
|
|
203
|
+
k.setdefault("id", f"{params['id']}-{i}")
|
|
204
|
+
k["id"] = f"{params['id']}-{k['id']}" if not str(k["id"]).startswith(params["id"]) else k["id"]
|
|
205
|
+
out = {"type": "group", "x": raw.get("x", 0), "y": raw.get("y", 0), "children": body}
|
|
206
|
+
elif name in BUILTIN:
|
|
207
|
+
out = BUILTIN[name](params, theme)
|
|
208
|
+
_unused(name, raw, params.read | set(PARAMS.get(name, ())), scene)
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError(f"unknown component '{name}'. Built-in: {', '.join(BUILTIN)}; scene: {', '.join(user) or 'none'}")
|
|
211
|
+
for k in _PASS:
|
|
212
|
+
if k in raw:
|
|
213
|
+
out[k] = raw[k]
|
|
214
|
+
out.setdefault("id", params["id"])
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _sequence(raw: dict) -> list[dict]:
|
|
219
|
+
"""Cut list -> back-to-back video layers. "transition": {"type": "fade", "dur": 0.4} overlaps them."""
|
|
220
|
+
tr = raw.get("transition") or {}
|
|
221
|
+
overlap = float(tr.get("dur", 0)) if tr else 0.0
|
|
222
|
+
kind = tr.get("type", "fade") if tr else "none"
|
|
223
|
+
base = raw.get("id", "seq")
|
|
224
|
+
out = []
|
|
225
|
+
for i, clip in enumerate(raw.get("clips", [])):
|
|
226
|
+
c = dict(clip) if isinstance(clip, dict) else {"src": clip}
|
|
227
|
+
c.setdefault("type", "video")
|
|
228
|
+
c.setdefault("id", f"{base}-{i + 1}")
|
|
229
|
+
for k in ("fit", "volume", "w", "h", "x", "y", "anchor"):
|
|
230
|
+
if k in raw and k not in c:
|
|
231
|
+
c[k] = raw[k]
|
|
232
|
+
if i == 0:
|
|
233
|
+
c.setdefault("at", raw.get("at", 0))
|
|
234
|
+
else:
|
|
235
|
+
prev_id = out[-1]["id"]
|
|
236
|
+
c.setdefault("at", f"{prev_id}.out-{overlap}" if overlap else f"{prev_id}.out")
|
|
237
|
+
if overlap and kind != "cut":
|
|
238
|
+
c.setdefault("enter", {"name": {"fade": "fade", "wipe": "wipe-right", "slide": "left",
|
|
239
|
+
"zoom": "zoom", "blur": "blur"}.get(kind, "fade"),
|
|
240
|
+
"dur": overlap, "ease": "inOutSine", "dist": 120})
|
|
241
|
+
out.append(c)
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _ts(s: str) -> float:
|
|
246
|
+
h, m, rest = s.strip().replace(",", ".").split(":") if s.count(":") == 2 else ["0", *s.strip().replace(",", ".").split(":")]
|
|
247
|
+
return int(h) * 3600 + int(m) * 60 + float(rest)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_cues(raw: dict, src: str | None) -> list[list]:
|
|
251
|
+
cues = raw.get("cues")
|
|
252
|
+
if cues:
|
|
253
|
+
out = []
|
|
254
|
+
for c in cues:
|
|
255
|
+
if isinstance(c, dict):
|
|
256
|
+
out.append([float(c.get("at", c.get("start", 0))), float(c.get("end")), str(c.get("text", ""))])
|
|
257
|
+
else:
|
|
258
|
+
out.append([float(c[0]), float(c[1]), str(c[2])])
|
|
259
|
+
return out
|
|
260
|
+
if not src:
|
|
261
|
+
return []
|
|
262
|
+
text = Path(src).read_text(encoding="utf-8-sig")
|
|
263
|
+
out = []
|
|
264
|
+
for block in re.split(r"\n\s*\n", text.replace("\r", "")):
|
|
265
|
+
lines = [l for l in block.strip().split("\n") if l.strip()]
|
|
266
|
+
for j, line in enumerate(lines):
|
|
267
|
+
if "-->" in line:
|
|
268
|
+
a, b = line.split("-->")
|
|
269
|
+
body = " ".join(lines[j + 1:])
|
|
270
|
+
out.append([_ts(a), _ts(b.split()[0]), re.sub(r"<[^>]+>", "", body)])
|
|
271
|
+
break
|
|
272
|
+
return out
|