kinescript 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. kinescript/__init__.py +6 -0
  2. kinescript/cli.py +167 -0
  3. kinescript/color.py +89 -0
  4. kinescript/components.py +272 -0
  5. kinescript/docs.py +345 -0
  6. kinescript/ease.py +143 -0
  7. kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  8. kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  9. kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  10. kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  11. kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  12. kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  13. kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  14. kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  15. kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  16. kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  17. kinescript/mcp.py +148 -0
  18. kinescript/media.py +220 -0
  19. kinescript/mesh.py +390 -0
  20. kinescript/models.py +339 -0
  21. kinescript/output.py +370 -0
  22. kinescript/presets.py +170 -0
  23. kinescript/props.py +185 -0
  24. kinescript/render.py +1204 -0
  25. kinescript/render3d.py +393 -0
  26. kinescript/scene.py +362 -0
  27. kinescript/schema.py +701 -0
  28. kinescript/shaders.py +79 -0
  29. kinescript/space.py +81 -0
  30. kinescript/svgpath.py +80 -0
  31. kinescript/timing.py +169 -0
  32. kinescript/verify.py +261 -0
  33. kinescript-1.1.0.dist-info/METADATA +192 -0
  34. kinescript-1.1.0.dist-info/RECORD +40 -0
  35. kinescript-1.1.0.dist-info/WHEEL +5 -0
  36. kinescript-1.1.0.dist-info/entry_points.txt +2 -0
  37. kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
  38. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  39. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  40. kinescript-1.1.0.dist-info/top_level.txt +1 -0
kinescript/output.py ADDED
@@ -0,0 +1,370 @@
1
+ """Encoding: frames piped to ffmpeg, split across worker processes, audio mixed alongside them."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from concurrent.futures import ProcessPoolExecutor
12
+ from pathlib import Path
13
+
14
+ from . import media
15
+ from .render import Renderer
16
+ from .scene import Scene, load
17
+
18
+ QUALITY = { # crf, x264 preset
19
+ "draft": (30, "ultrafast"),
20
+ "standard": (20, "veryfast"),
21
+ "high": (16, "medium"),
22
+ }
23
+
24
+
25
+ def _video_args(fmt: str, quality: str, fps: float, threads: int = 0) -> list[str]:
26
+ crf, preset = QUALITY.get(quality, QUALITY["standard"])
27
+ # Each worker runs its own encoder; left to auto-threading, eight of them oversubscribe the CPU.
28
+ thr = ["-threads", str(threads)] if threads else []
29
+ if fmt == "mov": # ProRes 4444 with alpha, for compositing in an NLE
30
+ return ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-vendor", "apl0", *thr]
31
+ if fmt == "webm":
32
+ return ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", str(crf + 10),
33
+ "-row-mt", "1", "-deadline", "realtime" if quality == "draft" else "good", "-cpu-used", "4", *thr]
34
+ if fmt == "gif": # intermediate for the palette pass; a near-lossless one keeps grain and doubles the GIF
35
+ return ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", *thr]
36
+ # x264's own GOP defaults (keyint 250, 3 B-frames, scene-cut keyframes): same PSNR as -g 2s -bf 2
37
+ # in 10-12% fewer bytes on the examples.
38
+ return ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", *thr]
39
+
40
+
41
+ # HD video is BT.709. Untagged files are read as 709 by browsers and players, but ffmpeg's default
42
+ # RGB->YUV matrix is BT.601, which shifts saturated colours; so convert with 709 and say so.
43
+ # The tags go on the frames (setparams): ffmpeg 8 drops -color_primaries / -color_trc output options
44
+ # when the frames themselves say "unknown".
45
+ TAG_709 = "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv"
46
+ TO_709 = "scale=out_color_matrix=bt709:out_range=tv"
47
+
48
+
49
+ def _encoder(path: str, w: int, h: int, fps: float, fmt: str, quality: str, threads: int = 0, yuv: bool = False):
50
+ """yuv: frames arrive as BT.709 I420 (Renderer.frame_i420) and pass to the encoder unconverted."""
51
+ pix, vf = ("yuv420p", TAG_709) if yuv else ("rgba", f"{TO_709},{TAG_709}")
52
+ cmd = [media.FFMPEG, "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", pix, "-s", f"{w}x{h}",
53
+ "-r", f"{fps}", "-i", "-", "-vf", vf, *_video_args(fmt, quality, fps, threads), "-an", path]
54
+ # stderr goes to a file: a pipe nobody reads until the end can fill and stall the encoder.
55
+ log = tempfile.TemporaryFile()
56
+ return subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=log, creationflags=media._NOWIN), log
57
+
58
+
59
+ def _render_range(args) -> dict:
60
+ spec, base, start, end, path, fmt, quality, scale, transparent, threads = args
61
+ scene = Scene(spec, base)
62
+ if transparent:
63
+ scene.bg = None
64
+ r = Renderer(scene, scale=scale, even=fmt in ("mp4", "webm", "gif"))
65
+ yuv = r.yuv and fmt in ("mp4", "gif")
66
+ enc, log = _encoder(path, r.w, r.h, scene.fps, fmt, quality, threads, yuv)
67
+ grab = r.frame_i420 if yuv else r.frame
68
+ t0 = time.perf_counter()
69
+ try:
70
+ for n in range(start, end):
71
+ enc.stdin.write(grab(n).data) # type: ignore[union-attr]
72
+ except BrokenPipeError:
73
+ pass # ffmpeg quit early; its own message is reported below
74
+ finally:
75
+ scene.close()
76
+ try:
77
+ enc.stdin.close() # type: ignore[union-attr]
78
+ except OSError:
79
+ pass
80
+ enc.wait()
81
+ log.seek(0)
82
+ err = log.read().decode(errors="replace")
83
+ log.close()
84
+ if enc.returncode:
85
+ raise RuntimeError(f"ffmpeg failed on frames {start}-{end}: {err.strip()[:400]}")
86
+ return {"frames": end - start, "seconds": time.perf_counter() - t0}
87
+
88
+
89
+ def _audio_inputs(scene: Scene) -> list[dict]:
90
+ """Every audible source as {src, at, trim0, trim1, gain_db, fade_in, fade_out, speed}."""
91
+ out = []
92
+ for layer in scene.all_layers():
93
+ if layer.type != "video" or not layer.src:
94
+ continue
95
+ vol = layer.raw.get("volume", 1)
96
+ if not vol or not media.probe(layer.src)["audio"]:
97
+ continue
98
+ trim = layer.raw.get("trim") or [0, None]
99
+ speed = float(layer.raw.get("speed", 1))
100
+ fi = layer.enter.dur if layer.enter else 0
101
+ fo = layer.exit.dur if layer.exit else 0
102
+ out.append(dict(src=layer.src, at=layer.t_in, trim0=float(trim[0]), dur=layer.dur * speed, gain=float(vol),
103
+ fade_in=fi, fade_out=fo, speed=speed))
104
+ for a in scene.audio:
105
+ src = scene.path(a["src"])
106
+ at = scene.timeline._time(a.get("at", 0), "audio", 0.0)
107
+ trim = a.get("trim") or [0, None]
108
+ total = media.probe(src)["duration"]
109
+ end = trim[1] if len(trim) > 1 and trim[1] is not None else total
110
+ dur = min(end - trim[0], scene.duration - at)
111
+ gain = 10 ** (float(a.get("gain", 0)) / 20) if "gain" in a else float(a.get("volume", 1))
112
+ out.append(dict(src=src, at=at, trim0=float(trim[0]), dur=max(0.01, dur), gain=gain,
113
+ fade_in=float(a.get("fadeIn", 0)), fade_out=float(a.get("fadeOut", 0)), speed=1.0))
114
+ return out
115
+
116
+
117
+ def _copy(video_in: list[str], out: str):
118
+ """Write the video input (one segment, or the concat list of several) to out without re-encoding."""
119
+ if video_in[0] == "-i":
120
+ shutil.move(video_in[1], out)
121
+ return
122
+ res = subprocess.run([media.FFMPEG, "-y", "-v", "error", *video_in, "-c", "copy", out], capture_output=True,
123
+ text=True, creationflags=media._NOWIN)
124
+ if res.returncode:
125
+ raise RuntimeError(f"concat failed: {res.stderr.strip()[:400]}")
126
+
127
+
128
+ def _start_audio(scene: Scene, out: str):
129
+ """Start mixing the scene's audio to an AAC file `out`, in its own ffmpeg process so it runs while the
130
+ frames render; it took 0.7 s of the launch video's 3.8 s when it ran after them. None if silent."""
131
+ ins = _audio_inputs(scene)
132
+ if not ins:
133
+ return None
134
+ cmd = [media.FFMPEG, "-y", "-v", "error"]
135
+ chains = []
136
+ for k, a in enumerate(ins):
137
+ cmd += ["-i", a["src"]]
138
+ f = [f"atrim=start={a['trim0']:.4f}:duration={a['dur']:.4f}", "asetpts=PTS-STARTPTS"]
139
+ if a["speed"] != 1:
140
+ f.append(f"atempo={max(0.5, min(2.0, a['speed'])):.4f}")
141
+ if a["fade_in"]:
142
+ f.append(f"afade=t=in:d={a['fade_in']:.3f}")
143
+ if a["fade_out"]:
144
+ f.append(f"afade=t=out:st={max(0, a['dur'] / a['speed'] - a['fade_out']):.3f}:d={a['fade_out']:.3f}")
145
+ f.append(f"volume={a['gain']:.4f}")
146
+ ms = int(round(a["at"] * 1000))
147
+ f.append(f"adelay={ms}|{ms}")
148
+ chains.append(f"[{k}:a]{','.join(f)}[a{k}]")
149
+ mix = "".join(f"[a{k}]" for k in range(len(ins)))
150
+ graph = ";".join(chains) + f";{mix}amix=inputs={len(ins)}:normalize=0:dropout_transition=0,alimiter=limit=0.95,atrim=duration={scene.duration:.4f}[aout]"
151
+ cmd += ["-filter_complex", graph, "-map", "[aout]", "-c:a", "aac", "-b:a", "192k", out]
152
+ log = tempfile.TemporaryFile() # as in _encoder: an unread stderr pipe can stall ffmpeg
153
+ return subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stderr=log, creationflags=media._NOWIN), log, out
154
+
155
+
156
+ def _mux(video_in: list[str], audio, out: str):
157
+ """Put the mixed audio under the video, both copied; the segments are joined on the way when video_in
158
+ is a concat list."""
159
+ proc, log, path = audio
160
+ proc.wait()
161
+ log.seek(0)
162
+ err = log.read().decode(errors="replace")
163
+ log.close()
164
+ if proc.returncode:
165
+ raise RuntimeError(f"audio mix failed: {err.strip()[:400]}")
166
+ res = subprocess.run([media.FFMPEG, "-y", "-v", "error", *video_in, "-i", path, "-map", "0:v", "-map", "1:a",
167
+ "-c", "copy", "-shortest", out], capture_output=True, text=True, creationflags=media._NOWIN)
168
+ if res.returncode:
169
+ raise RuntimeError(f"audio mux failed: {res.stderr.strip()[:400]}")
170
+
171
+
172
+ def render(src, out: str, quality: str = "standard", workers: int | None = None, scale: float = 1.0,
173
+ start: float | None = None, end: float | None = None, transparent: bool = False) -> dict:
174
+ """Render a scene to a file. Format from the extension: .mp4, .mov (ProRes 4444 alpha), .webm (VP9 alpha),
175
+ .gif, or a directory / pattern ending in .png for a frame sequence."""
176
+ scene = load(src)
177
+ spec, base = scene.spec, str(scene.base.resolve()) # warm workers keep the directory they started in
178
+ fps = scene.fps
179
+ f0 = 0 if start is None else max(0, int(round(start * fps)))
180
+ f1 = scene.frames if end is None else min(scene.frames, int(round(end * fps)))
181
+ if f1 <= f0:
182
+ raise ValueError("nothing to render: end is before start")
183
+ out_p = Path(out)
184
+ ext = out_p.suffix.lower()
185
+ t0 = time.perf_counter()
186
+ if ext == ".png" or out.endswith(("/", "\\")):
187
+ return _render_pngs(scene, out_p, f0, f1, scale, transparent)
188
+ fmt = {".mov": "mov", ".webm": "webm", ".gif": "gif"}.get(ext, "mp4")
189
+ transparent = transparent or fmt in ("mov", "webm") and spec.get("canvas", {}).get("bg") in (None, "transparent")
190
+ if fmt == "gif": # render no larger than the GIF will be (long side GIF_SIDE), not full size then shrink
191
+ scale = min(scale, GIF_SIDE / max(scene.w, scene.h))
192
+ n = f1 - f0
193
+ cpu = os.cpu_count() or 4
194
+ workers = workers or _workers(scene, n, cpu, fmt)
195
+ threads = max(1, cpu // workers)
196
+ tmp = Path(tempfile.mkdtemp(prefix="kine-"))
197
+ audio = None
198
+ mix = fmt == "mp4" and start is None and end is None
199
+ try:
200
+ seg_ext = ".mov" if fmt == "mov" else ".webm" if fmt == "webm" else ".mp4"
201
+ bounds = [f0 + n * k // workers for k in range(workers + 1)]
202
+ jobs = [(spec, base, bounds[k], bounds[k + 1], str(tmp / f"seg{k:03d}{seg_ext}"), fmt, quality, scale,
203
+ transparent, threads)
204
+ for k in range(workers) if bounds[k + 1] > bounds[k]]
205
+ if len(jobs) == 1:
206
+ audio = _start_audio(scene, str(tmp / "audio.m4a")) if mix else None
207
+ stats = [_render_range(jobs[0])]
208
+ else:
209
+ ex, failed = _pool(len(jobs)), True
210
+ try:
211
+ pending = ex.map(_render_range, jobs) # submitted now; the audio mix starts behind them
212
+ audio = _start_audio(scene, str(tmp / "audio.m4a")) if mix else None
213
+ stats = list(pending)
214
+ failed = False
215
+ finally:
216
+ _rest(failed)
217
+ if len(jobs) == 1:
218
+ video_in = ["-i", jobs[0][4]]
219
+ else:
220
+ listing = tmp / "list.txt"
221
+ listing.write_text("".join(f"file '{j[4]}'\n" for j in jobs))
222
+ video_in = ["-f", "concat", "-safe", "0", "-i", str(listing)]
223
+ out_p.parent.mkdir(parents=True, exist_ok=True)
224
+ final = str(tmp / f"final{seg_ext}")
225
+ if ext == ".gif":
226
+ _to_gif(video_in, str(out_p), fps)
227
+ elif audio:
228
+ _mux(video_in, audio, final)
229
+ else:
230
+ _copy(video_in, final)
231
+ if ext != ".gif":
232
+ shutil.move(final, str(out_p))
233
+ finally:
234
+ if audio and audio[0].poll() is None: # the frames failed while the mix was still running
235
+ audio[0].kill()
236
+ audio[0].wait()
237
+ if audio:
238
+ audio[1].close()
239
+ shutil.rmtree(tmp, ignore_errors=True)
240
+ scene.close()
241
+ wall = time.perf_counter() - t0
242
+ w, h = max(2, round(scene.w * scale)), max(2, round(scene.h * scale))
243
+ if fmt in ("mp4", "webm", "gif"):
244
+ w, h = w - w % 2, h - h % 2
245
+ return {"out": str(out_p), "w": w, "h": h, "fps": min(fps, GIF_FPS) if fmt == "gif" else fps, "frames": n,
246
+ "seconds": round(n / fps, 3), "wall": round(wall, 2), "fps_rendered": round(n / wall, 1),
247
+ "workers": len(jobs), "size_mb": round(out_p.stat().st_size / 1e6, 2) if out_p.exists() else None}
248
+
249
+
250
+ # Render processes stay up for WARM seconds after a render, so the next one in the same process (the MCP
251
+ # server, a script rendering several scenes) skips starting them: each imports Skia and opens a GL context.
252
+ # Repeat renders of examples/model.json took 1.7 s instead of 2.45 s, the launch video 2.2 s instead of
253
+ # 2.6 s (2026-10-30). What they cache is keyed on file versions (media, models) or on parameters (meshes,
254
+ # eases, colours), so nothing they keep goes stale. 0 shuts them down at the end of each render.
255
+ WARM = 120.0
256
+ _POOL: dict = {"busy": 0, "gen": 0}
257
+ _POOL_LOCK = threading.Lock()
258
+
259
+
260
+ def _pool(n: int) -> ProcessPoolExecutor:
261
+ """The warm pool, new when it has fewer than n processes or KINE_BACKEND changed since it started."""
262
+ key = os.environ.get("KINE_BACKEND", "")
263
+ with _POOL_LOCK:
264
+ if "ex" in _POOL and (_POOL["key"] != key or _POOL["size"] < n):
265
+ _drop()
266
+ if "ex" not in _POOL:
267
+ _POOL.update(ex=ProcessPoolExecutor(max_workers=n), key=key, size=n)
268
+ _POOL["busy"] += 1
269
+ _POOL["gen"] += 1
270
+ return _POOL["ex"]
271
+
272
+
273
+ def _rest(failed: bool):
274
+ """After a render: a failed pool shuts down now (its other jobs finish first), an idle one after WARM s."""
275
+ with _POOL_LOCK:
276
+ _POOL["busy"] -= 1
277
+ if failed or WARM <= 0:
278
+ _drop()
279
+ return
280
+ gen = _POOL["gen"]
281
+ t = threading.Timer(WARM, _idle, (gen,))
282
+ t.daemon = True
283
+ t.start()
284
+
285
+
286
+ def _idle(gen: int):
287
+ with _POOL_LOCK:
288
+ if _POOL["gen"] == gen and not _POOL["busy"]:
289
+ _drop()
290
+
291
+
292
+ def _drop():
293
+ ex = _POOL.pop("ex", None)
294
+ if ex is not None:
295
+ ex.shutdown(wait=True, cancel_futures=True)
296
+
297
+
298
+ def _workers(scene: Scene, frames: int, cpu: int, fmt: str = "mp4") -> int:
299
+ """Render processes for a render. On the GPU every process opens its own GL context on the same
300
+ GPU and its own video decoders, so past a few they slow each other down. Measured on a 20-thread
301
+ CPU with one GPU (2026-10-29): 1038 vector frames were fastest with 4-6 workers (3.2 s against
302
+ 4.8 s with one); 126 frames, and 312 frames with two video layers, were fastest with one. The
303
+ raster (CPU) backend is bound by the CPU and keeps one worker per 1.5 s of frames. VP9 and
304
+ ProRes encode slower than the GPU draws, so .webm/.mov take up to 4 workers, one per 60 frames
305
+ (1038-frame promo as .webm: 18.7 s with one worker, 10.9 s with four, 11.3 s with eight)."""
306
+ from .render import gpu_context
307
+ if fmt in ("webm", "mov"):
308
+ return max(1, min(4, frames // 60))
309
+ if gpu_context() is None:
310
+ return max(1, min(cpu - 1, 8, frames // int(scene.fps * 1.5) or 1))
311
+ layers = list(scene.all_layers())
312
+ if any(L.type == "model" for L in layers):
313
+ # Model frames spend their time in numpy and in building Skia's vertex lists, not on the GPU,
314
+ # so they spread over processes: examples/model.json (180 frames, ~11k triangles drawn a
315
+ # frame) took 4.2 s with one worker, 2.3 s with six and 2.2 s with eight (2026-10-30).
316
+ return max(1, min(6, frames // 30))
317
+ video = any(L.type in ("video", "sequence") for L in layers)
318
+ return max(1, min(4, frames // (500 if video else 250)))
319
+
320
+
321
+ GIF_SIDE, GIF_FPS = 720, 15 # a 6 s portrait GIF at full width and 20 fps ran past 10 MB
322
+
323
+
324
+ def _to_gif(video_in: list[str], out: str, fps: float):
325
+ gfps = min(fps, GIF_FPS)
326
+ # 128 colours with ordered dither and changed-rectangle updates: about half the size of error
327
+ # diffusion on moving gradients (6.6 -> 3.5 MB on a 6 s portrait clip) with no visible loss.
328
+ vf = (f"scale=in_color_matrix=bt709:in_range=tv,format=rgb24,fps={gfps},split[a][b];[a]palettegen=stats_mode=diff:max_colors=128[p];"
329
+ "[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle")
330
+ res = subprocess.run([media.FFMPEG, "-y", "-v", "error", *video_in, "-vf", vf, out], capture_output=True, text=True,
331
+ creationflags=media._NOWIN)
332
+ if res.returncode:
333
+ raise RuntimeError(f"gif failed: {res.stderr.strip()[:400]}")
334
+
335
+
336
+ def _render_pngs(scene: Scene, out: Path, f0: int, f1: int, scale: float, transparent: bool) -> dict:
337
+ import skia
338
+ if transparent:
339
+ scene.bg = None
340
+ folder = out if out.suffix.lower() != ".png" else out.parent
341
+ stem = "frame" if out.suffix.lower() != ".png" else out.stem
342
+ folder.mkdir(parents=True, exist_ok=True)
343
+ r = Renderer(scene, scale=scale)
344
+ t0 = time.perf_counter()
345
+ for n in range(f0, f1):
346
+ r.frame(n)
347
+ r.snapshot().save(str(folder / f"{stem}_{n:05d}.png"), skia.kPNG)
348
+ scene.close()
349
+ return {"out": str(folder), "frames": f1 - f0, "wall": round(time.perf_counter() - t0, 2)}
350
+
351
+
352
+ def still(src, at, out: str, scale: float = 1.0) -> dict:
353
+ """One frame to PNG/JPG. `at` is seconds, negative seconds from the end, or "end" (the last frame)."""
354
+ import skia
355
+ scene = load(src)
356
+ if str(at).strip().lower() == "end":
357
+ n = scene.frames - 1
358
+ else:
359
+ t = float(at)
360
+ n = int(round((scene.duration + t if t < 0 else t) * scene.fps))
361
+ n = max(0, min(scene.frames - 1, n))
362
+ r = Renderer(scene, scale=scale)
363
+ r.frame(n)
364
+ r.snapshot().save(out, skia.kPNG if out.lower().endswith(".png") else skia.kJPEG)
365
+ scene.close()
366
+ return {"out": out, "frame": n, "at": round(n / scene.fps, 3), "w": r.w, "h": r.h}
367
+
368
+
369
+ def dump_json(obj) -> str:
370
+ return json.dumps(obj, indent=2, ensure_ascii=False)
kinescript/presets.py ADDED
@@ -0,0 +1,170 @@
1
+ """Enter/exit motions. "enter": "rise", "rise+blur", or {"name": "pop", "dur": 0.4, "ease": "outBack"}.
2
+
3
+ Each preset maps progress p (0 = absent, 1 = fully present) onto modifiers that the
4
+ renderer composes with the layer's own properties.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+
11
+ from . import ease as E
12
+
13
+
14
+ @dataclass
15
+ class Mods:
16
+ alpha: float = 1.0
17
+ dx: float = 0.0
18
+ dy: float = 0.0
19
+ scale: float = 1.0
20
+ rotate: float = 0.0
21
+ blur: float = 0.0
22
+ reveal: float = 1.0 # typewriter / draw-on / wipe amount
23
+ wipe: str = "" # direction when a wipe is active
24
+ track: float = 0.0 # extra letter spacing (px)
25
+ rx: float = 0.0 # added to rotateX, rotateY (degrees) and z (px)
26
+ ry: float = 0.0
27
+ dz: float = 0.0
28
+
29
+ def combine(self, o: "Mods") -> "Mods":
30
+ return Mods(self.alpha * o.alpha, self.dx + o.dx, self.dy + o.dy, self.scale * o.scale,
31
+ self.rotate + o.rotate, self.blur + o.blur, min(self.reveal, o.reveal),
32
+ o.wipe or self.wipe, self.track + o.track, self.rx + o.rx, self.ry + o.ry, self.dz + o.dz)
33
+
34
+
35
+ IDENT = Mods()
36
+
37
+
38
+ def _fade(p, o):
39
+ return Mods(alpha=p)
40
+
41
+
42
+ def _move(dx, dy):
43
+ def f(p, o):
44
+ dist = o.get("dist", 48)
45
+ return Mods(alpha=min(1.0, p * 1.6), dx=(1 - p) * dist * dx, dy=(1 - p) * dist * dy)
46
+ return f
47
+
48
+
49
+ def _pop(p, o):
50
+ return Mods(alpha=min(1.0, p * 2), scale=o.get("from", 0.4) + (1 - o.get("from", 0.4)) * p)
51
+
52
+
53
+ def _zoom(p, o):
54
+ return Mods(alpha=p, scale=o.get("from", 1.25) + (1 - o.get("from", 1.25)) * p)
55
+
56
+
57
+ def _blur(p, o):
58
+ return Mods(alpha=p, blur=(1 - p) * o.get("amount", 24))
59
+
60
+
61
+ def _spin(p, o):
62
+ return Mods(alpha=p, rotate=(1 - p) * o.get("deg", -90), scale=0.6 + 0.4 * p)
63
+
64
+
65
+ def _wipe(direction):
66
+ return lambda p, o: Mods(reveal=p, wipe=direction)
67
+
68
+
69
+ def _reveal(p, o):
70
+ return Mods(reveal=p)
71
+
72
+
73
+ def _snap(p, o):
74
+ """Hard cut in: visible within the first fifth, settling from a slight overscale. No blur."""
75
+ return Mods(alpha=1.0 if p >= 0.2 else p / 0.2, scale=1 + (1 - p) * o.get("amount", 0.06))
76
+
77
+
78
+ def _track(p, o):
79
+ return Mods(alpha=p, track=(1 - p) * o.get("amount", 40))
80
+
81
+
82
+ def _flip(p, o):
83
+ return Mods(alpha=min(1.0, p * 4), ry=(1 - p) * o.get("deg", 90))
84
+
85
+
86
+ def _flip_up(p, o):
87
+ return Mods(alpha=min(1.0, p * 4), rx=(1 - p) * o.get("deg", 90))
88
+
89
+
90
+ def _tumble(p, o):
91
+ return Mods(alpha=min(1.0, p * 2), rx=(1 - p) * -70, ry=(1 - p) * o.get("deg", 160),
92
+ dz=(1 - p) * -o.get("dist", 400))
93
+
94
+
95
+ def _push(p, o):
96
+ return Mods(alpha=p, dz=(1 - p) * -o.get("dist", 600))
97
+
98
+
99
+ PRESETS = {
100
+ "none": lambda p, o: IDENT, "fade": _fade,
101
+ "rise": _move(0, 1), "drop": _move(0, -1), "left": _move(1, 0), "right": _move(-1, 0),
102
+ "slide-up": _move(0, 1), "slide-down": _move(0, -1),
103
+ "pop": _pop, "zoom": _zoom, "blur": _blur, "spin": _spin, "track": _track,
104
+ "wipe": _wipe("right"), "wipe-right": _wipe("right"), "wipe-left": _wipe("left"),
105
+ "wipe-up": _wipe("up"), "wipe-down": _wipe("down"),
106
+ "type": _reveal, "draw": _reveal, "snap": _snap,
107
+ "flip": _flip, "flip-up": _flip_up, "tumble": _tumble, "push": _push,
108
+ }
109
+ # Motions that turn or move a layer in depth; a scene using one gets a camera.
110
+ DEPTH = {"flip", "flip-up", "tumble", "push"}
111
+ DEFAULT_EASE = {"pop": "outBack", "spin": "outBack", "type": "linear", "draw": "inOutCubic", "snap": "outQuart",
112
+ "flip": "outBack", "flip-up": "outBack"}
113
+
114
+
115
+ # Names other tools use for the same motions. Keys are lower case without dashes.
116
+ ALIASES = {"typewriter": "type", "typing": "type", "slide": "left", "scale": "pop", "grow": "pop", "bounce": "pop",
117
+ "appear": "snap", "cut": "snap", "dissolve": "fade", "opacity": "fade", "up": "rise", "riseup": "rise",
118
+ "down": "drop", "fall": "drop", "blurin": "blur", "focus": "blur", "rotate": "spin", "reveal": "wipe",
119
+ "write": "draw", "stroke": "draw", "build": "draw", "assemble": "draw", "letterspacing": "track", "tracking": "track",
120
+ "flipy": "flip", "flipx": "flip-up", "rotatey": "flip", "rotatex": "flip-up", "dolly": "push",
121
+ "fly": "push"}
122
+
123
+
124
+ def canonical(name: str) -> str | None:
125
+ """'fadeIn' -> 'fade', 'slideUp' -> 'slide-up', 'typewriter' -> 'type'; None when nothing fits."""
126
+ if name in PRESETS:
127
+ return name
128
+ s = re.sub(r"(?<=[a-z])(?=[A-Z])", "-", str(name).strip()).lower().replace("_", "-").replace(" ", "-")
129
+ for cand in (s, re.sub(r"-?(in|out)$", "", s)):
130
+ if cand in PRESETS:
131
+ return cand
132
+ sq = cand.replace("-", "")
133
+ if sq in ALIASES:
134
+ return ALIASES[sq]
135
+ for p in PRESETS:
136
+ if p.replace("-", "") == sq:
137
+ return p
138
+ return None
139
+
140
+
141
+ @dataclass
142
+ class Motion:
143
+ names: list[str]
144
+ dur: float = 0.5
145
+ ease: str = ""
146
+ stagger: float = 0.0
147
+ opts: dict = field(default_factory=dict)
148
+
149
+ def mods(self, p: float) -> Mods:
150
+ out = IDENT
151
+ for n in self.names:
152
+ e = E.get(self.ease or DEFAULT_EASE.get(n, "outCubic"))
153
+ out = out.combine(PRESETS[n](e(max(0.0, min(1.0, p))), self.opts))
154
+ return out
155
+
156
+
157
+ def parse(spec, default_dur: float = 0.5) -> Motion | None:
158
+ if not spec:
159
+ return None
160
+ if isinstance(spec, str):
161
+ spec = {"name": spec}
162
+ names = []
163
+ for n in (n.strip() for n in str(spec.get("name", "fade")).split("+") if n.strip()):
164
+ c = canonical(n)
165
+ if c is None:
166
+ from .schema import unknown
167
+ raise ValueError(unknown("motion", n, PRESETS))
168
+ names.append(c)
169
+ opts = {k: v for k, v in spec.items() if k not in ("name", "dur", "ease", "stagger")}
170
+ return Motion(names, float(spec.get("dur", default_dur)), spec.get("ease", ""), float(spec.get("stagger", 0)), opts)