kinescript 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinescript/__init__.py +6 -0
- kinescript/cli.py +167 -0
- kinescript/color.py +89 -0
- kinescript/components.py +272 -0
- kinescript/docs.py +345 -0
- kinescript/ease.py +143 -0
- kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- kinescript/mcp.py +148 -0
- kinescript/media.py +220 -0
- kinescript/mesh.py +390 -0
- kinescript/models.py +339 -0
- kinescript/output.py +370 -0
- kinescript/presets.py +170 -0
- kinescript/props.py +185 -0
- kinescript/render.py +1204 -0
- kinescript/render3d.py +393 -0
- kinescript/scene.py +362 -0
- kinescript/schema.py +701 -0
- kinescript/shaders.py +79 -0
- kinescript/space.py +81 -0
- kinescript/svgpath.py +80 -0
- kinescript/timing.py +169 -0
- kinescript/verify.py +261 -0
- kinescript-1.1.0.dist-info/METADATA +192 -0
- kinescript-1.1.0.dist-info/RECORD +40 -0
- kinescript-1.1.0.dist-info/WHEEL +5 -0
- kinescript-1.1.0.dist-info/entry_points.txt +2 -0
- kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/top_level.txt +1 -0
kinescript/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()
|