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