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.
Files changed (40) hide show
  1. kinescript/__init__.py +6 -0
  2. kinescript/cli.py +167 -0
  3. kinescript/color.py +89 -0
  4. kinescript/components.py +272 -0
  5. kinescript/docs.py +345 -0
  6. kinescript/ease.py +143 -0
  7. kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  8. kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  9. kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  10. kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  11. kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  12. kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  13. kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  14. kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  15. kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  16. kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  17. kinescript/mcp.py +148 -0
  18. kinescript/media.py +220 -0
  19. kinescript/mesh.py +390 -0
  20. kinescript/models.py +339 -0
  21. kinescript/output.py +370 -0
  22. kinescript/presets.py +170 -0
  23. kinescript/props.py +185 -0
  24. kinescript/render.py +1204 -0
  25. kinescript/render3d.py +393 -0
  26. kinescript/scene.py +362 -0
  27. kinescript/schema.py +701 -0
  28. kinescript/shaders.py +79 -0
  29. kinescript/space.py +81 -0
  30. kinescript/svgpath.py +80 -0
  31. kinescript/timing.py +169 -0
  32. kinescript/verify.py +261 -0
  33. kinescript-1.1.0.dist-info/METADATA +192 -0
  34. kinescript-1.1.0.dist-info/RECORD +40 -0
  35. kinescript-1.1.0.dist-info/WHEEL +5 -0
  36. kinescript-1.1.0.dist-info/entry_points.txt +2 -0
  37. kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
  38. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  39. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  40. 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