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/scene.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Scene loading: theme variables, components, sequences, cue timing, compiled properties."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import components, media, models, schema
|
|
10
|
+
from . import presets as P
|
|
11
|
+
from .props import Ctx, Prop, compile_prop
|
|
12
|
+
from .timing import Timeline
|
|
13
|
+
|
|
14
|
+
LAYER_TYPES = {"rect", "circle", "ellipse", "path", "line", "arrow", "polygon", "text", "counter",
|
|
15
|
+
"image", "video", "group", "captions", "shader", "model"}
|
|
16
|
+
STRUCT = {"id", "type", "at", "dur", "until", "enter", "exit", "children", "layers", "src", "trim",
|
|
17
|
+
"speed", "fit", "text", "cues", "d", "points", "font", "split", "format", "align", "anchor",
|
|
18
|
+
"blend", "clip", "mode", "volume", "hidden", "note", "words", "stops", "cap", "join", "loop",
|
|
19
|
+
"allowOffscreen", "style", "audio", "shadow", "box", "fx", "fill", "stroke", "emph", "active",
|
|
20
|
+
"sksl", "preset", "uniforms", "caret", "case", "italic", "smooth", "closed", "focus", "use",
|
|
21
|
+
"backface", "fixed", "sort", "shape", "detail", "parts", "light"}
|
|
22
|
+
# Property defaults by name; everything not listed defaults to None.
|
|
23
|
+
DEFAULTS = {"x": 0.0, "y": 0.0, "opacity": 1.0, "scale": 1.0, "scaleX": 1.0, "scaleY": 1.0, "rotate": 0.0,
|
|
24
|
+
"blur": 0.0, "draw": 1.0, "zoom": 1.0, "z": 0.0, "rotateX": 0.0, "rotateY": 0.0}
|
|
25
|
+
DEPTH_PROPS = ("z", "rotateX", "rotateY")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SceneError(ValueError):
|
|
29
|
+
"""A scene that cannot be built. `issues` lists every problem found, each with its layer id."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, issues: list[dict] | None = None, layer: str | None = None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.issues = issues or [{"level": "error", "code": "load", "layer": layer, "message": message, "fix": ""}]
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_issues(cls, issues: list[dict]) -> "SceneError":
|
|
37
|
+
errs = [i for i in issues if i["level"] == "error"]
|
|
38
|
+
lines = [(f"layer '{i['layer']}': " if i.get("layer") else "") + i["message"] for i in errs]
|
|
39
|
+
msg = lines[0] if len(lines) == 1 else f"{len(lines)} problems:\n " + "\n ".join(lines)
|
|
40
|
+
return cls(msg, issues)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Layer:
|
|
44
|
+
__slots__ = ("id", "type", "raw", "t_in", "t_out", "props", "enter", "exit", "children", "parent",
|
|
45
|
+
"index", "src", "video", "ctx")
|
|
46
|
+
|
|
47
|
+
def __init__(self, lid: str, ltype: str, raw: dict, parent: "Layer | None", index: int):
|
|
48
|
+
self.id, self.type, self.raw, self.parent, self.index = lid, ltype, raw, parent, index
|
|
49
|
+
self.t_in = self.t_out = 0.0
|
|
50
|
+
self.props: dict[str, Prop] = {}
|
|
51
|
+
self.enter = self.exit = None
|
|
52
|
+
self.children: list[Layer] = []
|
|
53
|
+
self.src: str | None = None
|
|
54
|
+
self.video: media.VideoSource | None = None
|
|
55
|
+
self.ctx: Ctx | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def dur(self) -> float:
|
|
59
|
+
return self.t_out - self.t_in
|
|
60
|
+
|
|
61
|
+
def get(self, name: str, t: float, T: float = 0.0, i: int = 0, default=None):
|
|
62
|
+
p = self.props.get(name)
|
|
63
|
+
if p is None:
|
|
64
|
+
return DEFAULTS.get(name, default)
|
|
65
|
+
return p.at(t, self.dur, T, i)
|
|
66
|
+
|
|
67
|
+
def visible(self, T: float) -> bool:
|
|
68
|
+
return self.t_in <= T < self.t_out and not self.raw.get("hidden")
|
|
69
|
+
|
|
70
|
+
def __repr__(self):
|
|
71
|
+
return f"<{self.type} {self.id} {self.t_in:.2f}-{self.t_out:.2f}>"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Scene:
|
|
75
|
+
def __init__(self, spec: dict, base_dir: str | Path = ".", notes: list[dict] | None = None):
|
|
76
|
+
spec, report = schema.normalize(spec)
|
|
77
|
+
self.issues: list[dict] = list(notes or []) + report.issues
|
|
78
|
+
self._raise_errors()
|
|
79
|
+
self.spec = spec
|
|
80
|
+
self.base = Path(base_dir)
|
|
81
|
+
cv = spec.get("canvas", {})
|
|
82
|
+
self.w = int(cv.get("w", cv.get("width", 1920)))
|
|
83
|
+
self.h = int(cv.get("h", cv.get("height", 1080)))
|
|
84
|
+
self.fps = float(cv.get("fps", 30))
|
|
85
|
+
self.ctx = Ctx(self.w, self.h, self.fps)
|
|
86
|
+
theme = dict(components.THEME, **spec.get("theme", {}))
|
|
87
|
+
self.theme = theme
|
|
88
|
+
self.bg = cv.get("bg", "#0a0c10")
|
|
89
|
+
self.markers = spec.get("markers", {})
|
|
90
|
+
self.grade = spec.get("grade") or {}
|
|
91
|
+
self.motion_blur = spec.get("motionBlur") or {}
|
|
92
|
+
self.audio = spec.get("audio", [])
|
|
93
|
+
raw_layers = _subst(copy.deepcopy(spec.get("layers", [])), theme)
|
|
94
|
+
self.bg = _subst(self.bg, theme)
|
|
95
|
+
user_comps = spec.get("components", {})
|
|
96
|
+
try:
|
|
97
|
+
raw_layers = [components.expand(l, user_comps, theme, self) for l in raw_layers]
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
raise SceneError(str(e)) from e
|
|
100
|
+
raw_layers = [x for l in raw_layers for x in (l if isinstance(l, list) else [l])]
|
|
101
|
+
self._raise_errors()
|
|
102
|
+
self.timeline = Timeline(self.fps, self.markers)
|
|
103
|
+
self.layers: list[Layer] = []
|
|
104
|
+
self.by_id: dict[str, Layer] = {}
|
|
105
|
+
self._build(raw_layers, None, self.layers)
|
|
106
|
+
self.camera = self._camera(_subst(spec.get("camera"), theme))
|
|
107
|
+
self.light = _subst(spec.get("light"), theme)
|
|
108
|
+
self._check_media()
|
|
109
|
+
self._raise_errors()
|
|
110
|
+
try:
|
|
111
|
+
self.duration = self.timeline.solve(cv.get("duration"))
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
lid = getattr(e, "layer", None)
|
|
114
|
+
raise SceneError((f"layer '{lid}': " if lid else "") + str(e), layer=lid) from e
|
|
115
|
+
for layer in self.by_id.values():
|
|
116
|
+
layer.t_in, layer.t_out = self.timeline.resolved[layer.id]
|
|
117
|
+
if self.camera is not None:
|
|
118
|
+
self.camera.t_out = self.duration
|
|
119
|
+
self.depth = {L.id for L in self.layers if uses_depth(L)}
|
|
120
|
+
self.is3d = self.camera is not None or bool(self.depth)
|
|
121
|
+
self.frames = max(1, round(self.duration * self.fps))
|
|
122
|
+
self._check_values()
|
|
123
|
+
self._raise_errors()
|
|
124
|
+
|
|
125
|
+
def note(self, level: str, code: str, layer, message: str, fix: str = ""):
|
|
126
|
+
self.issues.append({"level": level, "code": code, "layer": layer, "message": message, "fix": fix})
|
|
127
|
+
|
|
128
|
+
def _raise_errors(self):
|
|
129
|
+
if any(i["level"] == "error" for i in self.issues):
|
|
130
|
+
raise SceneError.from_issues(self.issues)
|
|
131
|
+
|
|
132
|
+
def _check_media(self):
|
|
133
|
+
"""Missing files fail here, before any frame is rendered, with the path that was tried."""
|
|
134
|
+
def missing(src: str, owner, what: str) -> bool:
|
|
135
|
+
p = Path(src)
|
|
136
|
+
if p.exists():
|
|
137
|
+
return False
|
|
138
|
+
near = sorted(x.name for x in p.parent.iterdir()) if p.parent.is_dir() else []
|
|
139
|
+
close = schema.suggest(p.name, near) if near else None
|
|
140
|
+
fix = (f"did you mean '{close}'? " if close else "") + (
|
|
141
|
+
f"files in {p.parent}: {', '.join(near[:12])}" if near else f"folder {p.parent} does not exist")
|
|
142
|
+
self.note("error", "media-missing", owner, f"{what} not found: {p}",
|
|
143
|
+
fix + " (paths are relative to the scene file)")
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
for layer in self.all_layers():
|
|
147
|
+
if layer.src and not missing(layer.src, layer.id, f"{layer.type} src") and layer.type == "model":
|
|
148
|
+
try:
|
|
149
|
+
models.load(layer.src)
|
|
150
|
+
except ValueError as e:
|
|
151
|
+
self.note("error", "bad-model", layer.id, f"model src {e}",
|
|
152
|
+
"export it again as glTF 2.0 (.glb) or .obj")
|
|
153
|
+
for i, a in enumerate(self.audio):
|
|
154
|
+
if isinstance(a, dict) and a.get("src") and not missing(self.path(a["src"]), f"audio[{i}]", "audio src"):
|
|
155
|
+
_check_trim(self, f"audio[{i}]", a.get("trim") or [0, None], media.probe(self.path(a["src"]))["duration"])
|
|
156
|
+
|
|
157
|
+
def _check_values(self):
|
|
158
|
+
"""Evaluate every property at the start, middle and end of its layer, so a bad value is reported
|
|
159
|
+
now, with its layer and field, instead of as a Python error halfway through a render."""
|
|
160
|
+
for layer in self.all_layers():
|
|
161
|
+
d = layer.dur
|
|
162
|
+
for name, prop in layer.props.items():
|
|
163
|
+
if name.startswith("u_"):
|
|
164
|
+
continue
|
|
165
|
+
for t in (0.0, d / 2, max(0.0, d - 1e-3), d): # d: the last keyframe value itself
|
|
166
|
+
try:
|
|
167
|
+
v = prop.at(t, d, layer.t_in + t, 0)
|
|
168
|
+
except Exception as e: # noqa: BLE001 - any failure inside a user expression
|
|
169
|
+
self.note("error", "bad-expression", layer.id, f"{name} failed at t={t:.2f}s: {e}")
|
|
170
|
+
break
|
|
171
|
+
if name in schema.COLOR_KEYS:
|
|
172
|
+
if isinstance(v, str):
|
|
173
|
+
self.note("error", "bad-colour", layer.id, f"{name} '{v}' is not a colour",
|
|
174
|
+
"use #rrggbb, #rrggbbaa, rgb(...) or a CSS colour name")
|
|
175
|
+
break
|
|
176
|
+
elif name in schema.SCALAR and (isinstance(v, bool) or not isinstance(v, (int, float))):
|
|
177
|
+
self.note("error", "bad-value", layer.id, f"{name} is {v!r} at t={t:.2f}s; expected a number",
|
|
178
|
+
"use a number, a percentage like '50%', or an expression starting with '='")
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
def path(self, src: str) -> str:
|
|
182
|
+
p = Path(src)
|
|
183
|
+
return str(p if p.is_absolute() else (self.base / p))
|
|
184
|
+
|
|
185
|
+
def _build(self, raws: list, parent: Layer | None, out: list):
|
|
186
|
+
prev = None
|
|
187
|
+
for idx, raw in enumerate(raws):
|
|
188
|
+
if not isinstance(raw, dict):
|
|
189
|
+
raise SceneError(f"layer #{idx} must be an object, got {type(raw).__name__}")
|
|
190
|
+
ltype = raw.get("type") or _infer_type(raw)
|
|
191
|
+
if ltype not in LAYER_TYPES:
|
|
192
|
+
raise SceneError(f"layer #{idx} ({raw.get('id', '?')}): unknown type '{ltype}'. "
|
|
193
|
+
f"Known: {', '.join(sorted(LAYER_TYPES))}")
|
|
194
|
+
lid = str(raw.get("id") or f"{ltype}{len(self.by_id) + 1}")
|
|
195
|
+
if lid in self.by_id:
|
|
196
|
+
raise SceneError(f"duplicate layer id '{lid}'")
|
|
197
|
+
if ltype == "captions":
|
|
198
|
+
raw = dict({"x": "50%", "y": "86%", "size": 46, "weight": 700}, **raw)
|
|
199
|
+
layer = Layer(lid, ltype, raw, parent, len(self.by_id))
|
|
200
|
+
self.by_id[lid] = layer
|
|
201
|
+
natural = None
|
|
202
|
+
if ltype in ("video", "image", "model") or (ltype == "captions" and raw.get("src")):
|
|
203
|
+
layer.src = self.path(raw["src"]) if raw.get("src") else None
|
|
204
|
+
if ltype == "video" and layer.src and Path(layer.src).exists():
|
|
205
|
+
info = media.probe(layer.src)
|
|
206
|
+
trim = raw.get("trim") or [0, None]
|
|
207
|
+
_check_trim(self, lid, trim, info["duration"])
|
|
208
|
+
end = trim[1] if len(trim) > 1 and trim[1] is not None else info["duration"]
|
|
209
|
+
end = min(float(end), info["duration"]) if info["duration"] else end
|
|
210
|
+
natural = max(0.04, (end - trim[0]) / float(raw.get("speed", 1)))
|
|
211
|
+
if ltype == "captions" and (raw.get("cues") or (layer.src and Path(layer.src).exists())):
|
|
212
|
+
raw = layer.raw = dict(raw, cues=components.load_cues(raw, layer.src))
|
|
213
|
+
if raw["cues"] and "dur" not in raw and "until" not in raw:
|
|
214
|
+
natural = max(c[1] for c in raw["cues"])
|
|
215
|
+
try:
|
|
216
|
+
self.timeline.add(lid, raw.get("at", 0), raw.get("dur"), raw.get("until"), natural,
|
|
217
|
+
prev=prev, parent=parent.id if parent else None,
|
|
218
|
+
default_end=parent.id if parent else None)
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
raise SceneError(f"layer '{lid}': {e}", layer=lid) from e
|
|
221
|
+
ctx = self.ctx
|
|
222
|
+
if ltype == "group" and ("w" in raw or "h" in raw):
|
|
223
|
+
ctx = Ctx(_num(raw.get("w"), self.w), _num(raw.get("h"), self.h), self.fps)
|
|
224
|
+
layer.ctx = ctx
|
|
225
|
+
pctx = parent.ctx if parent and parent.ctx else self.ctx
|
|
226
|
+
try:
|
|
227
|
+
for k, v in raw.items():
|
|
228
|
+
if k in ("fill", "stroke", "emph", "active") and not isinstance(v, dict):
|
|
229
|
+
layer.props[k] = compile_prop(k, v, pctx)
|
|
230
|
+
elif k not in STRUCT:
|
|
231
|
+
layer.props[k] = compile_prop(k, v, pctx)
|
|
232
|
+
for un, uv in (raw.get("uniforms") or {}).items():
|
|
233
|
+
if not isinstance(uv, (int, float, str)) or (isinstance(uv, str) and uv.startswith("=")):
|
|
234
|
+
layer.props["u_" + un] = compile_prop("u_" + un, uv, pctx)
|
|
235
|
+
layer.enter = P.parse(raw.get("enter"))
|
|
236
|
+
layer.exit = P.parse(raw.get("exit"))
|
|
237
|
+
except (ValueError, SyntaxError) as e:
|
|
238
|
+
raise SceneError(f"layer '{lid}': {e}", layer=lid) from e
|
|
239
|
+
out.append(layer)
|
|
240
|
+
kids = raw.get("children") or raw.get("layers")
|
|
241
|
+
if ltype == "group" and kids:
|
|
242
|
+
kids = [components.expand(k, self.spec.get("components", {}), self.theme, self) for k in kids]
|
|
243
|
+
kids = [x for k in kids for x in (k if isinstance(k, list) else [k])]
|
|
244
|
+
self._build(kids, layer, layer.children)
|
|
245
|
+
prev = lid
|
|
246
|
+
|
|
247
|
+
def _camera(self, raw) -> Layer | None:
|
|
248
|
+
"""The camera as a layer that spans the scene, so its keys animate like any layer's."""
|
|
249
|
+
if raw is None:
|
|
250
|
+
return None
|
|
251
|
+
cam = Layer("camera", "camera", raw, None, -1)
|
|
252
|
+
try:
|
|
253
|
+
cam.props = {k: compile_prop(k, v, self.ctx) for k, v in raw.items()}
|
|
254
|
+
except (ValueError, SyntaxError) as e:
|
|
255
|
+
raise SceneError(f"camera: {e}", layer="camera") from e
|
|
256
|
+
return cam
|
|
257
|
+
|
|
258
|
+
def all_layers(self):
|
|
259
|
+
def walk(ls):
|
|
260
|
+
for layer in ls:
|
|
261
|
+
yield layer
|
|
262
|
+
yield from walk(layer.children)
|
|
263
|
+
return walk(self.layers)
|
|
264
|
+
|
|
265
|
+
def summary(self) -> dict:
|
|
266
|
+
return {"canvas": {"w": self.w, "h": self.h, "fps": self.fps, "duration": round(self.duration, 3),
|
|
267
|
+
"frames": self.frames},
|
|
268
|
+
"layers": [{"id": l.id, "type": l.type, "in": round(l.t_in, 3), "out": round(l.t_out, 3),
|
|
269
|
+
"parent": l.parent.id if l.parent else None} for l in self.all_layers()]}
|
|
270
|
+
|
|
271
|
+
def close(self):
|
|
272
|
+
for layer in self.all_layers():
|
|
273
|
+
if layer.video:
|
|
274
|
+
layer.video.close()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def uses_depth(L: Layer) -> bool:
|
|
278
|
+
"""True when the layer or anything inside it moves in depth."""
|
|
279
|
+
motions = [m for m in (L.enter, L.exit) if m]
|
|
280
|
+
return (L.type == "model" or any(k in L.props for k in DEPTH_PROPS) or any(P.DEPTH & set(m.names) for m in motions)
|
|
281
|
+
or any(uses_depth(c) for c in L.children))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _check_trim(scene: "Scene", owner, trim, length: float):
|
|
285
|
+
if not isinstance(trim, (list, tuple)) or not trim:
|
|
286
|
+
scene.note("error", "bad-trim", owner, f"trim must be [start, end] in source seconds, got {trim!r}")
|
|
287
|
+
return
|
|
288
|
+
a = float(trim[0])
|
|
289
|
+
b = trim[1] if len(trim) > 1 else None
|
|
290
|
+
if not length:
|
|
291
|
+
return
|
|
292
|
+
if a >= length:
|
|
293
|
+
scene.note("error", "bad-trim", owner, f"trim starts at {a:g}s but the source is only {length:.2f}s long",
|
|
294
|
+
f"use a start below {length:.2f}")
|
|
295
|
+
elif b is not None and float(b) <= a:
|
|
296
|
+
scene.note("error", "bad-trim", owner, f"trim end {b} is not after its start {a:g}")
|
|
297
|
+
elif b is not None and float(b) > length + 0.05:
|
|
298
|
+
scene.note("warning", "trim-past-end", owner,
|
|
299
|
+
f"trim ends at {float(b):g}s but the source is {length:.2f}s long, so it stops at {length:.2f}s",
|
|
300
|
+
f"use [{a:g}, {length:.2f}] or leave out the end")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _num(v, ref):
|
|
304
|
+
if v is None:
|
|
305
|
+
return ref
|
|
306
|
+
if isinstance(v, str) and v.endswith("%"):
|
|
307
|
+
return float(v[:-1]) / 100 * ref
|
|
308
|
+
return float(v)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _infer_type(raw: dict) -> str:
|
|
312
|
+
for key, t in (("shape", "model"), ("parts", "model"), ("text", "text"), ("cues", "captions"), ("d", "path"),
|
|
313
|
+
("points", "path"), ("children", "group"), ("layers", "group"), ("r", "circle")):
|
|
314
|
+
if key in raw:
|
|
315
|
+
return t
|
|
316
|
+
if "src" in raw:
|
|
317
|
+
ext = Path(str(raw["src"])).suffix.lower()
|
|
318
|
+
return ("video" if ext in (".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v") else
|
|
319
|
+
"model" if ext in models.EXTENSIONS else "image")
|
|
320
|
+
return "rect"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
_VAR = re.compile(r"\$([A-Za-z_]\w*)")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _subst(v, theme: dict):
|
|
327
|
+
if isinstance(v, str):
|
|
328
|
+
if v.startswith("$") and v[1:] in theme:
|
|
329
|
+
return copy.deepcopy(theme[v[1:]])
|
|
330
|
+
if "$" in v and not v.startswith("="):
|
|
331
|
+
return _VAR.sub(lambda m: str(theme.get(m.group(1), m.group(0))), v)
|
|
332
|
+
return v
|
|
333
|
+
if isinstance(v, list):
|
|
334
|
+
return [_subst(x, theme) for x in v]
|
|
335
|
+
if isinstance(v, dict):
|
|
336
|
+
return {k: _subst(x, theme) for k, x in v.items()}
|
|
337
|
+
return v
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load(src) -> Scene:
|
|
341
|
+
"""Load a scene from a .json path, a JSON string or a dict."""
|
|
342
|
+
if isinstance(src, Scene):
|
|
343
|
+
return src
|
|
344
|
+
if isinstance(src, dict):
|
|
345
|
+
return Scene(src)
|
|
346
|
+
s = str(src)
|
|
347
|
+
if s.lstrip().startswith("{"):
|
|
348
|
+
text, base, name = s, Path("."), "scene"
|
|
349
|
+
else:
|
|
350
|
+
p = Path(s)
|
|
351
|
+
if not p.exists():
|
|
352
|
+
raise SceneError(f"scene file not found: {p}")
|
|
353
|
+
text, base, name = p.read_text(encoding="utf-8-sig"), p.parent, p.name
|
|
354
|
+
try:
|
|
355
|
+
spec, fixed = schema.loads(text)
|
|
356
|
+
except json.JSONDecodeError as e:
|
|
357
|
+
lines = text.splitlines()
|
|
358
|
+
line = lines[e.lineno - 1].strip()[:120] if 0 < e.lineno <= len(lines) else ""
|
|
359
|
+
raise SceneError(f"{name}: invalid JSON at line {e.lineno} col {e.colno}: {e.msg}. The line is: {line}") from e
|
|
360
|
+
notes = [{"level": "info", "code": "json", "layer": None, "message": f"the JSON had {', '.join(fixed)}; read anyway",
|
|
361
|
+
"fix": "write strict JSON"}] if fixed else []
|
|
362
|
+
return Scene(spec, base, notes)
|