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/shaders.py ADDED
@@ -0,0 +1,79 @@
1
+ """SkSL shader layers. Built-in uniforms: iTime (layer seconds), iResolution (w, h), iProgress (0..1).
2
+ Colour uniforms are float4 (premultiply is not needed; alpha stays 1 unless you set it)."""
3
+ from __future__ import annotations
4
+
5
+ PRESETS = {
6
+ # Four soft colour fields drifting over each other.
7
+ "mesh": ("""
8
+ uniform float iTime; uniform float2 iResolution; uniform float iProgress;
9
+ uniform float4 c1; uniform float4 c2; uniform float4 c3; uniform float4 c4; uniform float speed;
10
+ half4 main(float2 p) {
11
+ float2 uv = p / iResolution; float t = iTime * speed;
12
+ float2 a = float2(0.25 + 0.15 * sin(t * 0.7), 0.3 + 0.2 * cos(t * 0.5));
13
+ float2 b = float2(0.75 + 0.15 * cos(t * 0.6), 0.25 + 0.15 * sin(t * 0.8));
14
+ float2 c = float2(0.3 + 0.2 * cos(t * 0.4), 0.8 + 0.1 * sin(t * 0.9));
15
+ float2 d = float2(0.8 + 0.1 * sin(t * 0.5), 0.75 + 0.15 * cos(t * 0.3));
16
+ float wa = 1.0 / (0.02 + pow(distance(uv, a), 2.0)); float wb = 1.0 / (0.02 + pow(distance(uv, b), 2.0));
17
+ float wc = 1.0 / (0.02 + pow(distance(uv, c), 2.0)); float wd = 1.0 / (0.02 + pow(distance(uv, d), 2.0));
18
+ float4 col = (c1 * wa + c2 * wb + c3 * wc + c4 * wd) / (wa + wb + wc + wd);
19
+ return half4(col.rgb, 1.0);
20
+ }""", {"c1": "#ff633f", "c2": "#b9f45d", "c3": "#242832", "c4": "#0a0c10", "speed": 1.0}),
21
+ # Horizontal light bands with a slow vertical sway.
22
+ "aurora": ("""
23
+ uniform float iTime; uniform float2 iResolution; uniform float iProgress;
24
+ uniform float4 base; uniform float4 glow; uniform float4 glow2; uniform float speed;
25
+ half4 main(float2 p) {
26
+ float2 uv = p / iResolution; float t = iTime * speed;
27
+ float band = 0.0;
28
+ for (int i = 0; i < 3; i++) {
29
+ float fi = float(i);
30
+ float y = 0.35 + 0.12 * fi + 0.08 * sin(uv.x * (3.0 + fi) + t * (0.6 + 0.2 * fi));
31
+ band += exp(-pow((uv.y - y) * (9.0 - fi * 2.0), 2.0)) * (0.7 - 0.15 * fi);
32
+ }
33
+ float4 g = mix(glow, glow2, 0.5 + 0.5 * sin(uv.x * 2.0 + t * 0.3));
34
+ float4 col = mix(base, g, clamp(band * 0.75, 0.0, 0.85));
35
+ return half4(col.rgb, 1.0);
36
+ }""", {"base": "#0a0c10", "glow": "#b9f45d", "glow2": "#ff633f", "speed": 1.0}),
37
+ # Receding floor grid, like a stage.
38
+ "grid": ("""
39
+ uniform float iTime; uniform float2 iResolution; uniform float iProgress;
40
+ uniform float4 base; uniform float4 line; uniform float speed; uniform float density;
41
+ half4 main(float2 p) {
42
+ float2 uv = p / iResolution;
43
+ float horizon = 0.42;
44
+ float4 col = base;
45
+ if (uv.y > horizon) {
46
+ float z = 1.0 / (uv.y - horizon + 0.001);
47
+ float gx = (uv.x - 0.5) * z * density;
48
+ float gz = z * density * 0.5 + iTime * speed;
49
+ float lx = abs(fract(gx) - 0.5); float lz = abs(fract(gz) - 0.5);
50
+ float w = 0.004 * z;
51
+ float g = max(smoothstep(w + 0.012, w, lx), smoothstep(w + 0.012, w, lz));
52
+ float fade = clamp((uv.y - horizon) * 2.4, 0.0, 1.0);
53
+ col = mix(base, line, g * fade);
54
+ }
55
+ return half4(col.rgb, 1.0);
56
+ }""", {"base": "#0a0c10", "line": "#ff633f", "speed": 0.6, "density": 1.0}),
57
+ # Timing grid: beat lines, bar lines every fourth beat, faint lanes, registration crosses.
58
+ # It advances one beat at a time (discrete steps), never drifts.
59
+ "score": ("""
60
+ uniform float iTime; uniform float2 iResolution; uniform float iProgress;
61
+ uniform float4 base; uniform float4 line; uniform float4 mark; uniform float beat; uniform float rate;
62
+ uniform float lane;
63
+ half4 main(float2 p) {
64
+ float shift = floor(iTime * rate) * beat;
65
+ float x = p.x + shift;
66
+ float bx = abs(fract(x / beat) - 0.5) * beat;
67
+ float barx = abs(fract(x / (beat * 4.0)) - 0.5) * beat * 4.0;
68
+ float ly = abs(fract(p.y / lane) - 0.5) * lane;
69
+ float beatLine = smoothstep(1.2, 0.2, bx) * 0.35;
70
+ float barLine = smoothstep(1.4, 0.3, barx) * 0.8;
71
+ float laneLine = smoothstep(1.0, 0.2, ly) * 0.25;
72
+ float cross = 0.0;
73
+ if (barx < 9.0 && ly < 9.0) { cross = max(smoothstep(1.3, 0.3, barx), smoothstep(1.3, 0.3, ly)); }
74
+ float4 col = base;
75
+ col = mix(col, line, clamp(max(max(beatLine, barLine), laneLine), 0.0, 1.0));
76
+ col = mix(col, mark, cross * 0.9);
77
+ return half4(col.rgb, 1.0);
78
+ }""", {"base": "#0a0c10", "line": "#242832", "mark": "#9299a8", "beat": 48.0, "rate": 2.0, "lane": 216.0}),
79
+ }
kinescript/space.py ADDED
@@ -0,0 +1,81 @@
1
+ """Depth for layers: 4x4 matrices in canvas pixels with x right, y down and z toward the viewer, the
2
+ convention CSS transforms use, and the camera that projects them onto the canvas."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ import skia
9
+
10
+ DEFAULT_FOV = 40.0
11
+ # A point is drawn only while its w (distance from the camera over the focus distance) is above
12
+ # this: closer than 1/20 of the way, a layer is magnified 20x and soon passes behind the lens.
13
+ NEAR = 0.05
14
+
15
+
16
+ def translate(x: float, y: float, z: float = 0.0) -> np.ndarray:
17
+ m = np.eye(4)
18
+ m[:3, 3] = x, y, z
19
+ return m
20
+
21
+
22
+ def scale(sx: float, sy: float) -> np.ndarray:
23
+ return np.diag([sx, sy, 1.0, 1.0])
24
+
25
+
26
+ def rotate_x(deg: float) -> np.ndarray:
27
+ c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
28
+ return np.array([[1, 0, 0, 0], [0, c, -s, 0], [0, s, c, 0], [0, 0, 0, 1]], float)
29
+
30
+
31
+ def rotate_y(deg: float) -> np.ndarray:
32
+ c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
33
+ return np.array([[c, 0, s, 0], [0, 1, 0, 0], [-s, 0, c, 0], [0, 0, 0, 1]], float)
34
+
35
+
36
+ def rotate_z(deg: float) -> np.ndarray:
37
+ """Clockwise on screen, like Canvas.rotate."""
38
+ c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
39
+ return np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], float)
40
+
41
+
42
+ def tilt(z: float, rx: float, ry: float) -> np.ndarray:
43
+ """The depth part of a layer transform, applied between its translate and its rotate."""
44
+ return translate(0, 0, z) @ rotate_x(rx) @ rotate_y(ry)
45
+
46
+
47
+ def camera(w: float, h: float, fov: float = DEFAULT_FOV, x: float = 0.0, y: float = 0.0, z: float = 0.0,
48
+ rx: float = 0.0, ry: float = 0.0, rz: float = 0.0) -> np.ndarray:
49
+ """World to canvas. The default camera looks at the canvas centre from the distance at which
50
+ `fov` (vertical, degrees) spans the canvas height, so z=0 maps to itself. x, y pan the camera
51
+ (and the point it orbits); z moves it back; ry orbits it right, rx up (looking down), rz rolls it."""
52
+ orbit = rotate_z(-rz) @ rotate_x(-rx) @ rotate_y(-ry)
53
+ return lens(w, h, fov) @ translate(0, 0, -z) @ orbit @ translate(-w / 2 - x, -h / 2 - y)
54
+
55
+
56
+ def lens(w: float, h: float, fov: float = DEFAULT_FOV) -> np.ndarray:
57
+ """The projection part of `camera`: camera space (origin at the canvas centre) to canvas."""
58
+ focus = (h / 2) / math.tan(math.radians(min(max(fov, 1.0), 179.0)) / 2)
59
+ persp = np.eye(4)
60
+ persp[3, 2] = -1.0 / focus
61
+ return translate(w / 2, h / 2) @ persp
62
+
63
+
64
+ def to_skia(m: np.ndarray) -> skia.M44:
65
+ return skia.M44.Rows(*(skia.V4(*row) for row in m.tolist()))
66
+
67
+
68
+ def project(m: np.ndarray, box) -> tuple[np.ndarray, np.ndarray]:
69
+ """Canvas positions and w of a local rect's corners (TL, TR, BR, BL) at z=0."""
70
+ bx, by, bw, bh = box
71
+ bw, bh = bw or 1.0, bh or 1.0
72
+ pts = np.array([[bx, by, 0, 1], [bx + bw, by, 0, 1], [bx + bw, by + bh, 0, 1], [bx, by + bh, 0, 1]], float)
73
+ out = pts @ m.T
74
+ w = out[:, 3]
75
+ return out[:, :2] / np.where(np.abs(w) < 1e-9, 1e-9, w)[:, None], w
76
+
77
+
78
+ def facing(xy: np.ndarray) -> bool:
79
+ """True while the rect whose projected corners are `xy` shows its front."""
80
+ (x0, y0), (x1, y1), _, (x3, y3) = xy
81
+ return (x1 - x0) * (y3 - y0) - (y1 - y0) * (x3 - x0) >= 0
kinescript/svgpath.py ADDED
@@ -0,0 +1,80 @@
1
+ """SVG path data ('M10 10 L 90 90 Q ...') to skia.Path. skia-python ships no parser."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ import skia
7
+
8
+ _TOK = re.compile(r"[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
9
+ _ARGS = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2, "A": 7, "Z": 0}
10
+
11
+
12
+ def parse(d: str) -> skia.Path:
13
+ toks = _TOK.findall(d)
14
+ path = skia.Path()
15
+ i, cmd = 0, None
16
+ cx = cy = sx = sy = 0.0
17
+ last_ctrl: tuple[float, float] | None = None
18
+ last_cmd = ""
19
+ while i < len(toks):
20
+ if toks[i].isalpha():
21
+ cmd = toks[i]
22
+ i += 1
23
+ if cmd in "Zz":
24
+ path.close()
25
+ cx, cy = sx, sy
26
+ last_cmd = "Z"
27
+ continue
28
+ if cmd is None:
29
+ raise ValueError(f"path data must start with a command: {d[:40]!r}")
30
+ n = _ARGS[cmd.upper()]
31
+ vals = [float(v) for v in toks[i:i + n]]
32
+ if len(vals) < n:
33
+ raise ValueError(f"path command {cmd} needs {n} numbers")
34
+ i += n
35
+ rel = cmd.islower()
36
+ U = cmd.upper()
37
+ ox, oy = (cx, cy) if rel else (0.0, 0.0)
38
+ if U == "M":
39
+ cx, cy = vals[0] + ox, vals[1] + oy
40
+ path.moveTo(cx, cy)
41
+ sx, sy = cx, cy
42
+ cmd = "l" if rel else "L" # extra pairs after M are line-tos
43
+ elif U == "L":
44
+ cx, cy = vals[0] + ox, vals[1] + oy
45
+ path.lineTo(cx, cy)
46
+ elif U == "H":
47
+ cx = vals[0] + (cx if rel else 0)
48
+ path.lineTo(cx, cy)
49
+ elif U == "V":
50
+ cy = vals[0] + (cy if rel else 0)
51
+ path.lineTo(cx, cy)
52
+ elif U == "C":
53
+ x1, y1, x2, y2, x, y = vals
54
+ path.cubicTo(x1 + ox, y1 + oy, x2 + ox, y2 + oy, x + ox, y + oy)
55
+ last_ctrl = (x2 + ox, y2 + oy)
56
+ cx, cy = x + ox, y + oy
57
+ elif U == "S":
58
+ x2, y2, x, y = vals
59
+ x1, y1 = (2 * cx - last_ctrl[0], 2 * cy - last_ctrl[1]) if last_ctrl and last_cmd in "CS" else (cx, cy)
60
+ path.cubicTo(x1, y1, x2 + ox, y2 + oy, x + ox, y + oy)
61
+ last_ctrl = (x2 + ox, y2 + oy)
62
+ cx, cy = x + ox, y + oy
63
+ elif U == "Q":
64
+ x1, y1, x, y = vals
65
+ path.quadTo(x1 + ox, y1 + oy, x + ox, y + oy)
66
+ last_ctrl = (x1 + ox, y1 + oy)
67
+ cx, cy = x + ox, y + oy
68
+ elif U == "T":
69
+ x, y = vals
70
+ x1, y1 = (2 * cx - last_ctrl[0], 2 * cy - last_ctrl[1]) if last_ctrl and last_cmd in "QT" else (cx, cy)
71
+ path.quadTo(x1, y1, x + ox, y + oy)
72
+ last_ctrl = (x1, y1)
73
+ cx, cy = x + ox, y + oy
74
+ elif U == "A":
75
+ rx, ry, rot, large, sweep, x, y = vals
76
+ path.arcTo(rx, ry, rot, skia.Path.ArcSize.kLarge_ArcSize if large else skia.Path.ArcSize.kSmall_ArcSize,
77
+ skia.PathDirection.kCW if sweep else skia.PathDirection.kCCW, x + ox, y + oy)
78
+ cx, cy = x + ox, y + oy
79
+ last_cmd = U
80
+ return path
kinescript/timing.py ADDED
@@ -0,0 +1,169 @@
1
+ """Cue-graph timing: start/end times written as references to other layers.
2
+
3
+ "at": 1.5 seconds
4
+ "at": "45f" frames
5
+ "at": "0:02.5" m:ss
6
+ "at": "title.out+0.2" 0.2 s after layer 'title' ends
7
+ "at": "prev.in+10f" 10 frames after the previous layer starts
8
+ "at": "drop" a named marker from scene "markers"
9
+
10
+ Change one duration and everything cued from it moves with it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ _TERM = re.compile(r"\s*([+-])\s*(\d+(?:\.\d+)?)\s*(s|f|ms)?\s*")
17
+ _BASE = re.compile(r"^\s*([A-Za-z_][\w-]*)(?:\.(in|out|mid))?")
18
+
19
+
20
+ class TimingError(ValueError):
21
+ def __init__(self, message: str, layer: str | None = None):
22
+ super().__init__(message)
23
+ self.layer = layer
24
+
25
+
26
+ FORMS = ('a number of seconds (1.5), "45f" (frames), "0:02.5", "title.out+0.2" (0.2 s after layer title ends), '
27
+ '"title.in", "title.mid", "prev.out", or a marker name')
28
+
29
+
30
+ def parse_clock(s: str, fps: float) -> float | None:
31
+ s = s.strip()
32
+ m = re.fullmatch(r"(-?\d+(?:\.\d+)?)\s*(s|f|ms)?", s)
33
+ if m:
34
+ v = float(m.group(1))
35
+ return v / fps if m.group(2) == "f" else v / 1000 if m.group(2) == "ms" else v
36
+ m = re.fullmatch(r"(\d+):(\d+(?:\.\d+)?)", s)
37
+ if m:
38
+ return int(m.group(1)) * 60 + float(m.group(2))
39
+ return None
40
+
41
+
42
+ def split_ref(expr: str) -> tuple[str | None, str, str]:
43
+ """'title.out+0.2' -> ('title', 'out', '+0.2'). Plain clocks return (None, '', expr)."""
44
+ m = _BASE.match(expr)
45
+ if not m or parse_clock(expr, 30) is not None:
46
+ return None, "", expr
47
+ return m.group(1), m.group(2) or "", expr[m.end():]
48
+
49
+
50
+ def offset(rest: str, fps: float) -> float:
51
+ total, pos = 0.0, 0
52
+ while pos < len(rest):
53
+ m = _TERM.match(rest, pos)
54
+ if not m or m.end() == pos:
55
+ raise TimingError(f"cannot parse time offset {rest!r}")
56
+ v = float(m.group(2))
57
+ v = v / fps if m.group(3) == "f" else v / 1000 if m.group(3) == "ms" else v
58
+ total += v if m.group(1) == "+" else -v
59
+ pos = m.end()
60
+ return total
61
+
62
+
63
+ class Timeline:
64
+ """Resolves in/out for every layer, following references depth-first."""
65
+
66
+ def __init__(self, fps: float, markers: dict[str, float] | None = None):
67
+ self.fps = fps
68
+ self.markers = {k: float(v) for k, v in (markers or {}).items()}
69
+ self.nodes: dict[str, dict] = {}
70
+ self.order: list[str] = []
71
+ self.resolved: dict[str, tuple[float, float | None]] = {}
72
+ self._stack: list[str] = []
73
+
74
+ def add(self, lid: str, at, dur=None, until=None, natural: float | None = None,
75
+ prev: str | None = None, parent: str | None = None, default_end: str | None = None):
76
+ if lid in self.nodes:
77
+ raise TimingError(f"duplicate layer id '{lid}'; every id must be unique", lid)
78
+ self.nodes[lid] = dict(at=at, dur=dur, until=until, natural=natural, prev=prev,
79
+ parent=parent, default_end=default_end)
80
+ self.order.append(lid)
81
+
82
+ def _time(self, v, owner: str, base: float) -> float:
83
+ if v is None:
84
+ return base
85
+ if isinstance(v, (int, float)):
86
+ return base + float(v)
87
+ s = str(v)
88
+ clk = parse_clock(s, self.fps)
89
+ if clk is not None:
90
+ return base + clk
91
+ name, edge, rest = split_ref(s)
92
+ if name is None:
93
+ raise TimingError(f"cannot read the time {s!r}. Write {FORMS}", owner)
94
+ if name == "prev":
95
+ name = self.nodes[owner]["prev"] if owner in self.nodes else None
96
+ if name is None:
97
+ raise TimingError("'prev' used on the first layer, which has no previous layer", owner)
98
+ if name in self.markers and not edge:
99
+ t = self.markers[name]
100
+ elif name == "start":
101
+ t = 0.0
102
+ elif name in self.nodes:
103
+ t_in, t_out = self.resolve(name)
104
+ if edge in ("", "in"):
105
+ t = t_in
106
+ else:
107
+ if t_out is None:
108
+ raise TimingError(f"refers to '{name}.{edge}', but '{name}' runs to the scene end, so it has "
109
+ f"no end time. Give '{name}' a dur or until", owner)
110
+ t = t_out if edge == "out" else (t_in + t_out) / 2
111
+ else:
112
+ from .schema import suggest
113
+ options = list(self.nodes) + list(self.markers)
114
+ close = suggest(name, options)
115
+ known = ", ".join(options[:16])
116
+ if " " in s.strip():
117
+ raise TimingError(f"cannot read the time {s!r}. Write {FORMS}", owner)
118
+ raise TimingError(f"unknown reference '{name}' in {s!r}." + (f" Did you mean '{close}'?" if close else "")
119
+ + f" Layer ids and markers: {known}", owner)
120
+ try:
121
+ return t + offset(rest, self.fps)
122
+ except TimingError as e:
123
+ raise TimingError(f"{e} in {s!r}. Write {FORMS}", owner) from None
124
+
125
+ def resolve(self, lid: str) -> tuple[float, float | None]:
126
+ if lid in self.resolved:
127
+ return self.resolved[lid]
128
+ if lid in self._stack:
129
+ raise TimingError("timing cycle: " + " -> ".join(self._stack + [lid])
130
+ + ". A layer cannot start or end relative to itself", lid)
131
+ self._stack.append(lid)
132
+ n = self.nodes[lid]
133
+ base = self.resolve(n["parent"])[0] if n["parent"] else 0.0
134
+ t_in = self._time(n["at"], lid, base)
135
+ until = n["until"]
136
+ if n["dur"] is not None:
137
+ d = n["dur"]
138
+ if isinstance(d, str):
139
+ clk = parse_clock(d, self.fps)
140
+ if clk is None:
141
+ raise TimingError(f"dur {d!r} is not a length of time; use seconds like 2.5, or \"45f\"", lid)
142
+ d = clk
143
+ t_out: float | None = t_in + float(d)
144
+ elif until is not None and until != "end":
145
+ t_out = self._time(until, lid, base)
146
+ elif n["natural"] is not None and until != "end":
147
+ t_out = t_in + n["natural"]
148
+ elif n["default_end"] is not None and until != "end":
149
+ t_out = self.resolve(n["default_end"])[1]
150
+ else:
151
+ t_out = None # open: runs to the scene end
152
+ if t_out is not None and t_out <= t_in:
153
+ raise TimingError(f"ends at {t_out:.3f}s, which is not after its start at {t_in:.3f}s", lid)
154
+ self._stack.pop()
155
+ self.resolved[lid] = (t_in, t_out)
156
+ return t_in, t_out
157
+
158
+ def solve(self, duration=None) -> float:
159
+ for lid in self.order:
160
+ self.resolve(lid)
161
+ if duration not in (None, "auto"):
162
+ end = self._time(duration, "canvas", 0.0)
163
+ else:
164
+ ends = [o for _, o in self.resolved.values() if o is not None]
165
+ end = max(ends) if ends else 5.0
166
+ for lid, (i, o) in list(self.resolved.items()):
167
+ if o is None:
168
+ self.resolved[lid] = (i, max(end, i + 1 / self.fps))
169
+ return end
kinescript/verify.py ADDED
@@ -0,0 +1,261 @@
1
+ """Agent-side verification: contact sheets, layout lint and frame probes.
2
+
3
+ An agent cannot watch a video. These turn a scene into things it can read: one image of
4
+ many timestamps, a JSON list of problems with time ranges, and the exact box of every layer.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+
10
+ import numpy as np
11
+ import skia
12
+
13
+ from . import color as C
14
+ from .render import FONT_DIR, Renderer, _argb
15
+ from .scene import Scene, SceneError, load
16
+
17
+ # Model triangles one frame may draw before check warns. 100,000 took 71 ms a frame at 1080p on
18
+ # the GPU in the 3D spike (2026-10-29), against about 15 ms at 20,000.
19
+ TRIANGLE_BUDGET = 60_000
20
+
21
+
22
+ def _times(scene: Scene, count: int, times=None) -> list[float]:
23
+ if times:
24
+ return [float(t) for t in times]
25
+ count = max(1, count)
26
+ step = scene.duration / count
27
+ return [min(scene.duration - 1 / scene.fps, step * (k + 0.5)) for k in range(count)]
28
+
29
+
30
+ def contact_sheet(src, out: str, count: int = 12, cols: int | None = None, width: int = 480,
31
+ times=None, boxes: bool = False) -> dict:
32
+ """One PNG of `count` evenly spaced frames, each labelled with its time and frame number.
33
+ boxes=True outlines every visible layer with its id."""
34
+ scene = load(src)
35
+ ts = _times(scene, count, times)
36
+ cols = cols or min(len(ts), 4)
37
+ rows = math.ceil(len(ts) / cols)
38
+ scale = width / scene.w
39
+ r = Renderer(scene, track=boxes, scale=scale)
40
+ tw, th = r.w, r.h
41
+ gap, label_h = 8, 26
42
+ sheet = skia.Surface(cols * (tw + gap) + gap, rows * (th + label_h + gap) + gap)
43
+ c = sheet.getCanvas()
44
+ c.clear(_argb("#0a0c10"))
45
+ mono = skia.Typeface.MakeFromFile(str(FONT_DIR / "IBMPlexMono-Medium.ttf"))
46
+ font = skia.Font(mono, 15)
47
+ small = skia.Font(mono, 11)
48
+ for k, T in enumerate(ts):
49
+ n = int(round(T * scene.fps))
50
+ arr = r.frame(n)
51
+ img = skia.Image.fromarray(np.ascontiguousarray(arr), colorType=skia.kRGBA_8888_ColorType)
52
+ x = gap + (k % cols) * (tw + gap)
53
+ y = gap + (k // cols) * (th + label_h + gap)
54
+ c.drawImage(img, x, y + label_h)
55
+ c.drawString(f"{T:6.2f}s f{n}", x + 2, y + 18, font, skia.Paint(AntiAlias=True, Color=_argb("#f2f0e8")))
56
+ if boxes:
57
+ _draw_boxes(c, scene, r.boxes, x, y + label_h, tw, th, small)
58
+ sheet.makeImageSnapshot().save(out, skia.kPNG)
59
+ scene.close()
60
+ return {"out": out, "times": [round(t, 3) for t in ts], "grid": [cols, rows], "backend": r.backend}
61
+
62
+
63
+ def _behind(boxes: dict, lid: str, a: list, w: int, h: int) -> str | None:
64
+ """The shape or media layer drawn before `lid` that covers most of its box, if any.
65
+ `boxes` is in draw order, so every entry before `lid` is underneath it. Layers that fill the
66
+ canvas are skipped: under a background the colour is wrong, not the placement."""
67
+ best, area = None, 0.0
68
+ for other, b in boxes.items():
69
+ if other == lid:
70
+ break
71
+ r = b["rect"]
72
+ if (b["type"] in ("text", "counter", "captions", "group") or b["alpha"] < 0.5
73
+ or (r[0] <= 0 and r[1] <= 0 and r[2] >= w and r[3] >= h)):
74
+ continue
75
+ ix, iy = min(a[2], r[2]) - max(a[0], r[0]), min(a[3], r[3]) - max(a[1], r[1])
76
+ if ix > 0 and iy > 0 and ix * iy > area:
77
+ best, area = other, ix * iy
78
+ return best
79
+
80
+
81
+ def _draw_boxes(c, scene: Scene, boxes: dict, ox: float, oy: float, tw: float, th: float, font):
82
+ """Outline every visible layer; label only top-level layers, on a dark tab just outside the box,
83
+ so labels neither cover the text they name nor pile up for every child of a component."""
84
+ line = skia.Paint(AntiAlias=True, Style=skia.Paint.kStroke_Style, StrokeWidth=1, Color=_argb("#b9f45d"))
85
+ tab = skia.Paint(AntiAlias=True, Color=_argb("#0a0c10d8"))
86
+ ink = skia.Paint(AntiAlias=True, Color=_argb("#b9f45d"))
87
+ taken: list[skia.Rect] = []
88
+ c.save()
89
+ c.clipRect(skia.Rect.MakeXYWH(ox, oy, tw, th))
90
+ for lid, b in boxes.items():
91
+ if b["alpha"] < 0.05:
92
+ continue
93
+ l, t_, rr, bb = b["rect"] # already in thumbnail pixels (the renderer is scaled)
94
+ c.drawRect(skia.Rect(ox + l, oy + t_, ox + rr, oy + bb), line)
95
+ layer = scene.by_id.get(lid)
96
+ if layer is not None and layer.parent is not None:
97
+ continue
98
+ w = font.measureText(lid) + 6
99
+ lx = min(max(ox, ox + l), ox + tw - w)
100
+ ly = oy + t_ - 14 if t_ - 14 >= 0 else oy + bb # above the box, or below it at the top edge
101
+ rect = skia.Rect.MakeXYWH(lx, ly, w, 14)
102
+ if any(rect.intersect(o) for o in taken):
103
+ continue # skip a label that would sit on another; the outline still shows
104
+ taken.append(rect)
105
+ c.drawRect(rect, tab)
106
+ c.drawString(lid, lx + 3, ly + 11, font, ink)
107
+ c.restore()
108
+
109
+
110
+ def probe(src, at: float | None = None) -> dict:
111
+ """Resolved timeline; with `at`, also every visible layer's screen box at that time."""
112
+ scene = load(src)
113
+ out = scene.summary()
114
+ if at is not None:
115
+ r = Renderer(scene, track=True)
116
+ r.draw(float(at))
117
+ out["at"] = float(at)
118
+ out["visible"] = r.boxes
119
+ scene.close()
120
+ return out
121
+
122
+
123
+ def _words(text: str) -> int:
124
+ return len([w for w in text.replace("**", "").split() if any(ch.isalnum() for ch in w)])
125
+
126
+
127
+ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
128
+ """Lint a scene. Returns {"ok", "errors", "warnings", "issues": [...]} where each issue names
129
+ the layer, the time range it was seen in, and a suggested fix."""
130
+ try:
131
+ scene = load(src)
132
+ except SceneError as e:
133
+ return _result(e.issues)
134
+ except (ValueError, FileNotFoundError, RuntimeError, OSError) as e:
135
+ return _result([{"level": "error", "code": "load", "layer": None, "message": str(e), "fix": ""}])
136
+ W, H = scene.w, scene.h
137
+ r = Renderer(scene, track=True, scale=1.0)
138
+ found: dict[tuple, dict] = {}
139
+
140
+ def add(level, code, lid, T, msg, fix="", until=None):
141
+ key = (code, lid)
142
+ it = found.get(key)
143
+ if it is None:
144
+ found[key] = {"level": level, "code": code, "layer": lid, "from": round(T, 2),
145
+ "to": round(T if until is None else until, 2), "message": msg, "fix": fix}
146
+ else:
147
+ it["to"] = round(T, 2)
148
+
149
+ n = max(1, int(scene.duration / step))
150
+ for k in range(n + 1):
151
+ T = min(scene.duration - 1e-3, k * step)
152
+ try:
153
+ r.draw(T)
154
+ except (ValueError, RuntimeError, OSError) as e:
155
+ scene.close()
156
+ return _result(scene.issues + [{"level": "error", "code": "render", "layer": None, "from": round(T, 2),
157
+ "to": round(T, 2), "message": f"{type(e).__name__}: {e}", "fix": ""}],
158
+ duration=scene.duration)
159
+ for lid in r.culled:
160
+ msg = ("the triangles of this model that cross the camera plane are not drawn"
161
+ if scene.by_id[lid].type == "model" else "layer crosses the camera plane, so it is not drawn")
162
+ add("warning", "behind-camera", lid, T, msg, "lower its z, or move the camera back (a higher camera z)")
163
+ if r.tris > TRIANGLE_BUDGET:
164
+ add("warning", "triangle-budget", None, T, f"{r.tris:,} model triangles in one frame; renders slow "
165
+ f"down past {TRIANGLE_BUDGET:,} (about 70 ms a frame at 100,000 on a GPU)",
166
+ "use \"detail\": \"low\" or \"medium\", or fewer parts")
167
+ texts = []
168
+ for lid, b in r.boxes.items():
169
+ if b["alpha"] < 0.3:
170
+ continue
171
+ l, t, rr, bb = b["rect"]
172
+ layer = scene.by_id.get(lid)
173
+ allow = layer is not None and layer.raw.get("allowOffscreen")
174
+ if b["type"] in ("text", "counter", "captions"):
175
+ texts.append((lid, b))
176
+ if b.get("mirrored"):
177
+ add("warning", "mirrored-text", lid, T, "text faces away from the camera, so it reads mirrored",
178
+ "keep rotateX and rotateY within ±90°, or set \"backface\": \"hidden\" and add a back side")
179
+ if (l < -1 or t < -1 or rr > W + 1 or bb > H + 1) and not allow:
180
+ add("error", "text-offscreen", lid, T, f"text box {b['rect']} leaves the {W}x{H} canvas",
181
+ "move it inward, lower size, or set maxWidth")
182
+ elif (l < W * safe or t < H * safe or rr > W * (1 - safe) or bb > H * (1 - safe)) and not allow:
183
+ add("warning", "safe-area", lid, T, f"text is inside the outer {int(safe * 100)}% margin",
184
+ "keep titles within the title-safe area")
185
+ px = b["size"] * b.get("scale", 1)
186
+ if px < H / 45 and b["alpha"] > 0.9:
187
+ add("warning", "tiny-text", lid, T, f"text renders at ~{px:.0f}px, small for {H}p video",
188
+ f"use at least {math.ceil(H / 45)}px")
189
+ elif b["type"] not in ("video", "group") and not allow:
190
+ if rr < 0 or bb < 0 or l > W or t > H:
191
+ add("info", "offscreen", lid, T, "layer is visible but entirely off canvas", "check x/y")
192
+ for i in range(len(texts)):
193
+ for j in range(i + 1, len(texts)):
194
+ a, b2 = texts[i][1]["rect"], texts[j][1]["rect"]
195
+ ix = min(a[2], b2[2]) - max(a[0], b2[0])
196
+ iy = min(a[3], b2[3]) - max(a[1], b2[1])
197
+ if ix > 4 and iy > 4:
198
+ area = min((a[2] - a[0]) * (a[3] - a[1]), (b2[2] - b2[0]) * (b2[3] - b2[1])) or 1
199
+ if ix * iy / area > 0.15:
200
+ add("warning", "text-overlap", f"{texts[i][0]}+{texts[j][0]}", T,
201
+ "two text layers overlap", "offset one of them or separate them in time")
202
+ # Contrast: render the frame without the text layer and its successors, compare luminance under its box.
203
+ for layer in scene.all_layers():
204
+ if layer.type not in ("text", "counter"):
205
+ continue
206
+ T = min(layer.t_out - 1e-3, layer.t_in + min(layer.dur * 0.6, 1.5))
207
+ r.draw(T)
208
+ b = r.boxes.get(layer.id)
209
+ if not b or b["alpha"] < 0.5 or not b.get("fill") or layer.raw.get("box"):
210
+ continue
211
+ r.draw(T, stop_at=layer.id)
212
+ buf = np.empty((r.h, r.w, 4), np.uint8)
213
+ r.surface.readPixels(r.info, buf, r.w * 4)
214
+ l, t, rr, bb = (int(max(0, v)) for v in b["rect"])
215
+ rr, bb = min(r.w, rr), min(r.h, bb)
216
+ if rr - l < 2 or bb - t < 2:
217
+ continue
218
+ patch = buf[t:bb, l:rr, :3].reshape(-1, 3).astype(np.float32) / 255
219
+ lin = np.where(patch <= 0.04045, patch / 12.92, ((patch + 0.055) / 1.055) ** 2.4)
220
+ lum = lin @ np.array([0.2126, 0.7152, 0.0722], np.float32)
221
+ fg = C.luminance(tuple(b["fill"]))
222
+ worst = min(C.contrast(fg, float(np.percentile(lum, 10))), C.contrast(fg, float(np.percentile(lum, 90))))
223
+ if worst < 3.0:
224
+ under = _behind(r.boxes, layer.id, b["rect"], r.w, r.h)
225
+ add("warning", "contrast", layer.id, T, f"contrast against what is behind it is {worst:.1f}:1 (want 4.5:1)"
226
+ + (f"; it overlaps '{under}'" if under else ""),
227
+ f"move it clear of '{under}', or add a box or a shadow" if under
228
+ else "add a box, a shadow, or change the colour")
229
+ # Reading time.
230
+ for layer in scene.all_layers():
231
+ if layer.type == "text":
232
+ words = _words(str(layer.raw.get("text", "")))
233
+ need = 0.4 + words / 3.3
234
+ if words >= 3 and layer.dur < need:
235
+ add("warning", "read-time", layer.id, layer.t_in,
236
+ f"{words} words on screen for {layer.dur:.2f}s; viewers need ~{need:.1f}s", "extend dur or cut words",
237
+ until=layer.t_out)
238
+ if layer.type == "captions":
239
+ for s0, s1, txt in layer.raw.get("cues", []):
240
+ words = _words(txt)
241
+ if words and (s1 - s0) < 0.25 + words / 4.5:
242
+ add("warning", "read-time", layer.id, layer.t_in + s0,
243
+ f"caption '{txt[:30]}' is {s1 - s0:.2f}s for {words} words", "split or lengthen the cue",
244
+ until=layer.t_in + s1)
245
+ break
246
+ scene.close()
247
+ return _result(scene.issues + list(found.values()), duration=scene.duration)
248
+
249
+
250
+ _RANK = {"error": 0, "warning": 1, "info": 2}
251
+
252
+
253
+ def _result(issues: list[dict], duration: float | None = None) -> dict:
254
+ """Errors first, then warnings, then info notes. `ok` is true when there are no errors."""
255
+ issues = sorted(issues, key=lambda i: (_RANK[i["level"]], i.get("from", -1)))
256
+ errors = sum(1 for i in issues if i["level"] == "error")
257
+ out = {"ok": errors == 0, "errors": errors, "warnings": sum(1 for i in issues if i["level"] == "warning"),
258
+ "issues": issues}
259
+ if duration is not None:
260
+ out["duration"] = round(duration, 3)
261
+ return out