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/render.py ADDED
@@ -0,0 +1,1204 @@
1
+ """Frame-pure renderer: frame n is a function of the scene and n alone."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import skia
12
+
13
+ from . import color as C
14
+ from . import media, render3d, space, svgpath
15
+ from .presets import IDENT, Mods
16
+ from .scene import Layer, Scene
17
+
18
+ ANCHORS = {"center": (0.5, 0.5), "topleft": (0, 0), "top": (0.5, 0), "topright": (1, 0), "left": (0, 0.5),
19
+ "right": (1, 0.5), "bottomleft": (0, 1), "bottom": (0.5, 1), "bottomright": (1, 1)}
20
+ FONT_FALLBACK = ["Segoe UI", "Helvetica Neue", "Arial", "DejaVu Sans"]
21
+ FONT_DIR = Path(__file__).parent / "fonts"
22
+ # Space Grotesk and IBM Plex Mono ship with the package (SIL OFL) so default text matches on every machine.
23
+ _GROTESK = {400: "SpaceGrotesk-Regular.ttf", 500: "SpaceGrotesk-Medium.ttf", 600: "SpaceGrotesk-SemiBold.ttf",
24
+ 700: "SpaceGrotesk-Bold.ttf"}
25
+ _PLEX = {400: "IBMPlexMono-Regular.ttf", 500: "IBMPlexMono-Medium.ttf", 600: "IBMPlexMono-SemiBold.ttf",
26
+ 700: "IBMPlexMono-Bold.ttf"}
27
+ BUNDLED = {"space grotesk": _GROTESK, "sans": _GROTESK, "ibm plex mono": _PLEX, "mono": _PLEX}
28
+ BLENDS = {"normal": skia.BlendMode.kSrcOver, "multiply": skia.BlendMode.kMultiply, "screen": skia.BlendMode.kScreen,
29
+ "overlay": skia.BlendMode.kOverlay, "add": skia.BlendMode.kPlus, "lighten": skia.BlendMode.kLighten,
30
+ "darken": skia.BlendMode.kDarken, "softLight": skia.BlendMode.kSoftLight,
31
+ "difference": skia.BlendMode.kDifference, "color": skia.BlendMode.kColor}
32
+ SAMPLING = skia.SamplingOptions(skia.FilterMode.kLinear, skia.MipmapMode.kNone)
33
+ # Video frames arrive at source size and upscale here. Catmull-Rom interpolates (a 1:1 draw is
34
+ # exact); Mitchell, used before, blurred every native-size frame (mean error 14.8 vs 4.7 on a sharp clip).
35
+ CUBIC = skia.SamplingOptions(skia.CubicResampler.CatmullRom())
36
+ _FMT = re.compile(r"\{:([^{}]*)\}")
37
+ _GL: dict = {}
38
+
39
+ # A frame as I420 (BT.709, limited range), computed on the GPU so the readback and the pipe to the
40
+ # encoder carry 12 bits a pixel instead of 32, and ffmpeg converts nothing. Each output RGBA pixel
41
+ # holds the next four bytes of the I420 buffer (Y plane, then U, then V), so one readPixels of a
42
+ # (w/4) x (1.5h) surface is the whole buffer. Chroma is the mean of each 2x2 block.
43
+ I420_SKSL = """
44
+ uniform shader src;
45
+ uniform float W;
46
+ uniform float H;
47
+ float3 px(float2 q) { return src.eval(q).rgb; }
48
+ float luma(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); }
49
+ float byte_at(float b) {
50
+ float wh = W * H;
51
+ if (b < wh) {
52
+ float y = floor(b / W);
53
+ return (16.0 + 219.0 * luma(px(float2(b - y * W + 0.5, y + 0.5)))) / 255.0;
54
+ }
55
+ float k = b - wh;
56
+ float q = wh * 0.25;
57
+ float plane = floor(k / q);
58
+ k -= plane * q;
59
+ float cw = W * 0.5;
60
+ float cy = floor(k / cw);
61
+ float2 o = float2((k - cy * cw) * 2.0, cy * 2.0);
62
+ float3 c = (px(o + float2(0.5, 0.5)) + px(o + float2(1.5, 0.5)) + px(o + float2(0.5, 1.5))
63
+ + px(o + float2(1.5, 1.5))) * 0.25;
64
+ float e = luma(c);
65
+ float d = plane < 0.5 ? (c.b - e) / 1.8556 : (c.r - e) / 1.5748;
66
+ return (128.0 + 224.0 * d) / 255.0;
67
+ }
68
+ half4 main(float2 p) {
69
+ float b = (floor(p.y) * (W * 0.25) + floor(p.x)) * 4.0;
70
+ return half4(byte_at(b), byte_at(b + 1.0), byte_at(b + 2.0), byte_at(b + 3.0));
71
+ }
72
+ """
73
+
74
+ # Motion blur on the GPU: each sub-frame is drawn into its own surface and one pass averages them, so
75
+ # only the result is read back. It is the integer mean (sum // n) of the 8-bit sub-frames, the same
76
+ # bytes as averaging their readbacks in numpy; the +0.5 keeps floor() correct when the GPU's division
77
+ # lands just under an exact multiple. The pass samples one texture per sub-frame, so up to BLUR_GPU of
78
+ # them; more are averaged in numpy.
79
+ BLUR_GPU = 16
80
+
81
+
82
+ def _blur_sksl(n: int) -> str:
83
+ adds = "".join(f"t += floor(float4(s{k}.eval(p)) * 255 + 0.5);" for k in range(n))
84
+ return ("".join(f"uniform shader s{k};" for k in range(n))
85
+ + f"float4 main(float2 p) {{ float4 t = float4(0); {adds} return floor((t + 0.5) / {n}.0) / 255; }}")
86
+
87
+
88
+ # Premultiplied to straight alpha with the integer rounding blurred frames have always had (_straighten).
89
+ STRAIGHT_SKSL = """
90
+ uniform shader src;
91
+ float4 main(float2 p) {
92
+ float4 c = floor(float4(src.eval(p)) * 255 + 0.5);
93
+ float3 rgb = c.a > 0 ? min(floor((c.rgb * 255 + floor(c.a * 0.5) + 0.5) / c.a), float3(255)) : float3(0);
94
+ return float4(rgb, c.a) / 255;
95
+ }
96
+ """
97
+
98
+
99
+ def _straighten(buf: np.ndarray):
100
+ """Premultiplied RGBA to straight, in place: (c * 255 + a // 2) // a. Alpha 0 becomes black, alpha 255
101
+ is already straight, so only the partly transparent pixels are computed."""
102
+ a = buf[..., 3]
103
+ buf[a == 0] = 0
104
+ part = (a > 0) & (a < 255)
105
+ px = buf[part]
106
+ al = px[:, 3:4].astype(np.uint16)
107
+ px[:, :3] = np.minimum((px[:, :3].astype(np.uint16) * 255 + al // 2) // al, 255)
108
+ buf[part] = px
109
+
110
+
111
+ def gpu_context():
112
+ """One hidden OpenGL context per process, or None when there is no usable GPU.
113
+ Set KINE_BACKEND=cpu to force the raster backend."""
114
+ if "ctx" in _GL:
115
+ return _GL["ctx"]
116
+ _GL["ctx"] = None
117
+ if os.environ.get("KINE_BACKEND", "").lower() == "cpu":
118
+ return None
119
+ try:
120
+ import glfw
121
+ if not glfw.init():
122
+ return None
123
+ glfw.window_hint(glfw.VISIBLE, glfw.FALSE)
124
+ win = glfw.create_window(16, 16, "kinescript", None, None)
125
+ if not win:
126
+ return None
127
+ glfw.make_context_current(win)
128
+ _GL["win"] = win
129
+ _GL["ctx"] = skia.GrDirectContext.MakeGL()
130
+ except (ImportError, RuntimeError, OSError):
131
+ _GL["ctx"] = None
132
+ return _GL["ctx"]
133
+
134
+
135
+ @dataclass
136
+ class Run:
137
+ text: str
138
+ x: float
139
+ line: int
140
+ emph: bool
141
+ word: int
142
+ char0: int
143
+
144
+
145
+ @dataclass
146
+ class Layout:
147
+ runs: list
148
+ lines: list # widths
149
+ w: float
150
+ h: float
151
+ lh: float
152
+ baseline0: float
153
+ nchars: int
154
+ nwords: int
155
+
156
+
157
+ def color_matrix(fx: dict) -> list[float] | None:
158
+ """4x5 row-major colour matrix from brightness/contrast/saturate/hue/exposure/temperature/tint/invert/gray."""
159
+ if not fx:
160
+ return None
161
+ m = np.eye(5)
162
+
163
+ def apply(mat):
164
+ nonlocal m
165
+ m = mat @ m
166
+ if fx.get("exposure"):
167
+ k = 2 ** float(fx["exposure"])
168
+ apply(np.diag([k, k, k, 1, 1]))
169
+ if fx.get("brightness"):
170
+ b = float(fx["brightness"])
171
+ e = np.eye(5); e[:3, 4] = b
172
+ apply(e)
173
+ if fx.get("contrast") not in (None, 1):
174
+ c = float(fx["contrast"])
175
+ e = np.diag([c, c, c, 1, 1]); e[:3, 4] = 0.5 * (1 - c)
176
+ apply(e)
177
+ s = fx.get("saturate", fx.get("saturation"))
178
+ if fx.get("gray"):
179
+ s = 1 - float(fx["gray"])
180
+ if s not in (None, 1):
181
+ s = float(s)
182
+ lr, lg, lb = 0.2126, 0.7152, 0.0722
183
+ e = np.eye(5)
184
+ e[:3, :3] = [[lr * (1 - s) + s, lg * (1 - s), lb * (1 - s)],
185
+ [lr * (1 - s), lg * (1 - s) + s, lb * (1 - s)],
186
+ [lr * (1 - s), lg * (1 - s), lb * (1 - s) + s]]
187
+ apply(e)
188
+ if fx.get("hue"):
189
+ a = math.radians(float(fx["hue"]))
190
+ cs, sn = math.cos(a), math.sin(a)
191
+ e = np.eye(5)
192
+ e[:3, :3] = [[0.213 + cs * 0.787 - sn * 0.213, 0.715 - cs * 0.715 - sn * 0.715, 0.072 - cs * 0.072 + sn * 0.928],
193
+ [0.213 - cs * 0.213 + sn * 0.143, 0.715 + cs * 0.285 + sn * 0.140, 0.072 - cs * 0.072 - sn * 0.283],
194
+ [0.213 - cs * 0.213 - sn * 0.787, 0.715 - cs * 0.715 + sn * 0.715, 0.072 + cs * 0.928 + sn * 0.072]]
195
+ apply(e)
196
+ if fx.get("temperature") or fx.get("tint"):
197
+ k, g = float(fx.get("temperature", 0)), float(fx.get("tint", 0))
198
+ apply(np.diag([1 + 0.12 * k, 1 + 0.08 * g, 1 - 0.12 * k, 1, 1]))
199
+ if fx.get("invert"):
200
+ e = np.diag([-1, -1, -1, 1, 1]).astype(float); e[:3, 4] = 1
201
+ apply(e)
202
+ return [float(v) for v in m[:4, :].flatten()]
203
+
204
+
205
+ def _argb(c, alpha: float = 1.0) -> int:
206
+ if c is None:
207
+ return 0
208
+ if isinstance(c, str):
209
+ c = C.parse(c)
210
+ return C.to_argb_int(c, alpha)
211
+
212
+
213
+ def declare_uniforms(src: str, uniforms: dict) -> str:
214
+ """Add `uniform` lines for built-ins (iTime, iResolution, iProgress) and for entries of the layer's
215
+ "uniforms" that the source uses without declaring, so a shader needs only its main()."""
216
+ decl = []
217
+ wanted = {"iTime": "float", "iResolution": "float2", "iProgress": "float"}
218
+ for name, v in uniforms.items():
219
+ n = len(v) if isinstance(v, (list, tuple)) else 4 if C.is_color(v) else 1
220
+ wanted[name] = "float" if n == 1 else f"float{n}"
221
+ for name, typ in wanted.items():
222
+ if re.search(rf"\b{name}\b", src) and not re.search(rf"\buniform\s+\w+\s+{name}\b", src):
223
+ decl.append(f"uniform {typ} {name};")
224
+ return "\n".join(decl + [glsl_to_sksl(src)]) if decl else glsl_to_sksl(src)
225
+
226
+
227
+ def glsl_to_sksl(src: str) -> str:
228
+ """Accept the two shapes models usually write instead of SkSL's `half4 main(float2 p)`:
229
+ Shadertoy's mainImage(out vec4 fragColor, in vec2 fragCoord), and GLSL's void main() with
230
+ gl_FragCoord / gl_FragColor (or a vec4 main() that returns the colour)."""
231
+ if re.search(r"\b(half4|float4|vec4)\s+main\s*\(\s*(float2|vec2)", src):
232
+ return src
233
+ if re.search(r"\bvoid\s+mainImage\s*\(", src):
234
+ return src + "\nhalf4 main(float2 p) { vec4 c = vec4(0); mainImage(c, p); return half4(c); }"
235
+ m = re.search(r"\b(void|vec4|half4|float4)\s+main\s*\(\s*(void)?\s*\)\s*\{", src)
236
+ if not m:
237
+ return src
238
+ depth, end = 1, m.end()
239
+ while end < len(src) and depth:
240
+ depth += {"{": 1, "}": -1}.get(src[end], 0)
241
+ end += 1
242
+ body = src[m.end():end - 1].replace("gl_FragCoord", "_kpos").replace("gl_FragColor", "_kcol")
243
+ if m.group(1) == "void":
244
+ body = "vec4 _kcol = vec4(0);" + body + "return half4(_kcol);"
245
+ return src[:m.start()] + "half4 main(float2 _kpos) {" + body + "}" + src[end:]
246
+
247
+
248
+ class Renderer:
249
+ def __init__(self, scene: Scene, track: bool = False, scale: float = 1.0, backend: str = "auto",
250
+ even: bool = False):
251
+ self.s = scene
252
+ self.scale = scale
253
+ self.w, self.h = max(2, round(scene.w * scale)), max(2, round(scene.h * scale))
254
+ if even: # 4:2:0 video needs even sizes
255
+ self.w, self.h = self.w - self.w % 2, self.h - self.h % 2
256
+ self.info = skia.ImageInfo.Make(self.w, self.h, skia.kRGBA_8888_ColorType, skia.kPremul_AlphaType)
257
+ ctx = gpu_context() if backend in ("auto", "gpu") else None
258
+ if backend == "gpu" and ctx is None:
259
+ raise RuntimeError("GPU backend requested but no OpenGL context could be created")
260
+ self.surface = skia.Surface.MakeRenderTarget(ctx, skia.Budgeted.kNo, self.info) if ctx else None
261
+ self.backend = "gpu" if self.surface is not None else "cpu"
262
+ if self.surface is None:
263
+ self.surface = skia.Surface.MakeRaster(self.info)
264
+ self.buf = np.empty((self.h, self.w, 4), np.uint8)
265
+ # Encoders take straight (unpremultiplied) RGBA; reading premultiplied pixels into a transparent
266
+ # .mov/.webm darkens every soft edge. Opaque frames are identical either way.
267
+ self.transparent = scene.bg in (None, "transparent", "none")
268
+ self.read_info = self.info.makeAlphaType(skia.kUnpremul_AlphaType) if self.transparent else self.info
269
+ self.track = track
270
+ self.boxes: dict[str, dict] = {}
271
+ self._tf: dict = {}
272
+ self._layouts: dict = {}
273
+ self._fonts: dict = {}
274
+ self._paths: dict = {}
275
+ self._shaders: dict = {}
276
+ self.stop_at: str | None = None
277
+ self._stopped = False
278
+ self._m3d: np.ndarray | None = None # world-to-canvas matrix of the layer being drawn, in 3D scenes
279
+ self.culled: set[str] = set() # layers skipped this frame because they cross the camera plane
280
+ self._lens: np.ndarray | None = None # camera space to canvas, for lighting models
281
+ self._models: dict = {} # merged parts of each model layer
282
+ self._solids: dict = {} # outlines and extruded meshes of text and path models
283
+ self._outer = None # the 3D matrix of the layer being drawn's parent (models put their floor in it)
284
+ self._msurf = None # offscreen that models draw into, antialiased, before compositing
285
+ self.tris = 0 # model triangles drawn this frame
286
+ self._subframes = None # motion blur on the GPU: (count, averaging effect, a surface per sub-frame)
287
+ self._straight = None # (effect, surface) straightening blurred transparent frames on the GPU
288
+ cm = color_matrix({k: v for k, v in scene.grade.items() if k not in ("vignette", "grain")})
289
+ self.grade_filter = skia.ColorFilters.Matrix(cm) if cm else None
290
+
291
+ # ---- public -------------------------------------------------------------------------
292
+ def frame(self, n: int) -> np.ndarray:
293
+ """RGBA uint8 array (h, w, 4) for frame n. The array is reused between calls."""
294
+ if self._compose(n) > 1 and self.transparent:
295
+ # A blurred frame keeps the integer rounding it has always been straightened with.
296
+ if self.backend == "gpu":
297
+ self._straightened().readPixels(self.info, self.buf, self.w * 4)
298
+ else:
299
+ self.surface.readPixels(self.info, self.buf, self.w * 4)
300
+ _straighten(self.buf)
301
+ else:
302
+ self.surface.readPixels(self.read_info, self.buf, self.w * 4)
303
+ return self.buf
304
+
305
+ def _compose(self, n: int) -> int:
306
+ """Draw frame n into the surface: with motion blur, the mean of its sub-frames. Returns how many."""
307
+ mb = self.s.motion_blur
308
+ samples = int(mb.get("samples", 0)) if mb else 0
309
+ T = n / self.s.fps
310
+ if samples < 2:
311
+ self.draw(T)
312
+ return 1
313
+ shutter = float(mb.get("shutter", 0.5)) / self.s.fps
314
+ times = [T + (k / (samples - 1) - 0.5) * shutter for k in range(samples)]
315
+ if self.backend == "gpu" and samples <= BLUR_GPU:
316
+ self._blur_gpu(times)
317
+ else:
318
+ self._blur_cpu(times)
319
+ return samples
320
+
321
+ def _blur_gpu(self, times: list[float]):
322
+ n = len(times)
323
+ if self._subframes is None or self._subframes[0] != n:
324
+ ctx = self.surface.recordingContext()
325
+ self._subframes = (n, skia.RuntimeEffect.MakeForShader(_blur_sksl(n)),
326
+ [skia.Surface.MakeRenderTarget(ctx, skia.Budgeted.kNo, self.info) for _ in range(n)])
327
+ _, eff, surfs = self._subframes
328
+ main = self.surface
329
+ try:
330
+ for t, surf in zip(times, surfs):
331
+ self.surface = surf # draw() draws into self.surface
332
+ self.draw(t)
333
+ finally:
334
+ self.surface = main
335
+ imgs = [s.makeImageSnapshot() for s in surfs]
336
+ b = skia.RuntimeEffectBuilder(eff)
337
+ for k, img in enumerate(imgs):
338
+ b.setChild(f"s{k}", img.makeShader(skia.TileMode.kClamp, skia.TileMode.kClamp, skia.SamplingOptions()))
339
+ c = main.getCanvas()
340
+ c.resetMatrix()
341
+ c.drawPaint(skia.Paint(Shader=b.makeShader(), BlendMode=skia.BlendMode.kSrc))
342
+ del imgs, b # as in frame_i420: a live snapshot makes the next draw into its surface copy it first
343
+
344
+ def _straightened(self) -> skia.Surface:
345
+ """A surface holding this surface's frame with straight alpha (STRAIGHT_SKSL)."""
346
+ if self._straight is None:
347
+ self._straight = (skia.RuntimeEffect.MakeForShader(STRAIGHT_SKSL), skia.Surface.MakeRenderTarget(
348
+ self.surface.recordingContext(), skia.Budgeted.kNo, self.info))
349
+ eff, surf = self._straight
350
+ img = self.surface.makeImageSnapshot()
351
+ b = skia.RuntimeEffectBuilder(eff)
352
+ b.setChild("src", img.makeShader(skia.TileMode.kClamp, skia.TileMode.kClamp, skia.SamplingOptions()))
353
+ surf.getCanvas().drawPaint(skia.Paint(Shader=b.makeShader(), BlendMode=skia.BlendMode.kSrc))
354
+ del img, b
355
+ return surf
356
+
357
+ def _blur_cpu(self, times: list[float]):
358
+ # 257 sub-frames of 255 still fit in 16 bits: half the memory traffic of 32-bit sums, same mean.
359
+ acc = np.zeros((self.h, self.w, 4), np.uint16 if len(times) <= 257 else np.uint32)
360
+ for t in times:
361
+ self.draw(t)
362
+ self.surface.readPixels(self.info, self.buf, self.w * 4)
363
+ np.add(acc, self.buf, out=acc)
364
+ np.floor_divide(acc, len(times), out=acc)
365
+ np.copyto(self.buf, acc, casting="unsafe")
366
+ self.surface.writePixels(skia.Pixmap(self.info, self.buf, self.w * 4)) # stills save the surface
367
+
368
+ @property
369
+ def yuv(self) -> bool:
370
+ """Whether frame_i420 runs on the GPU: an opaque frame with width a multiple of 4."""
371
+ return self.backend == "gpu" and not self.transparent and self.w % 4 == 0 and self.h % 2 == 0
372
+
373
+ def frame_i420(self, n: int) -> np.ndarray:
374
+ """Frame n as an I420 buffer (BT.709, limited range), reused between calls. Needs self.yuv."""
375
+ if not hasattr(self, "_i420"):
376
+ info = skia.ImageInfo.Make(self.w // 4, self.h * 3 // 2, skia.kRGBA_8888_ColorType, skia.kPremul_AlphaType)
377
+ surf = skia.Surface.MakeRenderTarget(self.surface.recordingContext(), skia.Budgeted.kNo, info)
378
+ self._i420 = (skia.RuntimeEffect.MakeForShader(I420_SKSL), info, surf,
379
+ np.empty(self.w * self.h * 3 // 2, np.uint8))
380
+ eff, info, surf, buf = self._i420
381
+ self._compose(n)
382
+ img = self.surface.makeImageSnapshot()
383
+ b = skia.RuntimeEffectBuilder(eff)
384
+ b.setChild("src", img.makeShader(skia.TileMode.kClamp, skia.TileMode.kClamp, skia.SamplingOptions()))
385
+ b.setUniform("W", float(self.w))
386
+ b.setUniform("H", float(self.h))
387
+ surf.getCanvas().drawPaint(skia.Paint(Shader=b.makeShader(), BlendMode=skia.BlendMode.kSrc))
388
+ # Drop the snapshot before the next draw: while it lives, drawing to the surface copies the
389
+ # whole texture first (copy-on-write), which cost ~80 ms a frame.
390
+ del img, b
391
+ surf.readPixels(info, buf, self.w)
392
+ return buf
393
+
394
+ def snapshot(self) -> skia.Image:
395
+ return self.surface.makeImageSnapshot()
396
+
397
+ def draw(self, T: float, stop_at: str | None = None):
398
+ s = self.s
399
+ self.boxes = {}
400
+ self.culled = set()
401
+ self.tris = 0
402
+ self.stop_at, self._stopped = stop_at, False
403
+ c = self.surface.getCanvas()
404
+ c.resetMatrix()
405
+ c.clear(skia.ColorTRANSPARENT)
406
+ if self.scale != 1:
407
+ c.scale(self.scale, self.scale)
408
+ self._background(c)
409
+ if self.grade_filter:
410
+ c.saveLayer(None, skia.Paint(ColorFilter=self.grade_filter))
411
+ cams = self._cameras(T) if s.is3d else None
412
+ for layer in s.layers:
413
+ if self._stopped:
414
+ break
415
+ m = None
416
+ if cams is not None:
417
+ fixed = layer.raw.get("fixed", layer.type == "captions")
418
+ if s.camera is not None and not fixed:
419
+ m = cams[0]
420
+ elif layer.id in s.depth:
421
+ m = cams[1]
422
+ self._m3d = m
423
+ if m is None:
424
+ self._layer(c, layer, T, 1.0)
425
+ else:
426
+ c.save()
427
+ c.concat(space.to_skia(m))
428
+ self._layer(c, layer, T, 1.0)
429
+ c.restore()
430
+ self._m3d = None
431
+ if self.grade_filter:
432
+ c.restore()
433
+ if not stop_at:
434
+ self._finish(c, T)
435
+
436
+ # ---- scene-level ---------------------------------------------------------------------
437
+ def _cameras(self, T: float):
438
+ """(world, fixed) camera matrices at T. Fixed layers share the lens but not the moves."""
439
+ s, cam = self.s, self.s.camera
440
+ fov = float(cam.get("fov", T, T, default=space.DEFAULT_FOV)) if cam else space.DEFAULT_FOV
441
+ fixed = space.camera(s.w, s.h, fov)
442
+ self._lens = space.lens(s.w, s.h, fov)
443
+ if cam is None:
444
+ return fixed, fixed
445
+ g = lambda k: float(cam.get(k, T, T)) # noqa: E731
446
+ return space.camera(s.w, s.h, fov, g("x"), g("y"), g("z"), g("rotateX"), g("rotateY"), g("rotate")), fixed
447
+
448
+ def _far(self, L: Layer, T: float) -> float:
449
+ """Sort key that puts the child farthest from the camera first."""
450
+ t = T - L.t_in
451
+ p = self._m3d @ np.array([float(L.get("x", t, T)), float(L.get("y", t, T)), float(L.get("z", t, T)), 1.0])
452
+ return -p[3]
453
+
454
+ def _background(self, c: skia.Canvas):
455
+ bg = self.s.bg
456
+ if isinstance(bg, dict):
457
+ p = skia.Paint(Shader=self._gradient(bg, 0, 0, self.s.w, self.s.h, 1.0))
458
+ c.drawRect(skia.Rect(0, 0, self.s.w, self.s.h), p)
459
+ elif bg:
460
+ c.clear(_argb(bg))
461
+
462
+ def _finish(self, c: skia.Canvas, T: float):
463
+ g = self.s.grade
464
+ W, H = self.s.w, self.s.h
465
+ if g.get("vignette"):
466
+ v = float(g["vignette"])
467
+ sh = skia.GradientShader.MakeRadial(skia.Point(W / 2, H / 2), math.hypot(W, H) / 2,
468
+ [_argb("#000000", 0), _argb("#000000", 0), _argb("#000000", min(1, v))],
469
+ [0.0, 0.55, 1.0])
470
+ c.drawRect(skia.Rect(0, 0, W, H), skia.Paint(Shader=sh))
471
+ if g.get("grain"):
472
+ tiles = media.grain_tiles()
473
+ n = int(T * self.s.fps)
474
+ tile = tiles[n % len(tiles)]
475
+ mtx = skia.Matrix.Translate((n * 37) % 256, (n * 91) % 256)
476
+ p = skia.Paint(Shader=tile.makeShader(skia.TileMode.kRepeat, skia.TileMode.kRepeat, SAMPLING, mtx),
477
+ Alphaf=min(1.0, float(g["grain"])), BlendMode=skia.BlendMode.kOverlay)
478
+ c.drawRect(skia.Rect(0, 0, W, H), p)
479
+
480
+ # ---- layers --------------------------------------------------------------------------
481
+ def _layer(self, c: skia.Canvas, L: Layer, T: float, parent_alpha: float):
482
+ if self.stop_at == L.id:
483
+ self._stopped = True
484
+ return
485
+ if not L.visible(T):
486
+ return
487
+ t = T - L.t_in
488
+ d = L.dur
489
+ g = lambda name, default=None: L.get(name, t, T, 0, default) # noqa: E731
490
+ split = L.raw.get("split") if L.type in ("text", "counter") else None
491
+ mods = IDENT
492
+ if L.enter and not split and t < L.enter.dur:
493
+ mods = L.enter.mods(t / L.enter.dur)
494
+ if L.exit and d - t < L.exit.dur:
495
+ mods = mods.combine(L.exit.mods((d - t) / L.exit.dur))
496
+ alpha = max(0.0, min(1.0, float(g("opacity")) * mods.alpha))
497
+ if alpha <= 0.002:
498
+ return
499
+ box = self._box(L, t, T, g)
500
+ x, y = float(g("x")) + mods.dx, float(g("y")) + mods.dy
501
+ rot = float(g("rotate")) + mods.rotate
502
+ sc = float(g("scale")) * mods.scale
503
+ sx, sy = sc * float(g("scaleX")), sc * float(g("scaleY"))
504
+ outer, tilt, facing = self._m3d, None, True
505
+ if outer is not None:
506
+ z, rx, ry = float(g("z")) + mods.dz, float(g("rotateX")) + mods.rx, float(g("rotateY")) + mods.ry
507
+ m = outer @ space.translate(x, y)
508
+ if z or rx or ry:
509
+ tilt = space.tilt(z, rx, ry)
510
+ m = m @ tilt
511
+ m = m @ space.rotate_z(rot) @ space.scale(sx, sy)
512
+ if box is not None:
513
+ xy, w = space.project(m, box)
514
+ if w.min() < space.NEAR:
515
+ self.culled.add(L.id)
516
+ return
517
+ facing = space.facing(xy)
518
+ if not facing and L.raw.get("backface") == "hidden":
519
+ return
520
+ self._m3d, self._outer = m, outer
521
+ c.save()
522
+ c.translate(x, y)
523
+ if tilt is not None:
524
+ c.concat(space.to_skia(tilt))
525
+ if rot:
526
+ c.rotate(rot)
527
+ if sx != 1 or sy != 1:
528
+ c.scale(sx, sy)
529
+ blur = float(g("blur")) + mods.blur
530
+ shadow = L.raw.get("shadow")
531
+ fx = L.raw.get("fx")
532
+ blend = L.raw.get("blend")
533
+ layered = alpha < 0.999 or blur > 0.05 or shadow or fx or (blend and blend != "normal")
534
+ if layered:
535
+ paint = skia.Paint(Alphaf=alpha)
536
+ filt = None
537
+ if blur > 0.05:
538
+ filt = skia.ImageFilters.Blur(blur, blur)
539
+ if shadow:
540
+ sh = shadow if isinstance(shadow, dict) else {}
541
+ sig = float(sh.get("blur", 16)) / 2
542
+ filt = skia.ImageFilters.DropShadow(float(sh.get("x", 0)), float(sh.get("y", 6)), sig, sig,
543
+ _argb(sh.get("color", "#00000099")), filt)
544
+ if filt:
545
+ paint.setImageFilter(filt)
546
+ if fx:
547
+ cm = color_matrix(fx)
548
+ if cm:
549
+ paint.setColorFilter(skia.ColorFilters.Matrix(cm))
550
+ if blend:
551
+ paint.setBlendMode(BLENDS.get(blend, skia.BlendMode.kSrcOver))
552
+ c.saveLayer(None, paint)
553
+ if box is not None:
554
+ bx, by, bw, bh = box
555
+ if mods.wipe and mods.reveal < 1:
556
+ r = max(0.0, mods.reveal)
557
+ clip = {"right": (bx, by, bx + bw * r, by + bh), "left": (bx + bw * (1 - r), by, bx + bw, by + bh),
558
+ "down": (bx, by, bx + bw, by + bh * r), "up": (bx, by + bh * (1 - r), bx + bw, by + bh)}[mods.wipe]
559
+ c.clipRect(skia.Rect(*clip), doAntiAlias=True)
560
+ reveal = mods.reveal if not mods.wipe else 1.0
561
+ draw = getattr(self, "_draw_" + L.type)
562
+ drawn = draw(c, L, t, T, g, box, reveal, mods) # a model returns its projected bounds
563
+ if self.track and (box is not None or drawn):
564
+ m = c.getTotalMatrix()
565
+ r = skia.Rect(*drawn) if drawn else m.mapRect(skia.Rect.MakeXYWH(*box))
566
+ sc = 1.0 if drawn else math.sqrt(abs(m.getScaleX() * m.getScaleY())) or abs(m.getSkewX())
567
+ info = {"rect": [round(r.left(), 1), round(r.top(), 1), round(r.right(), 1), round(r.bottom(), 1)],
568
+ "alpha": round(alpha * parent_alpha, 3), "type": L.type, "scale": round(sc, 4)}
569
+ if L.type in ("text", "counter", "captions"):
570
+ # The colour _draw_text uses, so checks see default-coloured text too.
571
+ fill = g("fill") if "fill" in L.props else C.parse(self.s.theme.get("paper", "#f2f0e8"))
572
+ info.update(size=float(g("size", 64)), fill=list(fill) if isinstance(fill, tuple) else None,
573
+ text=self._last_text if hasattr(self, "_last_text") else "")
574
+ if not facing:
575
+ info["mirrored"] = True
576
+ self.boxes[L.id] = info
577
+ if L.type == "group":
578
+ kids = L.children
579
+ if self._m3d is not None and L.raw.get("sort") == "depth":
580
+ kids = sorted(kids, key=lambda k: self._far(k, T))
581
+ for child in kids:
582
+ if self._stopped:
583
+ break
584
+ self._layer(c, child, T, parent_alpha * alpha)
585
+ if layered:
586
+ c.restore()
587
+ c.restore()
588
+ self._m3d = outer
589
+
590
+ def _box(self, L: Layer, t, T, g):
591
+ """Local content rect (x, y, w, h) after anchoring, or None for unbounded layers."""
592
+ ty = L.type
593
+ if ty == "group":
594
+ if L.raw.get("w") is None and L.raw.get("h") is None:
595
+ return None
596
+ w, h = float(g("w", self.s.w)), float(g("h", self.s.h))
597
+ ax, ay = ANCHORS.get(L.raw.get("anchor", "topleft"), (0, 0))
598
+ return (-ax * w, -ay * h, w, h)
599
+ if ty in ("text", "counter"):
600
+ lay = self._text_layout(L, t, T, g, self._text_of(L, t, T, g))
601
+ pad = self._pad(L)
602
+ w, h = lay.w + pad[1] * 2, lay.h + pad[0] * 2
603
+ elif ty in ("captions", "model"):
604
+ return None # a model's bounds come from its projected mesh (render3d.draw)
605
+ elif ty in ("circle",):
606
+ w = h = float(g("r", 50)) * 2
607
+ elif ty in ("path", "line", "arrow", "polygon"):
608
+ p = self._path(L, t, T, g)
609
+ b = p.computeTightBounds()
610
+ if "anchor" not in L.raw:
611
+ return (b.left(), b.top(), b.width(), b.height())
612
+ w, h = b.width(), b.height()
613
+ ax, ay = ANCHORS.get(L.raw["anchor"], (0.5, 0.5))
614
+ return (-ax * w, -ay * h, w, h)
615
+ elif ty == "image":
616
+ img = media.image(L.src)
617
+ w, h = float(g("w", img.width())), float(g("h", img.height()))
618
+ if L.raw.get("w") is not None and L.raw.get("h") is None:
619
+ h = w * img.height() / img.width()
620
+ elif L.raw.get("h") is not None and L.raw.get("w") is None:
621
+ w = h * img.width() / img.height()
622
+ elif ty in ("video", "shader"):
623
+ w, h = float(g("w", self.s.w)), float(g("h", self.s.h))
624
+ else:
625
+ w, h = float(g("w", 200)), float(g("h", 120))
626
+ default_anchor = "topleft" if ty in ("video", "shader") or (ty == "image" and L.raw.get("x") is None) else "center"
627
+ ax, ay = ANCHORS.get(L.raw.get("anchor", default_anchor), (0.5, 0.5))
628
+ return (-ax * w, -ay * h, w, h)
629
+
630
+ # ---- paint helpers --------------------------------------------------------------------
631
+ def _gradient(self, spec: dict, x, y, w, h, alpha: float):
632
+ stops = spec.get("stops", ["#000", "#fff"])
633
+ colors = [_argb(s if isinstance(s, str) else s[1], alpha) for s in stops]
634
+ pos = None
635
+ if stops and not isinstance(stops[0], str):
636
+ pos = [float(s[0]) for s in stops]
637
+ if "radial" in spec:
638
+ cx, cy, r = (spec["radial"] + [0.5, 0.5, 0.6])[:3] if isinstance(spec["radial"], list) else (0.5, 0.5, 0.6)
639
+ return skia.GradientShader.MakeRadial(skia.Point(x + cx * w, y + cy * h), max(1.0, r * max(w, h)), colors, pos)
640
+ a, b = spec.get("linear", [[0, 0], [1, 1]])
641
+ return skia.GradientShader.MakeLinear([skia.Point(x + a[0] * w, y + a[1] * h), skia.Point(x + b[0] * w, y + b[1] * h)],
642
+ colors, pos)
643
+
644
+ def _fill_paint(self, L: Layer, g, box, alpha=1.0, key="fill", default=None):
645
+ raw = L.raw.get(key, default)
646
+ if raw is None:
647
+ return None
648
+ p = skia.Paint(AntiAlias=True)
649
+ if isinstance(raw, dict):
650
+ bx, by, bw, bh = box
651
+ p.setShader(self._gradient(raw, bx, by, bw, bh, alpha))
652
+ else:
653
+ col = g(key) if key in L.props else C.parse(raw) if isinstance(raw, str) else raw
654
+ p.setColor(_argb(col, alpha))
655
+ return p
656
+
657
+ def _stroke_paint(self, L: Layer, g, alpha=1.0):
658
+ if L.raw.get("stroke") is None:
659
+ return None
660
+ col = g("stroke") if "stroke" in L.props else C.parse(L.raw["stroke"])
661
+ p = skia.Paint(AntiAlias=True, Style=skia.Paint.kStroke_Style, StrokeWidth=float(g("strokeWidth", 4)))
662
+ p.setColor(_argb(col, alpha))
663
+ cap = L.raw.get("cap", "round" if L.type in ("line", "arrow", "path") else "butt")
664
+ p.setStrokeCap({"round": skia.Paint.kRound_Cap, "square": skia.Paint.kSquare_Cap}.get(cap, skia.Paint.kButt_Cap))
665
+ p.setStrokeJoin({"round": skia.Paint.kRound_Join, "bevel": skia.Paint.kBevel_Join}.get(L.raw.get("join", "round"), skia.Paint.kMiter_Join))
666
+ return p
667
+
668
+ @staticmethod
669
+ def _trim(path: skia.Path, amount: float, start: float = 0.0) -> skia.Path:
670
+ if amount >= 0.999 and start <= 0:
671
+ return path
672
+ out = skia.Path()
673
+ meas = skia.PathMeasure(path, False)
674
+ while True:
675
+ n = meas.getLength()
676
+ meas.getSegment(n * start, n * max(start, min(1.0, amount)), out, True)
677
+ if not meas.nextContour():
678
+ break
679
+ return out
680
+
681
+ def _shape(self, c, L, g, path: skia.Path, box, reveal):
682
+ """Fill then stroke. 'draw' (0..1) trims the stroke and fades the fill with it."""
683
+ amt = max(0.0, min(1.0, float(g("draw")) * reveal))
684
+ has_stroke = L.raw.get("stroke") is not None
685
+ fp = self._fill_paint(L, g, box, alpha=amt, default=None if has_stroke else self.s.theme.get("paper", "#f2f0e8"))
686
+ if fp is not None and amt > 0:
687
+ c.drawPath(path, fp)
688
+ sp = self._stroke_paint(L, g)
689
+ if sp is not None and amt > 0.0005:
690
+ c.drawPath(self._trim(path, amt, float(g("drawStart", 0.0))), sp)
691
+
692
+ # ---- layer types ----------------------------------------------------------------------
693
+ def _draw_group(self, c, L, t, T, g, box, reveal, mods):
694
+ if box is not None and L.raw.get("clip"):
695
+ r = float(g("radius", 0))
696
+ rr = skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r)
697
+ c.clipRRect(rr, doAntiAlias=True)
698
+ fp = self._fill_paint(L, g, box) if box is not None else None
699
+ if fp is not None:
700
+ r = float(g("radius", 0))
701
+ c.drawRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r), fp)
702
+
703
+ def _draw_rect(self, c, L, t, T, g, box, reveal, mods):
704
+ bx, by, bw, bh = box
705
+ r = min(float(g("radius", 0)), bw / 2, bh / 2)
706
+ dim = float(g("dimOutside", 0))
707
+ if dim > 0:
708
+ hole = skia.Path()
709
+ hole.addRect(skia.Rect(-40000, -40000, 40000, 40000))
710
+ hole.addRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r))
711
+ hole.setFillType(skia.PathFillType.kEvenOdd)
712
+ c.drawPath(hole, skia.Paint(AntiAlias=True, Color=_argb("#000000", dim * reveal)))
713
+ path = skia.Path()
714
+ path.addRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r))
715
+ self._shape(c, L, g, path, box, reveal)
716
+
717
+ def _draw_circle(self, c, L, t, T, g, box, reveal, mods):
718
+ path = skia.Path()
719
+ path.addOval(skia.Rect.MakeXYWH(*box), skia.PathDirection.kCW, 3) # start at the top
720
+ self._shape(c, L, g, path, box, reveal)
721
+
722
+ def _draw_ellipse(self, c, L, t, T, g, box, reveal, mods):
723
+ self._draw_circle(c, L, t, T, g, box, reveal, mods)
724
+
725
+ def _path(self, L: Layer, t, T, g) -> skia.Path:
726
+ ty = L.type
727
+ static = all(L.props[k].static for k in ("x1", "y1", "x2", "y2", "bend", "r", "sides", "inner") if k in L.props)
728
+ key = L.id
729
+ if static and key in self._paths:
730
+ return self._paths[key]
731
+ p = skia.Path()
732
+ if ty == "path" and L.raw.get("d"):
733
+ p = svgpath.parse(L.raw["d"])
734
+ elif ty in ("path", "line"):
735
+ pts = L.raw.get("points") or [[0, 0], [100, 0]]
736
+ if L.raw.get("smooth") and len(pts) > 2:
737
+ p.moveTo(*pts[0])
738
+ for i in range(len(pts) - 1):
739
+ p0, p1, p2, p3 = pts[max(0, i - 1)], pts[i], pts[i + 1], pts[min(len(pts) - 1, i + 2)]
740
+ p.cubicTo(p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6,
741
+ p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6, p2[0], p2[1])
742
+ else:
743
+ p.moveTo(*pts[0])
744
+ for q in pts[1:]:
745
+ p.lineTo(*q)
746
+ if L.raw.get("closed"):
747
+ p.close()
748
+ elif ty == "arrow":
749
+ x1, y1 = float(g("x1", 0)), float(g("y1", 0))
750
+ x2, y2 = float(g("x2", 200)), float(g("y2", 0))
751
+ bend = float(g("bend", 0.0))
752
+ mx, my = (x1 + x2) / 2, (y1 + y2) / 2
753
+ nx, ny = -(y2 - y1), (x2 - x1)
754
+ p.moveTo(x1, y1)
755
+ p.quadTo(mx + nx * bend, my + ny * bend, x2, y2)
756
+ elif ty == "polygon":
757
+ n = max(3, int(g("sides", 6)))
758
+ r = float(g("r", 80))
759
+ inner = g("inner")
760
+ pts = []
761
+ steps = n * 2 if inner else n
762
+ for k in range(steps):
763
+ a = -math.pi / 2 + k * 2 * math.pi / steps
764
+ rr = r * (float(inner) if inner and k % 2 else 1)
765
+ pts.append((math.cos(a) * rr, math.sin(a) * rr))
766
+ p.moveTo(*pts[0])
767
+ for q in pts[1:]:
768
+ p.lineTo(*q)
769
+ p.close()
770
+ if static:
771
+ self._paths[key] = p
772
+ return p
773
+
774
+ def _draw_path(self, c, L, t, T, g, box, reveal, mods):
775
+ path = self._path(L, t, T, g)
776
+ if "anchor" in L.raw:
777
+ b = path.computeTightBounds()
778
+ c.translate(box[0] - b.left(), box[1] - b.top())
779
+ self._shape(c, L, g, path, box, reveal)
780
+
781
+ _draw_polygon = _draw_path
782
+
783
+ def _draw_line(self, c, L, t, T, g, box, reveal, mods):
784
+ if L.raw.get("stroke") is None:
785
+ L.raw["stroke"] = self.s.theme.get("paper", "#f2f0e8")
786
+ self._draw_path(c, L, t, T, g, box, reveal, mods)
787
+
788
+ def _draw_arrow(self, c, L, t, T, g, box, reveal, mods):
789
+ if L.raw.get("stroke") is None:
790
+ L.raw["stroke"] = self.s.theme.get("paper", "#f2f0e8")
791
+ path = self._path(L, t, T, g)
792
+ amt = float(g("draw")) * reveal
793
+ sp = self._stroke_paint(L, g)
794
+ if amt <= 0.0005:
795
+ return
796
+ c.drawPath(self._trim(path, amt), sp)
797
+ meas = skia.PathMeasure(path, False)
798
+ pt = meas.getPosTan(meas.getLength() * min(1.0, amt))
799
+ if pt:
800
+ pos, tan = pt
801
+ head = float(g("head", 22))
802
+ ang = math.atan2(tan.y(), tan.x())
803
+ hp = skia.Path()
804
+ hp.moveTo(pos.x(), pos.y())
805
+ for s in (2.6, -2.6):
806
+ hp.moveTo(pos.x(), pos.y())
807
+ hp.lineTo(pos.x() + head * math.cos(ang + s), pos.y() + head * math.sin(ang + s))
808
+ c.drawPath(hp, sp)
809
+
810
+ def _draw_image(self, c, L, t, T, g, box, reveal, mods):
811
+ img = media.image(L.src)
812
+ self._content(c, L, g, box, img, img.width(), img.height())
813
+
814
+ def _content(self, c, L, g, box, img, iw, ih):
815
+ bx, by, bw, bh = box
816
+ zoom = float(g("zoom"))
817
+ fx, fy = (L.raw.get("focus") or [0.5, 0.5]) if not isinstance(L.raw.get("focus"), str) else (0.5, 0.5)
818
+ if "focusX" in L.props:
819
+ fx = float(g("focusX"))
820
+ if "focusY" in L.props:
821
+ fy = float(g("focusY"))
822
+ fit = L.raw.get("fit", "cover")
823
+ r = float(g("radius", 0))
824
+ c.save()
825
+ if zoom != 1 or r or fit == "cover":
826
+ c.clipRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r), doAntiAlias=True)
827
+ if fit == "stretch" or L.type == "video":
828
+ cw, ch = bw, bh
829
+ else:
830
+ k = (max if fit == "cover" else min)(bw / iw, bh / ih)
831
+ cw, ch = iw * k, ih * k
832
+ cx0 = bx + (bw - cw) / 2
833
+ cy0 = by + (bh - ch) / 2
834
+ px, py = bx + fx * bw, by + fy * bh
835
+ dst = skia.Rect.MakeXYWH(px + (cx0 - px) * zoom, py + (cy0 - py) * zoom, cw * zoom, ch * zoom)
836
+ c.drawImageRect(img, dst, CUBIC if L.type == "video" else SAMPLING, skia.Paint(AntiAlias=True))
837
+ c.restore()
838
+ sp = self._stroke_paint(L, g)
839
+ if sp:
840
+ c.drawRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r), sp)
841
+
842
+ def _draw_video(self, c, L, t, T, g, box, reveal, mods):
843
+ bx, by, bw, bh = box
844
+ speed = float(L.raw.get("speed", 1))
845
+ if L.video is None:
846
+ zmax = 1.0
847
+ zp = L.props.get("zoom")
848
+ if zp is not None:
849
+ zmax = max(float(zp.at(L.dur * k / 8, L.dur)) for k in range(9))
850
+ info = media.probe(L.src)
851
+ # Decode at no more than the source's own resolution: piping upscaled RGBA (8 MB a frame at
852
+ # 1080p) costs more than the decode, and the GPU upscales it at draw time anyway.
853
+ rw, rh = info["w"] / max(bw, 1), info["h"] / max(bh, 1)
854
+ native = (min if L.raw.get("fit", "cover") == "cover" else max)(rw, rh) if info["w"] else 1.0
855
+ k = min(max(1.0, zmax) * self.scale, native)
856
+ L.video = media.VideoSource(L.src, round(bw * k), round(bh * k), self.s.fps, speed, L.raw.get("fit", "cover"),
857
+ ctx=gpu_context() if self.backend == "gpu" else None)
858
+ trim0 = float((L.raw.get("trim") or [0])[0])
859
+ src_t = trim0 + t * speed
860
+ if L.raw.get("loop"):
861
+ span = L.video.info["duration"] - trim0
862
+ if span > 0:
863
+ src_t = trim0 + (t * speed) % span
864
+ img = L.video.at(src_t)
865
+ if img is not None:
866
+ self._content(c, L, g, box, img, img.width(), img.height())
867
+
868
+ # ---- shader layer ---------------------------------------------------------------------
869
+ def _draw_shader(self, c, L, t, T, g, box, reveal, mods):
870
+ eff, defaults = self._effect(L)
871
+ bx, by, bw, bh = box
872
+ b = skia.RuntimeShaderBuilder(eff)
873
+ names = {u.name for u in eff.uniforms()}
874
+ vals = dict(defaults, **(L.raw.get("uniforms") or {}))
875
+ for name in names:
876
+ if name == "iTime":
877
+ b.setUniform(name, float(t))
878
+ elif name == "iProgress":
879
+ b.setUniform(name, float(t / L.dur if L.dur else 0))
880
+ elif name == "iResolution":
881
+ b.setUniform(name, [float(bw), float(bh)])
882
+ elif name in vals:
883
+ v = L.get("u_" + name, t, T) if ("u_" + name) in L.props else vals[name]
884
+ if isinstance(v, str):
885
+ v = C.parse(v)
886
+ b.setUniform(name, [float(x) for x in v] if isinstance(v, (list, tuple)) else float(v))
887
+ mtx = skia.Matrix.Translate(bx, by)
888
+ c.drawRect(skia.Rect.MakeXYWH(*box), skia.Paint(Shader=b.makeShader(mtx)))
889
+
890
+ def _draw_model(self, c, L, t, T, g, box, reveal, mods):
891
+ return render3d.draw(self, c, L, g, reveal)
892
+
893
+ def _model_surface(self, w: int, h: int):
894
+ """An offscreen for models at least w x h device pixels, and its supersampling factor: 4x MSAA
895
+ on the GPU (no measurable cost), else 2x supersampling, which `check` and `--boxes` skip."""
896
+ ss = 1 if self.backend == "gpu" or self.track else 2
897
+ have = self._msurf
898
+ if have is None or have.width() < w * ss or have.height() < h * ss:
899
+ size = (max(w * ss, have.width() if have else 0), max(h * ss, have.height() if have else 0))
900
+ info = skia.ImageInfo.Make(*size, skia.kRGBA_8888_ColorType, skia.kPremul_AlphaType)
901
+ ctx = self.surface.recordingContext()
902
+ have = skia.Surface.MakeRenderTarget(ctx, skia.Budgeted.kNo, info, 4) if ctx else None
903
+ if have is None and ctx: # no MSAA here: supersample on the GPU instead
904
+ return self._supersampled(w, h)
905
+ self._msurf = have = have or skia.Surface.MakeRaster(info)
906
+ return have, ss
907
+
908
+ def _supersampled(self, w: int, h: int):
909
+ info = skia.ImageInfo.Make(w * 2, h * 2, skia.kRGBA_8888_ColorType, skia.kPremul_AlphaType)
910
+ return skia.Surface.MakeRenderTarget(self.surface.recordingContext(), skia.Budgeted.kNo, info), 2
911
+
912
+ def _effect(self, L: Layer):
913
+ hit = self._shaders.get(L.id)
914
+ if hit:
915
+ return hit
916
+ from . import shaders
917
+ defaults: dict = {}
918
+ src = L.raw.get("sksl")
919
+ if not src:
920
+ name = L.raw.get("preset", "mesh")
921
+ if name not in shaders.PRESETS:
922
+ raise ValueError(f"layer '{L.id}': unknown shader preset '{name}'. Known: {', '.join(shaders.PRESETS)}")
923
+ src, defaults = shaders.PRESETS[name]
924
+ else:
925
+ src = declare_uniforms(src, L.raw.get("uniforms") or {})
926
+ try:
927
+ eff = skia.RuntimeEffect.MakeForShader(src)
928
+ except RuntimeError as e:
929
+ errs = [x.strip() for x in str(e).splitlines() if "error" in x.lower()] or [str(e).strip()]
930
+ more = f" (and {len(errs) - 1} more, often caused by the first)" if len(errs) > 1 else ""
931
+ raise ValueError(f"layer '{L.id}': SkSL error: {errs[0]}{more}. A shader is "
932
+ "half4 main(float2 p) { ... return half4(r, g, b, 1); } with p in pixels") from e
933
+ if eff is None:
934
+ raise ValueError(f"layer '{L.id}': SkSL did not compile")
935
+ self._shaders[L.id] = (eff, defaults)
936
+ return eff, defaults
937
+
938
+ # ---- text -----------------------------------------------------------------------------
939
+ def _typeface(self, family: str | None, weight: int, italic: bool) -> skia.Typeface:
940
+ key = (family, weight, italic)
941
+ tf = self._tf.get(key)
942
+ if tf is None:
943
+ style = skia.FontStyle(int(weight), 5, skia.FontStyle.kItalic_Slant if italic else skia.FontStyle.kUpright_Slant)
944
+ bundled = BUNDLED.get((family or "").lower())
945
+ if bundled:
946
+ best = min(bundled, key=lambda w: abs(w - int(weight)))
947
+ tf = skia.Typeface.MakeFromFile(str(FONT_DIR / bundled[best]))
948
+ elif family and (family.lower().endswith((".ttf", ".otf", ".ttc"))):
949
+ tf = skia.Typeface.MakeFromFile(self.s.path(family))
950
+ else:
951
+ for fam in ([family] if family else []) + FONT_FALLBACK:
952
+ tf = skia.Typeface(fam, style)
953
+ if tf and tf.getFamilyName().lower() == fam.lower():
954
+ break
955
+ self._tf[key] = tf
956
+ return tf
957
+
958
+ def _pad(self, L: Layer):
959
+ box = L.raw.get("box")
960
+ if not box:
961
+ return (0.0, 0.0)
962
+ pad = box.get("pad", [12, 20]) if isinstance(box, dict) else [12, 20]
963
+ return (float(pad), float(pad)) if isinstance(pad, (int, float)) else (float(pad[0]), float(pad[1]))
964
+
965
+ def _text_of(self, L: Layer, t, T, g) -> str:
966
+ if L.type == "counter":
967
+ v = float(g("value", 0))
968
+ fmt = L.raw.get("format", "{:,.0f}")
969
+ txt = _FMT.sub(lambda m: format(v, m.group(1)), fmt)
970
+ else:
971
+ txt = str(L.raw.get("text", ""))
972
+ case = L.raw.get("case") or L.raw.get("textCase")
973
+ if case == "upper":
974
+ txt = txt.upper()
975
+ elif case == "lower":
976
+ txt = txt.lower()
977
+ self._last_text = txt.replace("**", "")
978
+ return txt
979
+
980
+ def _font(self, L: Layer, g, size: float) -> skia.Font:
981
+ fam = L.raw.get("font") or self.s.theme.get("font")
982
+ key = (fam, int(g("weight", 600)), bool(L.raw.get("italic")), size)
983
+ f = self._fonts.get(key)
984
+ if f is None:
985
+ f = self._fonts[key] = skia.Font(self._typeface(*key[:3]), size)
986
+ f.setSubpixel(True)
987
+ f.setEdging(skia.Font.Edging.kAntiAlias)
988
+ return f
989
+
990
+ def _text_layout(self, L: Layer, t, T, g, txt: str, size: float | None = None, max_w=None) -> Layout:
991
+ size = float(size if size is not None else g("size", 64))
992
+ ls = float(g("letterSpacing", 0))
993
+ mw = max_w if max_w is not None else g("maxWidth")
994
+ lhm = float(g("lineHeight", 1.18))
995
+ # Every input that changes the layout belongs in the key, or a frame depends on which frames came before.
996
+ key = (L.id, txt, size, ls, mw, lhm, int(g("weight", 600)))
997
+ lay = self._layouts.get(key)
998
+ if lay:
999
+ return lay
1000
+ font = self._font(L, g, size)
1001
+ meas = lambda s: font.measureText(s) + ls * len(s) # noqa: E731
1002
+ space = meas(" ")
1003
+ lines: list[list] = []
1004
+ widths: list[float] = []
1005
+ wi = 0
1006
+ for para in txt.split("\n"):
1007
+ emph = False
1008
+ words = []
1009
+ for tok in para.split(" "):
1010
+ if tok == "":
1011
+ continue
1012
+ parts = tok.split("**")
1013
+ clean = "".join(parts)
1014
+ word_emph = emph
1015
+ if len(parts) > 1:
1016
+ word_emph = True if not emph else emph
1017
+ if (len(parts) - 1) % 2:
1018
+ emph = not emph
1019
+ if tok.startswith("**") and tok.endswith("**") and len(parts) == 3:
1020
+ word_emph = True
1021
+ words.append((clean, word_emph))
1022
+ indent = (len(para) - len(para.lstrip(" "))) * space
1023
+ cur, cw = [], indent
1024
+ for w_, e_ in words:
1025
+ ww = meas(w_)
1026
+ if cur and mw and cw + space + ww > float(mw):
1027
+ lines.append(cur)
1028
+ widths.append(cw)
1029
+ cur, cw = [], 0.0
1030
+ x = cw + (space if cur else 0.0)
1031
+ cur.append((w_, e_, x, wi))
1032
+ wi += 1
1033
+ cw = x + ww
1034
+ lines.append(cur)
1035
+ widths.append(cw)
1036
+ m = font.getMetrics()
1037
+ lh = size * lhm
1038
+ baseline0 = (lh - (m.fDescent - m.fAscent)) / 2 - m.fAscent
1039
+ W = max(widths) if widths else 0.0
1040
+ align = L.raw.get("align", "center")
1041
+ k = {"left": 0.0, "center": 0.5, "right": 1.0}.get(align, 0.5)
1042
+ runs = []
1043
+ ci = 0
1044
+ split = L.raw.get("split")
1045
+ for li, (ln, lw) in enumerate(zip(lines, widths)):
1046
+ off = (W - lw) * k
1047
+ for w_, e_, x, idx in ln:
1048
+ if split == "chars" or ls:
1049
+ cx = x
1050
+ for ch in w_:
1051
+ runs.append(Run(ch, off + cx, li, e_, idx, ci))
1052
+ cx += meas(ch)
1053
+ ci += 1
1054
+ else:
1055
+ runs.append(Run(w_, off + x, li, e_, idx, ci))
1056
+ ci += len(w_)
1057
+ ci += 1 # the space
1058
+ lay = Layout(runs, widths, W, lh * len(lines), lh, baseline0, ci, wi)
1059
+ if len(self._layouts) > 2000:
1060
+ self._layouts.clear()
1061
+ self._layouts[key] = lay
1062
+ return lay
1063
+
1064
+ def _draw_counter(self, c, L, t, T, g, box, reveal, mods):
1065
+ self._draw_text(c, L, t, T, g, box, reveal, mods)
1066
+
1067
+ def _draw_text(self, c, L, t, T, g, box, reveal, mods, txt=None, layout=None, active=None, cue_t=None):
1068
+ txt = txt if txt is not None else self._text_of(L, t, T, g)
1069
+ lay = layout or self._text_layout(L, t, T, g, txt)
1070
+ size = float(g("size", 64))
1071
+ font = self._font(L, g, size)
1072
+ bx, by, bw, bh = box
1073
+ pad = self._pad(L)
1074
+ boxspec = L.raw.get("box")
1075
+ if boxspec:
1076
+ bs = boxspec if isinstance(boxspec, dict) else {}
1077
+ r = float(bs.get("radius", 10))
1078
+ c.drawRRect(skia.RRect.MakeRectXY(skia.Rect.MakeXYWH(*box), r, r),
1079
+ skia.Paint(AntiAlias=True, Color=_argb(bs.get("fill", "#000000b0"))))
1080
+ ox, oy = bx + pad[1], by + pad[0]
1081
+ fill = g("fill") if "fill" in L.props else C.parse(self.s.theme.get("paper", "#f2f0e8"))
1082
+ emph = g("emph") if "emph" in L.props else C.parse(self.s.theme.get("accent", "#ff633f"))
1083
+ act = g("active") if "active" in L.props else emph
1084
+ fpaint = skia.Paint(AntiAlias=True)
1085
+ spaint = self._stroke_paint(L, g)
1086
+ split = L.raw.get("split")
1087
+ enter = L.enter if split else None
1088
+ stagger = (enter.stagger if enter and enter.stagger else 0.04) if split else 0
1089
+ typing = enter is None and L.enter and "type" in L.enter.names and t < L.enter.dur
1090
+ visible_chars = lay.nchars * reveal if (reveal < 1 or typing) else None
1091
+ track = mods.track
1092
+ unit_keys = [k for k in ("unitX", "unitY", "unitRotate", "unitScale", "unitOpacity") if k in L.props]
1093
+ units: dict[int, list[Run]] = {}
1094
+ for r in lay.runs:
1095
+ u = r.char0 if split == "chars" else r.word if split == "words" else r.line if split == "lines" else 0
1096
+ units.setdefault(u, []).append(r)
1097
+ order = sorted(units)
1098
+ for ui, u in enumerate(order):
1099
+ runs = units[u]
1100
+ um = IDENT
1101
+ if enter is not None:
1102
+ p = (t - ui * stagger) / enter.dur if enter.dur else 1.0
1103
+ if p < 1:
1104
+ um = enter.mods(p)
1105
+ if um.alpha <= 0.002:
1106
+ continue
1107
+ ux = um.dx + (float(L.get("unitX", t, T, ui)) if "unitX" in unit_keys else 0)
1108
+ uy = um.dy + (float(L.get("unitY", t, T, ui)) if "unitY" in unit_keys else 0)
1109
+ ua = um.alpha * (float(L.get("unitOpacity", t, T, ui)) if "unitOpacity" in unit_keys else 1)
1110
+ urot = um.rotate + (float(L.get("unitRotate", t, T, ui)) if "unitRotate" in unit_keys else 0)
1111
+ usc = um.scale * (float(L.get("unitScale", t, T, ui)) if "unitScale" in unit_keys else 1)
1112
+ x0 = min(r.x for r in runs)
1113
+ x1 = max(r.x + font.measureText(r.text) for r in runs)
1114
+ ucx, ucy = ox + (x0 + x1) / 2, oy + runs[0].line * lay.lh + lay.lh / 2
1115
+ c.save()
1116
+ moved = ux or uy or urot or usc != 1 or um.rx or um.ry or um.dz
1117
+ if moved:
1118
+ c.translate(ucx + ux, ucy + uy)
1119
+ if um.rx or um.ry or um.dz:
1120
+ c.concat(space.to_skia(space.tilt(um.dz, um.rx, um.ry)))
1121
+ if urot:
1122
+ c.rotate(urot)
1123
+ if usc != 1:
1124
+ c.scale(usc, usc)
1125
+ c.translate(-ucx, -ucy)
1126
+ if um.blur > 0.1:
1127
+ c.saveLayer(None, skia.Paint(ImageFilter=skia.ImageFilters.Blur(um.blur, um.blur)))
1128
+ for r in runs:
1129
+ text = r.text
1130
+ if visible_chars is not None:
1131
+ if r.char0 >= visible_chars:
1132
+ continue
1133
+ text = text[: max(0, int(visible_chars - r.char0))]
1134
+ col = fill
1135
+ if active is not None:
1136
+ col = act if r.word <= active else fill
1137
+ if L.raw.get("mode") == "pop" and r.word > active:
1138
+ continue
1139
+ elif r.emph:
1140
+ col = emph
1141
+ fpaint.setColor(_argb(col, ua))
1142
+ y = oy + r.line * lay.lh + lay.baseline0
1143
+ x = ox + r.x + track * (r.char0 - lay.runs[0].char0) if track else ox + r.x
1144
+ if spaint is not None:
1145
+ c.drawString(text, x, y, font, spaint)
1146
+ c.drawString(text, x, y, font, fpaint)
1147
+ if typing and L.raw.get("caret", True) and visible_chars is not None and runs and u == order[-1]:
1148
+ pass
1149
+ if um.blur > 0.1:
1150
+ c.restore()
1151
+ c.restore()
1152
+ if visible_chars is not None and L.raw.get("caret") and (int(T * 2) % 2 == 0 or reveal < 1):
1153
+ last = None
1154
+ for r in lay.runs:
1155
+ if r.char0 < visible_chars:
1156
+ last = r
1157
+ if last is not None:
1158
+ vis = last.text[: max(0, int(visible_chars - last.char0))]
1159
+ cx = ox + last.x + font.measureText(vis) + 4
1160
+ cy = oy + last.line * lay.lh
1161
+ c.drawRect(skia.Rect.MakeXYWH(cx, cy + lay.lh * 0.15, max(3, size * 0.06), lay.lh * 0.7),
1162
+ skia.Paint(Color=_argb(emph)))
1163
+
1164
+ def _draw_captions(self, c, L, t, T, g, box, reveal, mods):
1165
+ cues = L.raw.get("cues") or []
1166
+ cue = next((q for q in cues if q[0] <= t < q[1]), None)
1167
+ if cue is None:
1168
+ self._last_text = ""
1169
+ return
1170
+ s0, s1, text = cue
1171
+ mw = g("maxWidth") or self.s.w * 0.8
1172
+ lay = self._text_layout(L, t, T, g, text, max_w=mw)
1173
+ pad = self._pad(L) if L.raw.get("box") is not None else (0.0, 0.0)
1174
+ if L.raw.get("box") is None:
1175
+ L.raw["box"] = {"fill": "#000000a8", "pad": [14, 24], "radius": 12}
1176
+ pad = self._pad(L)
1177
+ w, h = lay.w + pad[1] * 2, lay.h + pad[0] * 2
1178
+ cbox = (-w / 2, -h / 2, w, h)
1179
+ ct = t - s0
1180
+ a = min(1.0, ct / 0.12, (s1 - t) / 0.12)
1181
+ active = None
1182
+ if L.raw.get("mode") in ("karaoke", "pop"):
1183
+ words = text.replace("**", "").split()
1184
+ total = sum(len(wd) for wd in words) or 1
1185
+ frac = ct / max(0.05, (s1 - s0) * 0.92)
1186
+ acc, active = 0, -1
1187
+ first_word = lay.runs[0].word if lay.runs else 0
1188
+ for k, wd in enumerate(words):
1189
+ if acc / total <= frac:
1190
+ active = first_word + k
1191
+ acc += len(wd)
1192
+ c.save()
1193
+ if a < 1:
1194
+ c.saveLayer(None, skia.Paint(Alphaf=max(0.0, a)))
1195
+ self._draw_text(c, L, t, T, g, cbox, 1.0, IDENT, txt=text, layout=lay, active=active)
1196
+ if self.track:
1197
+ m = c.getTotalMatrix()
1198
+ r = m.mapRect(skia.Rect.MakeXYWH(*cbox))
1199
+ self.boxes[L.id] = {"rect": [round(r.left(), 1), round(r.top(), 1), round(r.right(), 1), round(r.bottom(), 1)],
1200
+ "alpha": round(a, 3), "type": "captions", "size": float(g("size", 64)),
1201
+ "fill": list(g("fill")) if "fill" in L.props else None, "text": text, "scale": 1.0}
1202
+ if a < 1:
1203
+ c.restore()
1204
+ c.restore()