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/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)
|