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/schema.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Scene validation written for agents, including small ones.
|
|
2
|
+
|
|
3
|
+
`normalize(spec)` runs before a scene is built. It
|
|
4
|
+
- rewrites well-known synonyms from other tools to Kinescript's names ("color" -> "fill",
|
|
5
|
+
"fontSize" -> "size", "duration" -> "dur", ...) and reports each rewrite as an info note,
|
|
6
|
+
- rejects every key Kinescript does not read, with the closest valid name first,
|
|
7
|
+
- catches values that would only fail (or silently do nothing) at render time,
|
|
8
|
+
and reports all problems at once, each with the layer id and field, so one `kine check`
|
|
9
|
+
round fixes them all.
|
|
10
|
+
|
|
11
|
+
The key lists below are hand-kept. Their failure polarity is fail-loud: a key the renderer
|
|
12
|
+
reads but a list lacks makes `kine check` reject valid scenes, and tests/test_schema.py
|
|
13
|
+
renders the examples and the promo with a tracer and fails, naming this file, when the
|
|
14
|
+
renderer reads a key that is missing here.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import difflib
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from . import color as C
|
|
23
|
+
|
|
24
|
+
# Keys that are always allowed and ignored: notes to self.
|
|
25
|
+
COMMENT = {"note", "notes", "comment", "comments", "description", "name"}
|
|
26
|
+
|
|
27
|
+
TOP = {"canvas", "theme", "markers", "grade", "motionBlur", "audio", "components", "layers", "meta", "version",
|
|
28
|
+
"$schema", "camera", "light"}
|
|
29
|
+
TOP_ALIAS = {"layer": "layers", "elements": "layers", "objects": "layers", "items": "layers", "scene": "layers",
|
|
30
|
+
"children": "layers", "tracks": "layers", "motion_blur": "motionBlur", "colorGrade": "grade",
|
|
31
|
+
"music": "audio", "sound": "audio", "marker": "markers"}
|
|
32
|
+
CANVAS = {"w", "h", "fps", "bg", "duration"}
|
|
33
|
+
CANVAS_ALIAS = {"width": "w", "height": "h", "background": "bg", "backgroundColor": "bg", "background_color": "bg",
|
|
34
|
+
"bgColor": "bg", "color": "bg", "fill": "bg", "frameRate": "fps", "framerate": "fps",
|
|
35
|
+
"frame_rate": "fps", "length": "duration", "dur": "duration", "seconds": "duration"}
|
|
36
|
+
FX = {"brightness", "contrast", "saturate", "saturation", "hue", "exposure", "temperature", "tint", "gray", "invert"}
|
|
37
|
+
GRADE = FX | {"vignette", "grain"}
|
|
38
|
+
SHADOW = {"x", "y", "blur", "color"}
|
|
39
|
+
SHADOW_ALIAS = {"colour": "color", "fill": "color", "offsetX": "x", "offsetY": "y", "dx": "x", "dy": "y",
|
|
40
|
+
"radius": "blur", "spread": "blur"}
|
|
41
|
+
BOX = {"fill", "pad", "radius"}
|
|
42
|
+
BOX_ALIAS = {"color": "fill", "colour": "fill", "background": "fill", "bg": "fill", "padding": "pad",
|
|
43
|
+
"borderRadius": "radius", "cornerRadius": "radius"}
|
|
44
|
+
MOTION = {"name", "dur", "ease", "stagger", "dist", "from", "amount", "deg"}
|
|
45
|
+
MOTION_ALIAS = {"type": "name", "preset": "name", "duration": "dur", "easing": "ease", "distance": "dist",
|
|
46
|
+
"delay": "stagger"}
|
|
47
|
+
TWEEN = {"from", "to", "dur", "delay", "ease", "keys"}
|
|
48
|
+
TWEEN_ALIAS = {"duration": "dur", "easing": "ease", "start": "from", "end": "to", "begin": "from"}
|
|
49
|
+
GRADIENT = {"linear", "radial", "stops"}
|
|
50
|
+
AUDIO = {"src", "at", "trim", "gain", "volume", "fadeIn", "fadeOut"}
|
|
51
|
+
AUDIO_ALIAS = {"source": "src", "file": "src", "path": "src", "start": "at", "fade_in": "fadeIn",
|
|
52
|
+
"fade_out": "fadeOut", "fadein": "fadeIn", "fadeout": "fadeOut"}
|
|
53
|
+
MOTION_BLUR = {"samples", "shutter"}
|
|
54
|
+
CAMERA = {"x", "y", "z", "rotateX", "rotateY", "rotate", "fov"}
|
|
55
|
+
CAMERA_ALIAS = {"rotationX": "rotateX", "rotationY": "rotateY", "rotateZ": "rotate", "rotationZ": "rotate",
|
|
56
|
+
"rotation": "rotate", "roll": "rotate", "orbit": "rotateY", "tilt": "rotateX", "dolly": "z",
|
|
57
|
+
"fieldOfView": "fov"}
|
|
58
|
+
LIGHT = {"preset", "key", "fill", "ambient", "rim", "from", "color"}
|
|
59
|
+
LIGHT_ALIAS = {"colour": "color", "direction": "from", "dir": "from", "position": "from", "intensity": "key",
|
|
60
|
+
"ambience": "ambient", "name": "preset"}
|
|
61
|
+
MATERIAL = {"fill", "shine", "metal", "rim", "bands"}
|
|
62
|
+
PART = MATERIAL | {"shape", "w", "h", "depth", "r", "tube", "x", "y", "z", "rotateX", "rotateY", "rotate", "scale",
|
|
63
|
+
"text", "size", "font", "weight", "italic", "letterSpacing", "lineHeight", "d", "strokeWidth",
|
|
64
|
+
"bevel"}
|
|
65
|
+
MODEL_ALIAS = {"radius": "r", "thickness": "tube", "geometry": "shape", "primitive": "shape", "extrude": "depth",
|
|
66
|
+
"extrusion": "depth", "chamfer": "bevel",
|
|
67
|
+
"mesh": "shape", "gloss": "shine", "glossiness": "shine", "shininess": "shine",
|
|
68
|
+
"specular": "shine", "metallic": "metal", "metalness": "metal", "toon": "bands", "steps": "bands",
|
|
69
|
+
"quality": "detail", "lighting": "light", "children": "parts", "meshes": "parts", "model": "src",
|
|
70
|
+
"floor": "ground", "groundShadow": "ground", "contactShadow": "ground"}
|
|
71
|
+
GROUND = {"y", "opacity", "blur"}
|
|
72
|
+
GROUND_ALIAS = {"alpha": "opacity", "strength": "opacity", "softness": "blur", "radius": "blur", "level": "y"}
|
|
73
|
+
|
|
74
|
+
COMMON = {"id", "type", "at", "dur", "until", "x", "y", "anchor", "opacity", "scale", "scaleX", "scaleY", "rotate",
|
|
75
|
+
"blur", "blend", "shadow", "fx", "enter", "exit", "hidden", "allowOffscreen", "z", "rotateX", "rotateY",
|
|
76
|
+
"backface", "fixed"}
|
|
77
|
+
SHAPE = {"fill", "stroke", "strokeWidth", "draw", "drawStart", "cap", "join"}
|
|
78
|
+
TEXT = {"text", "size", "weight", "font", "italic", "fill", "emph", "align", "maxWidth", "lineHeight",
|
|
79
|
+
"letterSpacing", "stroke", "strokeWidth", "box", "case", "textCase", "caret", "split",
|
|
80
|
+
"unitX", "unitY", "unitRotate", "unitScale", "unitOpacity"}
|
|
81
|
+
MEDIA = {"src", "w", "h", "fit", "zoom", "focus", "focusX", "focusY", "radius", "stroke", "strokeWidth", "cap", "join"}
|
|
82
|
+
TYPES: dict[str, set[str]] = {
|
|
83
|
+
"rect": SHAPE | {"w", "h", "radius", "dimOutside"},
|
|
84
|
+
"circle": SHAPE | {"r"},
|
|
85
|
+
"ellipse": SHAPE | {"w", "h"},
|
|
86
|
+
"polygon": SHAPE | {"sides", "r", "inner"},
|
|
87
|
+
"path": SHAPE | {"d", "points", "smooth", "closed"},
|
|
88
|
+
"line": SHAPE | {"points", "smooth", "closed"},
|
|
89
|
+
"arrow": SHAPE | {"x1", "y1", "x2", "y2", "bend", "head"},
|
|
90
|
+
"text": TEXT,
|
|
91
|
+
"counter": TEXT | {"value", "format"},
|
|
92
|
+
"image": MEDIA,
|
|
93
|
+
"video": MEDIA | {"trim", "speed", "volume", "loop"},
|
|
94
|
+
"captions": TEXT | {"src", "cues", "mode", "active"},
|
|
95
|
+
"shader": {"preset", "uniforms", "sksl", "w", "h"},
|
|
96
|
+
"group": {"children", "layers", "w", "h", "clip", "radius", "fill", "sort"},
|
|
97
|
+
"model": PART - {"x", "y", "z", "rotateX", "rotateY", "rotate", "scale"} | {"detail", "parts", "explode",
|
|
98
|
+
"draw", "light", "src", "ground"},
|
|
99
|
+
}
|
|
100
|
+
SEQUENCE = {"id", "type", "at", "clips", "transition", "fit", "volume", "w", "h", "x", "y", "anchor"}
|
|
101
|
+
TYPE_ALIAS = {"rectangle": "rect", "square": "rect", "box": "rect", "shape": "rect", "txt": "text", "label": "text",
|
|
102
|
+
"title": "text", "heading": "text", "string": "text", "img": "image", "picture": "image",
|
|
103
|
+
"photo": "image", "oval": "ellipse", "caption": "captions", "subtitles": "captions",
|
|
104
|
+
"subtitle": "captions", "number": "counter", "count": "counter", "movie": "video", "clip": "video",
|
|
105
|
+
"svg": "path", "polyline": "line", "star": "polygon", "background": "shader", "container": "group",
|
|
106
|
+
"frame": "group", "mesh": "model", "3d": "model", "object": "model", "primitive": "model",
|
|
107
|
+
"solid": "model"}
|
|
108
|
+
# Synonyms other tools use. A per-type entry wins over the general one.
|
|
109
|
+
ALIAS = {"color": "fill", "colour": "fill", "fillColor": "fill", "backgroundColor": "fill", "background": "fill",
|
|
110
|
+
"fontSize": "size", "font_size": "size", "textSize": "size", "fontWeight": "weight",
|
|
111
|
+
"font_weight": "weight", "fontFamily": "font", "font_family": "font", "fontStyle": "italic",
|
|
112
|
+
"duration": "dur", "length": "dur", "start": "at", "startTime": "at", "start_time": "at",
|
|
113
|
+
"begin": "at", "delay": "at", "time": "at", "end": "until", "endTime": "until", "end_time": "until",
|
|
114
|
+
"width": "w", "height": "h", "rotation": "rotate", "angle": "rotate", "content": "text",
|
|
115
|
+
"alpha": "opacity", "borderRadius": "radius", "cornerRadius": "radius", "border_radius": "radius",
|
|
116
|
+
"lineWidth": "strokeWidth", "stroke_width": "strokeWidth", "borderWidth": "strokeWidth",
|
|
117
|
+
"strokeColor": "stroke", "borderColor": "stroke", "source": "src", "file": "src", "url": "src",
|
|
118
|
+
"textAlign": "align", "text_align": "align", "letter_spacing": "letterSpacing", "tracking": "letterSpacing",
|
|
119
|
+
"line_height": "lineHeight", "leading": "lineHeight", "max_width": "maxWidth", "wrap": "maxWidth",
|
|
120
|
+
"animation": "enter", "animate": "enter", "entrance": "enter", "in": "enter", "intro": "enter",
|
|
121
|
+
"out": "exit", "outro": "exit", "exitAnimation": "exit", "transform": None, "kids": "children",
|
|
122
|
+
"items": "children", "elements": "children", "visible": None, "zIndex": None,
|
|
123
|
+
"rotationX": "rotateX", "rotationY": "rotateY", "rotateZ": "rotate", "rotationZ": "rotate",
|
|
124
|
+
"translateZ": "z", "backfaceVisibility": "backface"}
|
|
125
|
+
TYPE_ALIASES = {
|
|
126
|
+
"circle": {"radius": "r", "size": "r"}, "polygon": {"radius": "r", "points": "sides"},
|
|
127
|
+
"line": {"color": "stroke", "colour": "stroke", "fill": "stroke", "width": "strokeWidth"},
|
|
128
|
+
"arrow": {"color": "stroke", "colour": "stroke", "fill": "stroke", "width": "strokeWidth"},
|
|
129
|
+
"image": {"path": "src", "image": "src"}, "video": {"path": "src", "video": "src", "mute": None},
|
|
130
|
+
"counter": {"number": "value", "to": "value"},
|
|
131
|
+
"captions": {"subtitles": "cues", "lines": "cues"},
|
|
132
|
+
"shader": {"shader": "preset", "code": "sksl", "source": "sksl"},
|
|
133
|
+
"model": MODEL_ALIAS,
|
|
134
|
+
}
|
|
135
|
+
ENUMS = {
|
|
136
|
+
"anchor": ("center", "topleft", "top", "topright", "left", "right", "bottomleft", "bottom", "bottomright"),
|
|
137
|
+
"align": ("left", "center", "right"),
|
|
138
|
+
"split": ("chars", "words", "lines"),
|
|
139
|
+
"fit": ("cover", "contain", "stretch"),
|
|
140
|
+
"case": ("upper", "lower"), "textCase": ("upper", "lower"),
|
|
141
|
+
"cap": ("butt", "round", "square"), "join": ("miter", "round", "bevel"),
|
|
142
|
+
"mode": ("karaoke", "pop"),
|
|
143
|
+
"blend": ("normal", "multiply", "screen", "overlay", "add", "lighten", "darken", "softLight", "difference",
|
|
144
|
+
"color"),
|
|
145
|
+
"backface": ("visible", "hidden"),
|
|
146
|
+
"sort": ("order", "depth"),
|
|
147
|
+
"shape": ("cube", "sphere", "cylinder", "cone", "torus", "plane", "text", "path"),
|
|
148
|
+
"detail": ("low", "medium", "high"),
|
|
149
|
+
}
|
|
150
|
+
ENUM_ALIAS = {"middle": "center", "centre": "center", "centered": "center", "mid": "center",
|
|
151
|
+
"characters": "chars", "letters": "chars", "char": "chars", "word": "words", "line": "lines",
|
|
152
|
+
"uppercase": "upper", "lowercase": "lower", "fill": "cover", "crop": "cover", "scale": "contain",
|
|
153
|
+
"plus": "add", "additive": "add", "soft-light": "softLight", "softlight": "softLight",
|
|
154
|
+
"box": "cube", "cuboid": "cube", "ball": "sphere", "globe": "sphere", "donut": "torus",
|
|
155
|
+
"doughnut": "torus", "ring": "torus", "tube": "cylinder", "can": "cylinder", "pipe": "cylinder",
|
|
156
|
+
"card": "plane", "quad": "plane", "svg": "path", "string": "text", "med": "medium", "hi": "high",
|
|
157
|
+
"lo": "low"}
|
|
158
|
+
WEIGHTS = {"thin": 100, "extralight": 200, "light": 300, "normal": 400, "regular": 400, "book": 400,
|
|
159
|
+
"medium": 500, "semibold": 600, "demibold": 600, "bold": 700, "extrabold": 800, "heavy": 900,
|
|
160
|
+
"black": 900}
|
|
161
|
+
COLOR_KEYS = {"fill", "stroke", "emph", "active"}
|
|
162
|
+
# Objects some tools nest animated values under; their keys belong on the layer itself.
|
|
163
|
+
WRAPPERS = {"tween", "tweens", "keyframes", "animate", "animation", "animations", "props", "properties", "style"}
|
|
164
|
+
# Props whose value must be one number (after % and expressions are resolved).
|
|
165
|
+
SCALAR = {"x", "y", "w", "h", "r", "opacity", "scale", "scaleX", "scaleY", "rotate", "blur", "size", "weight",
|
|
166
|
+
"strokeWidth", "draw", "drawStart", "zoom", "value", "radius", "dimOutside", "sides", "inner", "x1", "y1",
|
|
167
|
+
"x2", "y2", "bend", "head", "maxWidth", "lineHeight", "letterSpacing", "focusX", "focusY", "speed",
|
|
168
|
+
"volume", "unitX", "unitY", "unitRotate", "unitScale", "unitOpacity", "z", "rotateX", "rotateY", "fov",
|
|
169
|
+
"depth", "tube", "shine", "metal", "rim", "bands", "explode", "bevel"}
|
|
170
|
+
REQUIRED = {"text": ("text",), "image": ("src",), "video": ("src",), "path": ("d", "points"), "line": ("points",),
|
|
171
|
+
"captions": ("cues", "src"), "shader": ("preset", "sksl"), "model": ("shape", "parts", "src")}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Report:
|
|
175
|
+
def __init__(self):
|
|
176
|
+
self.issues: list[dict] = []
|
|
177
|
+
|
|
178
|
+
def add(self, level, code, layer, message, fix=""):
|
|
179
|
+
self.issues.append({"level": level, "code": code, "layer": layer, "message": message, "fix": fix})
|
|
180
|
+
|
|
181
|
+
def errors(self):
|
|
182
|
+
return [i for i in self.issues if i["level"] == "error"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def suggest(word: str, options) -> str | None:
|
|
186
|
+
opts = list(options)
|
|
187
|
+
low = {o.lower(): o for o in opts}
|
|
188
|
+
w = str(word).strip()
|
|
189
|
+
if w.lower() in low:
|
|
190
|
+
return low[w.lower()]
|
|
191
|
+
squashed = {re.sub(r"[-_ ]", "", o.lower()): o for o in opts}
|
|
192
|
+
if re.sub(r"[-_ ]", "", w.lower()) in squashed:
|
|
193
|
+
return squashed[re.sub(r"[-_ ]", "", w.lower())]
|
|
194
|
+
m = difflib.get_close_matches(w, opts, n=1, cutoff=0.6) or difflib.get_close_matches(w.lower(), list(low), 1, 0.6)
|
|
195
|
+
return (low.get(m[0], m[0])) if m else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def unknown(kind: str, word, options) -> str:
|
|
199
|
+
"""'unknown motion 'slid'. Did you mean 'slide-up'? Known: ...' with the suggestion first."""
|
|
200
|
+
s = suggest(word, options)
|
|
201
|
+
head = f"unknown {kind} '{word}'."
|
|
202
|
+
if s:
|
|
203
|
+
head += f" Did you mean '{s}'?"
|
|
204
|
+
return f"{head} Known: {', '.join(sorted(options))}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _keys(obj: dict, allowed: set, alias: dict, rep: Report, lid, where: str, extra_ok=()):
|
|
208
|
+
"""Rename aliases in place and report unknown keys of one object."""
|
|
209
|
+
for k in list(obj):
|
|
210
|
+
if k in allowed or k in COMMENT or k in extra_ok or str(k).startswith(("_", "//", "$")):
|
|
211
|
+
continue
|
|
212
|
+
if k in alias and (alias[k] is None or alias[k] in allowed):
|
|
213
|
+
to = alias[k]
|
|
214
|
+
if to is None:
|
|
215
|
+
del obj[k]
|
|
216
|
+
rep.add("info", "ignored-key", lid, f"{where}'{k}' has no meaning in Kinescript and was ignored")
|
|
217
|
+
continue
|
|
218
|
+
if to in obj:
|
|
219
|
+
rep.add("error", "duplicate-key", lid, f"{where}both '{k}' and '{to}' are set",
|
|
220
|
+
f"keep only '{to}'")
|
|
221
|
+
continue
|
|
222
|
+
obj[to] = obj.pop(k)
|
|
223
|
+
rep.add("info", "renamed-key", lid, f"{where}'{k}' read as '{to}'", f"write '{to}'")
|
|
224
|
+
continue
|
|
225
|
+
s = suggest(k, allowed)
|
|
226
|
+
rep.add("error", "unknown-key", lid,
|
|
227
|
+
f"{where}unknown key '{k}'." + (f" Did you mean '{s}'?" if s else "")
|
|
228
|
+
+ f" Valid here: {', '.join(sorted(allowed))}", f"rename or remove '{k}'")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _enum(raw: dict, key: str, rep: Report, lid):
|
|
232
|
+
v = raw.get(key)
|
|
233
|
+
if not isinstance(v, str):
|
|
234
|
+
return
|
|
235
|
+
opts = ENUMS[key]
|
|
236
|
+
if v in opts:
|
|
237
|
+
return
|
|
238
|
+
low = re.sub(r"[-_ ]", "", v.strip().lower())
|
|
239
|
+
if key == "anchor":
|
|
240
|
+
low = low.replace("middle", "center").replace("centre", "center").replace("upper", "top").replace(
|
|
241
|
+
"lower", "bottom")
|
|
242
|
+
for a, b in (("lefttop", "topleft"), ("righttop", "topright"), ("leftbottom", "bottomleft"),
|
|
243
|
+
("rightbottom", "bottomright"), ("topcenter", "top"), ("centertop", "top"),
|
|
244
|
+
("bottomcenter", "bottom"), ("centerbottom", "bottom"), ("centerleft", "left"),
|
|
245
|
+
("leftcenter", "left"), ("centerright", "right"), ("rightcenter", "right"),
|
|
246
|
+
("centercenter", "center")):
|
|
247
|
+
low = low.replace(a, b)
|
|
248
|
+
hit = ENUM_ALIAS.get(low, low)
|
|
249
|
+
if hit not in opts:
|
|
250
|
+
hit = next((o for o in opts if o.lower() == hit), None)
|
|
251
|
+
if hit:
|
|
252
|
+
raw[key] = hit
|
|
253
|
+
rep.add("info", "renamed-value", lid, f"{key} '{v}' read as '{hit}'", f"write '{hit}'")
|
|
254
|
+
return
|
|
255
|
+
rep.add("error", "bad-value", lid, f"{key}: " + unknown("value", v, opts))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_DEG = re.compile(r"^\s*(-?\d+(?:\.\d+)?)\s*(deg|°|rad|turn)\s*$")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _values(raw: dict, ltype: str, rep: Report, lid):
|
|
262
|
+
"""Fix values that have one obvious meaning; reject the ones that would fail later."""
|
|
263
|
+
for k in ("x", "y"):
|
|
264
|
+
v = raw.get(k)
|
|
265
|
+
if isinstance(v, str) and v.strip().lower() in ("center", "centre", "middle"):
|
|
266
|
+
raw[k] = "50%"
|
|
267
|
+
rep.add("info", "renamed-value", lid, f"{k} '{v}' read as '50%'", f"write \"{k}\": \"50%\"")
|
|
268
|
+
for k in ("rotate", "rotateX", "rotateY"):
|
|
269
|
+
v = raw.get(k)
|
|
270
|
+
if isinstance(v, str) and (m := _DEG.match(v)):
|
|
271
|
+
num, unit = float(m.group(1)), m.group(2)
|
|
272
|
+
raw[k] = num * {"rad": 57.29577951308232, "turn": 360.0}.get(unit, 1.0)
|
|
273
|
+
rep.add("info", "renamed-value", lid, f"{k} '{v}' read as {raw[k]:g} (degrees)",
|
|
274
|
+
f"{k} takes a number of degrees")
|
|
275
|
+
v = raw.get("opacity")
|
|
276
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool) and 1 < v <= 100:
|
|
277
|
+
raw["opacity"] = v / 100
|
|
278
|
+
rep.add("info", "renamed-value", lid, f"opacity {v} read as {v / 100:g}", "opacity runs from 0 to 1")
|
|
279
|
+
v = raw.get("weight")
|
|
280
|
+
if isinstance(v, str) and not v.startswith("="):
|
|
281
|
+
w = WEIGHTS.get(re.sub(r"[-_ ]", "", v.lower()))
|
|
282
|
+
if w:
|
|
283
|
+
raw["weight"] = w
|
|
284
|
+
rep.add("info", "renamed-value", lid, f"weight '{v}' read as {w}", "weight takes 100..900")
|
|
285
|
+
for k in ("italic", "hidden", "clip", "loop", "smooth", "closed", "allowOffscreen", "fixed"):
|
|
286
|
+
if isinstance(raw.get(k), str) and raw[k].lower() in ("true", "false", "yes", "no"):
|
|
287
|
+
raw[k] = raw[k].lower() in ("true", "yes")
|
|
288
|
+
if isinstance(raw.get("italic"), str) and raw["italic"].lower() in ("italic", "oblique", "normal"):
|
|
289
|
+
raw["italic"] = raw["italic"].lower() != "normal"
|
|
290
|
+
for key in ENUMS:
|
|
291
|
+
_enum(raw, key, rep, lid)
|
|
292
|
+
for k in ("dur",):
|
|
293
|
+
v = raw.get(k)
|
|
294
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool) and v <= 0:
|
|
295
|
+
rep.add("error", "bad-dur", lid, f"dur must be greater than 0 (got {v})",
|
|
296
|
+
"give it a positive number of seconds, or remove dur to run to the end; to hide a layer use \"hidden\": true")
|
|
297
|
+
for k, v in raw.items():
|
|
298
|
+
if k in SCALAR and isinstance(v, list) and v and all(isinstance(x, (int, float)) for x in v):
|
|
299
|
+
rep.add("error", "flat-keyframes", lid,
|
|
300
|
+
f"{k}: {v} is a flat list. Keyframes are a list of [time, value] pairs",
|
|
301
|
+
f"\"{k}\": [[0, {v[0]}], [\"end\", {v[-1]}]]")
|
|
302
|
+
elif k in SCALAR and isinstance(v, str) and not v.startswith(("=", "$")) and not _lengthish(v):
|
|
303
|
+
rep.add("error", "bad-value", lid, f"{k} is '{v}', which is not a number",
|
|
304
|
+
f"use a number{', a percentage like 50%' if k in ('x', 'y', 'w', 'h', 'maxWidth') else ''} "
|
|
305
|
+
"or an expression starting with '='")
|
|
306
|
+
elif k in SCALAR and isinstance(v, bool):
|
|
307
|
+
rep.add("error", "bad-value", lid, f"{k} is {str(v).lower()}, which is not a number", "use a number")
|
|
308
|
+
elif k in ("fill", "stroke") and isinstance(v, str) and (g := css_gradient(v)) is not None:
|
|
309
|
+
raw[k] = g
|
|
310
|
+
rep.add("info", "renamed-value", lid, f"{k}: CSS gradient read as {g}", "write the gradient object")
|
|
311
|
+
elif k in COLOR_KEYS and isinstance(v, str) and not v.startswith(("=", "$")) and not C.is_color(v):
|
|
312
|
+
rep.add("error", "bad-colour", lid, f"{k} '{v}' is not a colour",
|
|
313
|
+
"use #rrggbb, #rrggbbaa, rgb(...), or a CSS colour name such as 'red'")
|
|
314
|
+
elif isinstance(v, dict) and ("to" in v or "keys" in v or "from" in v):
|
|
315
|
+
_keys(v, TWEEN, TWEEN_ALIAS, rep, lid, f"{k} tween: ")
|
|
316
|
+
elif k in ("fill", "stroke") and isinstance(v, dict):
|
|
317
|
+
_keys(v, GRADIENT, {"colors": "stops", "colours": "stops"}, rep, lid, f"{k} gradient: ")
|
|
318
|
+
if "stops" not in v:
|
|
319
|
+
rep.add("error", "bad-value", lid, f"{k}: a gradient needs 'stops'",
|
|
320
|
+
'{"linear": [[0, 0], [1, 1]], "stops": ["#ff633f", "#b9f45d"]}')
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
_CSS_DIR = {"to right": ([0, 0.5], [1, 0.5]), "to left": ([1, 0.5], [0, 0.5]), "to bottom": ([0.5, 0], [0.5, 1]),
|
|
324
|
+
"to top": ([0.5, 1], [0.5, 0]), "to bottom right": ([0, 0], [1, 1]), "to top left": ([1, 1], [0, 0]),
|
|
325
|
+
"to top right": ([0, 1], [1, 0]), "to bottom left": ([1, 0], [0, 1])}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def css_gradient(v: str) -> dict | None:
|
|
329
|
+
"""'linear-gradient(90deg, #00f, #808)' or 'radial-gradient(#fff, #000)' -> a gradient object."""
|
|
330
|
+
import math
|
|
331
|
+
m = re.fullmatch(r"\s*(linear|radial)-gradient\((.*)\)\s*", v, re.S)
|
|
332
|
+
if not m:
|
|
333
|
+
return None
|
|
334
|
+
parts = [p.strip() for p in re.split(r",(?![^(]*\))", m.group(2)) if p.strip()]
|
|
335
|
+
pts = ([0.5, 0], [0.5, 1]) # CSS default: top to bottom
|
|
336
|
+
if parts and (parts[0] in _CSS_DIR or re.fullmatch(r"-?\d+(\.\d+)?deg", parts[0])):
|
|
337
|
+
d = parts.pop(0)
|
|
338
|
+
if d in _CSS_DIR:
|
|
339
|
+
pts = _CSS_DIR[d]
|
|
340
|
+
else:
|
|
341
|
+
a = math.radians(float(d[:-3]))
|
|
342
|
+
dx, dy = math.sin(a) / 2, -math.cos(a) / 2
|
|
343
|
+
pts = ([round(0.5 - dx, 3), round(0.5 - dy, 3)], [round(0.5 + dx, 3), round(0.5 + dy, 3)])
|
|
344
|
+
stops = []
|
|
345
|
+
for p in parts:
|
|
346
|
+
mm = re.fullmatch(r"(.+?)\s+(\d+(?:\.\d+)?)%", p)
|
|
347
|
+
col, pos = (mm.group(1), float(mm.group(2)) / 100) if mm else (p, None)
|
|
348
|
+
if not C.is_color(col):
|
|
349
|
+
return None
|
|
350
|
+
stops.append(col if pos is None else [pos, col])
|
|
351
|
+
if len(stops) < 2:
|
|
352
|
+
return None
|
|
353
|
+
if any(isinstance(x, list) for x in stops):
|
|
354
|
+
n = len(stops)
|
|
355
|
+
stops = [x if isinstance(x, list) else [i / (n - 1), x] for i, x in enumerate(stops)]
|
|
356
|
+
return {"radial": [0.5, 0.5, 0.7], "stops": stops} if m.group(1) == "radial" else {"linear": list(pts), "stops": stops}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_NUMLIKE = re.compile(r"^\s*-?\d+(\.\d+)?\s*(%|vw|vh|vmin|px)?\s*([+-]\s*\d+(\.\d+)?\s*(px)?)?\s*$")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _lengthish(v: str) -> bool:
|
|
363
|
+
return bool(_NUMLIKE.match(v))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _motion(raw: dict, key: str, rep: Report, lid):
|
|
367
|
+
from .presets import PRESETS, canonical
|
|
368
|
+
v = raw.get(key)
|
|
369
|
+
if v in (None, False, "", "none"):
|
|
370
|
+
return
|
|
371
|
+
spec = {"name": v} if isinstance(v, str) else v
|
|
372
|
+
if not isinstance(spec, dict):
|
|
373
|
+
rep.add("error", "bad-value", lid, f"{key} must be a motion name like \"rise\" or an object "
|
|
374
|
+
f"{{\"name\": \"rise\", \"dur\": 0.5}}, got {type(v).__name__}")
|
|
375
|
+
return
|
|
376
|
+
if isinstance(v, dict):
|
|
377
|
+
_keys(v, MOTION, MOTION_ALIAS, rep, lid, f"{key}: ")
|
|
378
|
+
for n in str(spec.get("name", "fade")).split("+"):
|
|
379
|
+
n = n.strip()
|
|
380
|
+
if n and canonical(n) is None:
|
|
381
|
+
rep.add("error", "bad-motion", lid, f"{key}: " + unknown("motion", n, PRESETS))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _layer(raw, rep: Report, path: str, comps: dict):
|
|
385
|
+
if not isinstance(raw, dict):
|
|
386
|
+
rep.add("error", "bad-layer", None, f"{path} must be an object, got {type(raw).__name__}")
|
|
387
|
+
return
|
|
388
|
+
lid = raw.get("id") or path
|
|
389
|
+
if "use" not in raw and isinstance(raw.get("type"), str):
|
|
390
|
+
from .components import BUILTIN
|
|
391
|
+
name = suggest(raw["type"], BUILTIN)
|
|
392
|
+
if name and name.lower() == raw["type"].strip().lower():
|
|
393
|
+
raw["use"] = name
|
|
394
|
+
del raw["type"]
|
|
395
|
+
rep.add("info", "renamed-key", lid, f"type '{name}' is a component; read as \"use\": \"{name}\"",
|
|
396
|
+
f"write \"use\": \"{name}\"")
|
|
397
|
+
for wrap in [k for k in raw if k in WRAPPERS and isinstance(raw[k], dict)
|
|
398
|
+
and not {"name", "type", "preset"} & set(raw[k])]: # a motion object is not a wrapper
|
|
399
|
+
inner = raw.pop(wrap)
|
|
400
|
+
for k, v in inner.items():
|
|
401
|
+
if k in raw:
|
|
402
|
+
rep.add("error", "duplicate-key", lid, f"'{k}' is set both directly and inside '{wrap}'")
|
|
403
|
+
else:
|
|
404
|
+
raw[k] = v
|
|
405
|
+
rep.add("info", "renamed-key", lid, f"'{wrap}' unwrapped: its keys ({', '.join(inner)}) are layer keys",
|
|
406
|
+
"put animated values directly on the layer, e.g. \"opacity\": {\"from\": 0, \"to\": 1}")
|
|
407
|
+
if "use" in raw:
|
|
408
|
+
from .components import BUILTIN
|
|
409
|
+
name = raw["use"]
|
|
410
|
+
if name not in BUILTIN and name not in comps:
|
|
411
|
+
s = suggest(name, list(BUILTIN) + list(comps))
|
|
412
|
+
if s and s.lower() == str(name).lower():
|
|
413
|
+
raw["use"] = s
|
|
414
|
+
rep.add("info", "renamed-value", lid, f"component '{name}' read as '{s}'", f"write '{s}'")
|
|
415
|
+
else:
|
|
416
|
+
rep.add("error", "bad-component", lid, unknown("component", name, list(BUILTIN) + list(comps)))
|
|
417
|
+
for key in ("enter", "exit"):
|
|
418
|
+
_motion(raw, key, rep, lid)
|
|
419
|
+
return # parameters are checked when the component expands (components.Params)
|
|
420
|
+
t = raw.get("type")
|
|
421
|
+
if isinstance(t, str) and t.strip().lower() in ENUMS["shape"] and t not in TYPES and "shape" not in raw:
|
|
422
|
+
raw["type"], raw["shape"] = "model", t.strip().lower()
|
|
423
|
+
rep.add("info", "renamed-value", lid, f"type '{t}' read as a model with shape '{raw['shape']}'",
|
|
424
|
+
f"write \"type\": \"model\", \"shape\": \"{raw['shape']}\"")
|
|
425
|
+
t = "model"
|
|
426
|
+
if isinstance(t, str) and t not in TYPES and t != "sequence":
|
|
427
|
+
s = t.strip()
|
|
428
|
+
fixed = s if s in TYPES else TYPE_ALIAS.get(s.lower()) or (s.lower() if s.lower() in TYPES else None)
|
|
429
|
+
if fixed:
|
|
430
|
+
raw["type"] = fixed
|
|
431
|
+
rep.add("info", "renamed-value", lid, f"type '{t}' read as '{fixed}'", f"write \"type\": \"{fixed}\"")
|
|
432
|
+
t = fixed
|
|
433
|
+
else:
|
|
434
|
+
rep.add("error", "bad-type", lid, unknown("layer type", t, list(TYPES) + ["sequence"]))
|
|
435
|
+
return
|
|
436
|
+
if t == "sequence":
|
|
437
|
+
_keys(raw, SEQUENCE, {"items": "clips", "videos": "clips", "children": "clips"}, rep, lid, "")
|
|
438
|
+
return
|
|
439
|
+
if t is None:
|
|
440
|
+
from .scene import _infer_type
|
|
441
|
+
t = _infer_type(raw)
|
|
442
|
+
lalias = dict(ALIAS, **TYPE_ALIASES.get(t, {}))
|
|
443
|
+
if t == "group":
|
|
444
|
+
lalias.pop("items", None)
|
|
445
|
+
pos = raw.get("position") or raw.get("pos")
|
|
446
|
+
if pos is not None and "x" not in raw and "y" not in raw:
|
|
447
|
+
key = "position" if "position" in raw else "pos"
|
|
448
|
+
if isinstance(pos, (list, tuple)) and len(pos) == 2:
|
|
449
|
+
raw["x"], raw["y"] = pos
|
|
450
|
+
elif isinstance(pos, dict):
|
|
451
|
+
raw["x"], raw["y"] = pos.get("x", 0), pos.get("y", 0)
|
|
452
|
+
del raw[key]
|
|
453
|
+
rep.add("info", "renamed-key", lid, f"'{key}' read as x and y", "write \"x\" and \"y\"")
|
|
454
|
+
_keys(raw, COMMON | TYPES[t], lalias, rep, lid, "")
|
|
455
|
+
shape = raw.get("shape")
|
|
456
|
+
if t == "model" and isinstance(shape, str) and shape.lower().endswith((".glb", ".gltf", ".obj")):
|
|
457
|
+
raw["src"] = raw.pop("shape")
|
|
458
|
+
rep.add("info", "renamed-key", lid, f"shape '{shape}' read as src (a model file)",
|
|
459
|
+
f"write \"src\": \"{shape}\"")
|
|
460
|
+
for need in [REQUIRED.get(t)]:
|
|
461
|
+
if need and not any(raw.get(k) not in (None, "", []) for k in need):
|
|
462
|
+
rep.add("error", "missing-key", lid, f"a {t} layer needs " + " or ".join(f"'{k}'" for k in need),
|
|
463
|
+
{"text": "\"text\": \"Hello\"", "image": "\"src\": \"photo.jpg\"", "video": "\"src\": \"clip.mp4\""}
|
|
464
|
+
.get(t, ""))
|
|
465
|
+
_values(raw, t, rep, lid)
|
|
466
|
+
for key in ("enter", "exit"):
|
|
467
|
+
_motion(raw, key, rep, lid)
|
|
468
|
+
if isinstance(raw.get("shadow"), dict):
|
|
469
|
+
_keys(raw["shadow"], SHADOW, SHADOW_ALIAS, rep, lid, "shadow: ")
|
|
470
|
+
if isinstance(raw.get("fx"), dict):
|
|
471
|
+
_keys(raw["fx"], FX, {"saturation": "saturate", "grayscale": "gray", "greyscale": "gray", "hueRotate": "hue",
|
|
472
|
+
"warmth": "temperature"}, rep, lid, "fx: ")
|
|
473
|
+
if isinstance(raw.get("box"), dict):
|
|
474
|
+
_keys(raw["box"], BOX, BOX_ALIAS, rep, lid, "box: ")
|
|
475
|
+
if t == "shader" and raw.get("preset"):
|
|
476
|
+
from .shaders import PRESETS as SH
|
|
477
|
+
if raw["preset"] not in SH:
|
|
478
|
+
s = suggest(raw["preset"], SH)
|
|
479
|
+
if s and s.lower() == str(raw["preset"]).lower():
|
|
480
|
+
raw["preset"] = s
|
|
481
|
+
else:
|
|
482
|
+
rep.add("error", "bad-value", lid, "preset: " + unknown("shader preset", raw["preset"], SH))
|
|
483
|
+
if t == "model":
|
|
484
|
+
_model(raw, rep, lid)
|
|
485
|
+
kids = raw.get("children") if raw.get("children") is not None else raw.get("layers")
|
|
486
|
+
if t == "group" and isinstance(kids, list):
|
|
487
|
+
for i, k in enumerate(kids):
|
|
488
|
+
_layer(k, rep, f"{lid}.children[{i}]", comps)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _light(obj: dict, rep: Report, lid):
|
|
492
|
+
"""`light` is a preset name or an object of light amounts; both are checked here."""
|
|
493
|
+
from .render3d import FROM, LIGHTS
|
|
494
|
+
v = obj.get("light")
|
|
495
|
+
if isinstance(v, str):
|
|
496
|
+
s = v if v in LIGHTS else suggest(v, LIGHTS)
|
|
497
|
+
if s and s.lower() == v.lower():
|
|
498
|
+
obj["light"] = s
|
|
499
|
+
else:
|
|
500
|
+
rep.add("error", "bad-value", lid, "light: " + unknown("light preset", v, LIGHTS))
|
|
501
|
+
elif isinstance(v, dict):
|
|
502
|
+
_keys(v, LIGHT, LIGHT_ALIAS, rep, lid, "light: ")
|
|
503
|
+
if v.get("preset") is not None and v["preset"] not in LIGHTS:
|
|
504
|
+
rep.add("error", "bad-value", lid, "light preset: " + unknown("light preset", v["preset"], LIGHTS))
|
|
505
|
+
if v.get("from") is not None and v["from"] not in FROM:
|
|
506
|
+
rep.add("error", "bad-value", lid, "light from: " + unknown("direction", v["from"], FROM))
|
|
507
|
+
for k in ("key", "fill", "ambient", "rim"):
|
|
508
|
+
if k in v and (isinstance(v[k], bool) or not isinstance(v[k], (int, float))):
|
|
509
|
+
rep.add("error", "bad-value", lid, f"light {k} is {v[k]!r}; it takes a number (about 0 to 1)")
|
|
510
|
+
if isinstance(v.get("color"), str) and not C.is_color(v["color"]):
|
|
511
|
+
rep.add("error", "bad-colour", lid, f"light color '{v['color']}' is not a colour")
|
|
512
|
+
elif v is not None:
|
|
513
|
+
rep.add("error", "bad-value", lid, f"light must be a preset ({', '.join(LIGHTS)}) or an object, got {v!r}")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _outline(obj: dict, rep: Report, lid, where: str = ""):
|
|
517
|
+
"""Text and path shapes need their outline; a number in `d` is a depth (d is the path data here)."""
|
|
518
|
+
d = obj.get("d")
|
|
519
|
+
if isinstance(d, (int, float)) and not isinstance(d, bool) and "depth" not in obj:
|
|
520
|
+
obj["depth"] = obj.pop("d")
|
|
521
|
+
rep.add("info", "renamed-key", lid, f"{where}'d' read as 'depth'", "write 'depth'")
|
|
522
|
+
shape = obj.get("shape")
|
|
523
|
+
if shape == "text" and not str(obj.get("text", "")).strip():
|
|
524
|
+
rep.add("error", "missing-key", lid, f"{where}a text model needs 'text'",
|
|
525
|
+
'add "text": "..."')
|
|
526
|
+
if shape == "path" and not isinstance(obj.get("d"), str):
|
|
527
|
+
rep.add("error", "missing-key", lid, f"{where}a path model needs 'd', SVG path data",
|
|
528
|
+
'add "d": "M0 0 L100 0 L50 80 Z"')
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _model(raw: dict, rep: Report, lid):
|
|
532
|
+
_light(raw, rep, lid)
|
|
533
|
+
_outline(raw, rep, lid)
|
|
534
|
+
if raw.get("src") and raw.get("shape"):
|
|
535
|
+
rep.add("warning", "ignored-key", lid, f"shape '{raw['shape']}' is ignored: the model comes from src",
|
|
536
|
+
"drop shape, or make the shape its own part")
|
|
537
|
+
gr = raw.get("ground")
|
|
538
|
+
if isinstance(gr, dict):
|
|
539
|
+
_keys(gr, GROUND, GROUND_ALIAS, rep, lid, "ground: ")
|
|
540
|
+
for k, v in gr.items():
|
|
541
|
+
if k in GROUND and (isinstance(v, bool) or not isinstance(v, (int, float))):
|
|
542
|
+
rep.add("error", "bad-value", lid, f"ground {k} is {v!r}; it takes a number")
|
|
543
|
+
elif gr is not None and not isinstance(gr, bool):
|
|
544
|
+
rep.add("error", "bad-value", lid, f"ground is {gr!r}; it takes true or an object like {{\"opacity\": 0.5}}")
|
|
545
|
+
if isinstance(raw.get("fill"), dict):
|
|
546
|
+
rep.add("error", "bad-value", lid, "a model's fill is one colour; gradients do not apply to models",
|
|
547
|
+
"use a colour, and give parts their own fill for more than one")
|
|
548
|
+
parts = raw.get("parts")
|
|
549
|
+
if parts is not None and not isinstance(parts, list):
|
|
550
|
+
rep.add("error", "bad-value", lid, "parts must be a list of objects, each with a shape")
|
|
551
|
+
return
|
|
552
|
+
for i, p in enumerate(parts or []):
|
|
553
|
+
where = f"parts[{i}]: "
|
|
554
|
+
if not isinstance(p, dict):
|
|
555
|
+
rep.add("error", "bad-value", lid, f"{where}a part is an object like {{\"shape\": \"sphere\", \"r\": 40}}")
|
|
556
|
+
continue
|
|
557
|
+
_keys(p, PART, dict(ALIAS, **MODEL_ALIAS), rep, lid, where)
|
|
558
|
+
_values(p, "model", rep, lid)
|
|
559
|
+
_outline(p, rep, lid, where)
|
|
560
|
+
for k, v in p.items(): # what _values lets through for layers but a part cannot animate or resolve
|
|
561
|
+
if k in SCALAR and (isinstance(v, (list, dict)) or (isinstance(v, str) and (v.startswith("=") or _lengthish(v)))):
|
|
562
|
+
rep.add("error", "bad-value", lid, f"{where}{k} is {v!r}; parts take plain numbers (they do not animate)",
|
|
563
|
+
"animate the model layer, or make the part its own model layer")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def normalize(spec) -> tuple[dict, Report]:
|
|
567
|
+
"""Return a cleaned copy of `spec` and a Report of notes and errors (nothing is raised here)."""
|
|
568
|
+
rep = Report()
|
|
569
|
+
if not isinstance(spec, dict):
|
|
570
|
+
rep.add("error", "bad-scene", None, f"a scene is a JSON object with 'canvas' and 'layers', got {type(spec).__name__}")
|
|
571
|
+
return {}, rep
|
|
572
|
+
spec = copy.deepcopy(spec)
|
|
573
|
+
cv = spec.get("canvas")
|
|
574
|
+
if cv is None:
|
|
575
|
+
cv = spec["canvas"] = {}
|
|
576
|
+
for k in [k for k in spec if k in ("width", "height", "fps", "bg", "background", "backgroundColor",
|
|
577
|
+
"duration", "frameRate", "w", "h")]:
|
|
578
|
+
to = CANVAS_ALIAS.get(k, k)
|
|
579
|
+
if isinstance(cv, dict) and to not in cv:
|
|
580
|
+
cv[to] = spec.pop(k)
|
|
581
|
+
rep.add("info", "renamed-key", None, f"top-level '{k}' read as canvas.{to}", "put it inside \"canvas\"")
|
|
582
|
+
elif isinstance(cv, dict):
|
|
583
|
+
spec.pop(k)
|
|
584
|
+
rep.add("error", "duplicate-key", None, f"top-level '{k}' belongs inside canvas as '{to}', and canvas.{to} "
|
|
585
|
+
"is already set", f"remove the top-level '{k}' or set canvas.{to}")
|
|
586
|
+
for k in list(spec):
|
|
587
|
+
if k in TOP_ALIAS and TOP_ALIAS[k] not in spec and (TOP_ALIAS[k] != "layers" or isinstance(spec[k], list)):
|
|
588
|
+
spec[TOP_ALIAS[k]] = spec.pop(k)
|
|
589
|
+
rep.add("info", "renamed-key", None, f"'{k}' read as '{TOP_ALIAS[k]}'", f"write '{TOP_ALIAS[k]}'")
|
|
590
|
+
_keys(spec, TOP, {}, rep, None, "scene: ")
|
|
591
|
+
if not isinstance(cv, dict):
|
|
592
|
+
rep.add("error", "bad-canvas", None, f"canvas must be an object like {{\"w\": 1920, \"h\": 1080, \"fps\": 30}}, "
|
|
593
|
+
f"got {type(cv).__name__}")
|
|
594
|
+
spec["canvas"] = cv = {}
|
|
595
|
+
_keys(cv, CANVAS, CANVAS_ALIAS, rep, None, "canvas: ")
|
|
596
|
+
for k, lo, hi in (("w", 16, 16384), ("h", 16, 16384), ("fps", 1, 240)):
|
|
597
|
+
v = cv.get(k)
|
|
598
|
+
if v is None:
|
|
599
|
+
continue
|
|
600
|
+
if isinstance(v, str) and _lengthish(v) and not v.strip().endswith("%"):
|
|
601
|
+
v = cv[k] = float(re.sub(r"[a-z]+", "", v))
|
|
602
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
603
|
+
rep.add("error", "bad-canvas", None, f"canvas.{k} must be a number, got {v!r}")
|
|
604
|
+
elif not lo <= v <= hi:
|
|
605
|
+
rep.add("error", "bad-canvas", None, f"canvas.{k} is {v:g}; it must be between {lo} and {hi}")
|
|
606
|
+
d = cv.get("duration")
|
|
607
|
+
if isinstance(d, (int, float)) and not isinstance(d, bool) and d <= 0:
|
|
608
|
+
rep.add("error", "bad-canvas", None, f"canvas.duration must be greater than 0 (got {d})",
|
|
609
|
+
"use seconds, or \"auto\" to end with the last layer")
|
|
610
|
+
if cv.get("bg") is not None and isinstance(cv["bg"], str) and not cv["bg"].startswith("$") \
|
|
611
|
+
and not C.is_color(cv["bg"]) and cv["bg"] not in ("transparent", "none"):
|
|
612
|
+
rep.add("error", "bad-colour", None, f"canvas.bg '{cv['bg']}' is not a colour", "use #rrggbb")
|
|
613
|
+
if isinstance(spec.get("grade"), dict):
|
|
614
|
+
_keys(spec["grade"], GRADE, {"saturation": "saturate"}, rep, None, "grade: ")
|
|
615
|
+
if isinstance(spec.get("motionBlur"), dict):
|
|
616
|
+
_keys(spec["motionBlur"], MOTION_BLUR, {}, rep, None, "motionBlur: ")
|
|
617
|
+
cam = spec.get("camera")
|
|
618
|
+
if isinstance(cam, dict):
|
|
619
|
+
_keys(cam, CAMERA, CAMERA_ALIAS, rep, "camera", "camera: ")
|
|
620
|
+
_values(cam, "camera", rep, "camera")
|
|
621
|
+
fov = cam.get("fov")
|
|
622
|
+
if isinstance(fov, (int, float)) and not isinstance(fov, bool) and not 5 <= fov <= 150:
|
|
623
|
+
rep.add("error", "bad-value", "camera", f"fov is {fov:g}; it must be between 5 and 150 degrees",
|
|
624
|
+
"40 is the default; lower flattens perspective, higher exaggerates it")
|
|
625
|
+
elif cam is not None:
|
|
626
|
+
rep.add("error", "bad-value", "camera", f"camera must be an object like {{\"z\": -200, \"rotateY\": 10}}, "
|
|
627
|
+
f"got {type(cam).__name__}")
|
|
628
|
+
spec.pop("camera")
|
|
629
|
+
_light(spec, rep, None)
|
|
630
|
+
audio = spec.get("audio")
|
|
631
|
+
if isinstance(audio, dict):
|
|
632
|
+
audio = spec["audio"] = [audio]
|
|
633
|
+
for i, a in enumerate(audio or []):
|
|
634
|
+
if isinstance(a, str):
|
|
635
|
+
audio[i] = a = {"src": a}
|
|
636
|
+
if isinstance(a, dict):
|
|
637
|
+
_keys(a, AUDIO, AUDIO_ALIAS, rep, f"audio[{i}]", "")
|
|
638
|
+
if not a.get("src"):
|
|
639
|
+
rep.add("error", "missing-key", f"audio[{i}]", "an audio entry needs 'src'")
|
|
640
|
+
layers = spec.get("layers")
|
|
641
|
+
if layers is None or layers == []:
|
|
642
|
+
rep.add("error", "no-layers", None, "the scene has no layers", "add \"layers\": [ ... ] with at least one layer")
|
|
643
|
+
spec["layers"] = []
|
|
644
|
+
elif not isinstance(layers, list):
|
|
645
|
+
rep.add("error", "bad-layers", None, f"'layers' must be a list, got {type(layers).__name__}")
|
|
646
|
+
spec["layers"] = []
|
|
647
|
+
comps = spec.get("components") or {}
|
|
648
|
+
for i, raw in enumerate(spec["layers"]):
|
|
649
|
+
_layer(raw, rep, f"layers[{i}]", comps if isinstance(comps, dict) else {})
|
|
650
|
+
return spec, rep
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---- tolerant JSON ---------------------------------------------------------------------------
|
|
654
|
+
def loads(text: str) -> tuple[object, list[str]]:
|
|
655
|
+
"""json.loads that also accepts // and /* */ comments, trailing commas and 'single quotes'.
|
|
656
|
+
Returns (value, notes); raises json.JSONDecodeError for anything else."""
|
|
657
|
+
import json
|
|
658
|
+
try:
|
|
659
|
+
return json.loads(text), []
|
|
660
|
+
except json.JSONDecodeError as first:
|
|
661
|
+
out, i, n, notes = [], 0, len(text), set()
|
|
662
|
+
while i < n:
|
|
663
|
+
ch = text[i]
|
|
664
|
+
if ch in "\"'":
|
|
665
|
+
q, j, buf = ch, i + 1, []
|
|
666
|
+
while j < n and text[j] != q:
|
|
667
|
+
if text[j] == "\\" and j + 1 < n:
|
|
668
|
+
buf.append(text[j:j + 2])
|
|
669
|
+
j += 2
|
|
670
|
+
continue
|
|
671
|
+
buf.append('\\"' if text[j] == '"' else text[j])
|
|
672
|
+
j += 1
|
|
673
|
+
if q == "'":
|
|
674
|
+
notes.add("single quotes")
|
|
675
|
+
out.append('"' + "".join(buf) + '"')
|
|
676
|
+
i = j + 1
|
|
677
|
+
elif text.startswith("//", i):
|
|
678
|
+
notes.add("comments")
|
|
679
|
+
while i < n and text[i] != "\n":
|
|
680
|
+
i += 1
|
|
681
|
+
elif text.startswith("/*", i):
|
|
682
|
+
notes.add("comments")
|
|
683
|
+
e = text.find("*/", i + 2)
|
|
684
|
+
i = n if e < 0 else e + 2
|
|
685
|
+
elif ch == ",":
|
|
686
|
+
j = i + 1
|
|
687
|
+
while j < n and text[j] in " \t\r\n":
|
|
688
|
+
j += 1
|
|
689
|
+
if j < n and text[j] in "]}":
|
|
690
|
+
notes.add("trailing commas")
|
|
691
|
+
i += 1
|
|
692
|
+
continue
|
|
693
|
+
out.append(ch)
|
|
694
|
+
i += 1
|
|
695
|
+
else:
|
|
696
|
+
out.append(ch)
|
|
697
|
+
i += 1
|
|
698
|
+
try:
|
|
699
|
+
return json.loads("".join(out)), sorted(notes)
|
|
700
|
+
except json.JSONDecodeError:
|
|
701
|
+
raise first from None
|