kinescript 1.3.0__tar.gz → 1.3.1__tar.gz
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-1.3.0 → kinescript-1.3.1}/PKG-INFO +16 -8
- {kinescript-1.3.0 → kinescript-1.3.1}/README.md +15 -7
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/__init__.py +1 -1
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/components.py +12 -1
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/docs.py +14 -7
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/verify.py +27 -3
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/PKG-INFO +16 -8
- {kinescript-1.3.0 → kinescript-1.3.1}/pyproject.toml +1 -1
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_core.py +40 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/LICENSE +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/audio.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/cli.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/color.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/ease.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/listen.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/mcp.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/media.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/mesh.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/models.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/output.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/presets.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/props.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/render.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/render3d.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/scene.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/schema.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/shaders.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/space.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/svgpath.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/timing.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/voice.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/words.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/SOURCES.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/dependency_links.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/entry_points.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/requires.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/top_level.txt +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/setup.cfg +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_color_pipeline.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_docs.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_models.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_motion_blur.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_packaging.py +0 -0
- {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_schema.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinescript
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read.
|
|
5
5
|
Author: Dr. Know How
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,13 +135,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
136
|
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
137
|
(local, fast),
|
|
138
|
-
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
139
139
|
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
140
|
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
141
|
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
142
|
options changed is spoken again, and rendering never calls a speech engine.
|
|
143
|
-
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell
|
|
144
|
-
|
|
143
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
|
|
144
|
+
Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
|
|
145
|
+
goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
|
|
146
|
+
and Kokoro says the line without them.
|
|
145
147
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
146
148
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
147
149
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -167,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
167
169
|
|
|
168
170
|
`kine voice scene.json` speaks it once. The
|
|
169
171
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
170
|
-
to its scene file.
|
|
172
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
171
173
|
|
|
172
174
|
```bash
|
|
173
|
-
pip install kinescript-kokoro
|
|
175
|
+
pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
|
|
176
|
+
pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
|
|
177
|
+
pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
|
|
174
178
|
```
|
|
175
179
|
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
A plugin can also live in another Python, for Kokoro on Python 3.13 or newer or to keep PyTorch out of
|
|
181
|
+
Kinescript's environment: point `KINE_VOICE_CMD`, or `KINE_VOICE_CMD_<NAME>` for one backend, at it. Each plugin's
|
|
182
|
+
README has the details:
|
|
183
|
+
[Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro),
|
|
184
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox),
|
|
185
|
+
[ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs).
|
|
178
186
|
|
|
179
187
|
## Built for agents
|
|
180
188
|
|
|
@@ -88,13 +88,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
88
88
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
89
89
|
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
90
90
|
(local, fast),
|
|
91
|
-
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
91
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
92
92
|
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
93
93
|
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
94
94
|
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
95
95
|
options changed is spoken again, and rendering never calls a speech engine.
|
|
96
|
-
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell
|
|
97
|
-
|
|
96
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
|
|
97
|
+
Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
|
|
98
|
+
goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
|
|
99
|
+
and Kokoro says the line without them.
|
|
98
100
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
99
101
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
100
102
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -120,14 +122,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
120
122
|
|
|
121
123
|
`kine voice scene.json` speaks it once. The
|
|
122
124
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
123
|
-
to its scene file.
|
|
125
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
124
126
|
|
|
125
127
|
```bash
|
|
126
|
-
pip install kinescript-kokoro
|
|
128
|
+
pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
|
|
129
|
+
pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
|
|
130
|
+
pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
|
|
127
131
|
```
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
A plugin can also live in another Python, for Kokoro on Python 3.13 or newer or to keep PyTorch out of
|
|
134
|
+
Kinescript's environment: point `KINE_VOICE_CMD`, or `KINE_VOICE_CMD_<NAME>` for one backend, at it. Each plugin's
|
|
135
|
+
README has the details:
|
|
136
|
+
[Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro),
|
|
137
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox),
|
|
138
|
+
[ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs).
|
|
131
139
|
|
|
132
140
|
## Built for agents
|
|
133
141
|
|
|
@@ -9,5 +9,5 @@ except ImportError as e: # on Linux the wheel loads the system's EGL and GL, ev
|
|
|
9
9
|
from .output import render, still
|
|
10
10
|
from .scene import Scene, SceneError, load
|
|
11
11
|
|
|
12
|
-
__version__ = "1.3.
|
|
12
|
+
__version__ = "1.3.1"
|
|
13
13
|
__all__ = ["Scene", "SceneError", "load", "render", "still", "__version__"]
|
|
@@ -182,6 +182,17 @@ def _fill_template(v, params):
|
|
|
182
182
|
_counter = [0]
|
|
183
183
|
|
|
184
184
|
|
|
185
|
+
def _own(layers, pid: str) -> None:
|
|
186
|
+
"""Put the instance id in front of the ids of a scene component's layers, nested ones too, so it can be used
|
|
187
|
+
more than once."""
|
|
188
|
+
for k in layers:
|
|
189
|
+
if not isinstance(k, dict):
|
|
190
|
+
continue
|
|
191
|
+
if "id" in k and not str(k["id"]).startswith(f"{pid}-"):
|
|
192
|
+
k["id"] = f"{pid}-{k['id']}"
|
|
193
|
+
_own(k.get("children") or [], pid)
|
|
194
|
+
|
|
195
|
+
|
|
185
196
|
def expand(raw, user: dict, theme: dict, scene=None):
|
|
186
197
|
if not isinstance(raw, dict):
|
|
187
198
|
return raw
|
|
@@ -201,7 +212,7 @@ def expand(raw, user: dict, theme: dict, scene=None):
|
|
|
201
212
|
_unused(name, raw, names, scene)
|
|
202
213
|
for i, k in enumerate(body):
|
|
203
214
|
k.setdefault("id", f"{params['id']}-{i}")
|
|
204
|
-
|
|
215
|
+
_own(body, params["id"])
|
|
205
216
|
out = {"type": "group", "x": raw.get("x", 0), "y": raw.get("y", 0), "children": body}
|
|
206
217
|
elif name in BUILTIN:
|
|
207
218
|
out = BUILTIN[name](params, theme)
|
|
@@ -76,7 +76,8 @@ KEYS EVERY LAYER HAS
|
|
|
76
76
|
x, y position of the anchor. A number (px), "50%" (of the canvas, or of the group's
|
|
77
77
|
w and h), "50%-120", "10vw", "5vh", or an expression.
|
|
78
78
|
anchor center (default) | topleft | top | topright | left | right | bottomleft | bottom |
|
|
79
|
-
bottomright. Video
|
|
79
|
+
bottomright. Video and shader default to topleft, even with x and y set; an image
|
|
80
|
+
defaults to topleft without x and to center with it.
|
|
80
81
|
opacity 0..1 (default 1)
|
|
81
82
|
scale, scaleX, scaleY 1 = normal size
|
|
82
83
|
rotate degrees, clockwise
|
|
@@ -92,7 +93,8 @@ KEYS EVERY LAYER HAS
|
|
|
92
93
|
enter, exit a motion (see ENTER AND EXIT)
|
|
93
94
|
hidden true skips the layer (and its sound)
|
|
94
95
|
sound a file played when the layer starts, timed by the layer (see SOUND)
|
|
95
|
-
allowOffscreen true stops check warning when the layer leaves the canvas
|
|
96
|
+
allowOffscreen true stops check warning when the layer leaves the canvas; on a group or
|
|
97
|
+
sequence it covers the layers inside
|
|
96
98
|
note free text, ignored
|
|
97
99
|
|
|
98
100
|
LAYER TYPES (defaults in brackets)
|
|
@@ -280,7 +282,8 @@ COMPONENTS {"use": "<name>", "id": "...", "at": ..., "dur": ..., ...parameters}
|
|
|
280
282
|
scale, rotate, hidden, sound.
|
|
281
283
|
Your own: "components": {"badge": {"params": {"label": "NEW"}, "layers": [
|
|
282
284
|
{"type": "text", "text": "{{label}}", "size": 40}]}}
|
|
283
|
-
then a layer {"use": "badge", "label": "BETA", "x": 200, "y": 100}.
|
|
285
|
+
then a layer {"use": "badge", "label": "BETA", "x": 200, "y": 100}. Its layers' ids, nested ones too,
|
|
286
|
+
get the instance id in front ("b1-title" for "title" in {"use": "badge", "id": "b1"}).
|
|
284
287
|
|
|
285
288
|
SOUND
|
|
286
289
|
Top-level "audio" lists the sounds. Each one:
|
|
@@ -338,8 +341,10 @@ SOUND
|
|
|
338
341
|
backend (kokoro, chatterbox, elevenlabs, each a plugin) and hands it settings with options:
|
|
339
342
|
{"id": "vo2", "role": "voice", "text": "[whispers] Nobody knows. [laughs] Yet.", "backend": "elevenlabs",
|
|
340
343
|
"speaker": "George", "options": {"stability": 0.3}}
|
|
341
|
-
chatterbox
|
|
342
|
-
[happy], [sarcastic], [surprised], [crying] and a few more ([laughs] reads as [laugh])
|
|
344
|
+
chatterbox takes [laugh], [chuckle], [sigh], [gasp], [cough], [clear throat], [whispering], [angry],
|
|
345
|
+
[happy], [sarcastic], [surprised], [crying] and a few more ([laughs] reads as [laugh]), but acts them
|
|
346
|
+
only some of the time ([laugh] made a sound in 6 of 9 test takes, [whispering] whispered in 0 of 7),
|
|
347
|
+
so use elevenlabs when a line needs its tags heard; its speaker
|
|
343
348
|
is a clip of 5 s or more whose voice it copies, and "options": {"model": "standard", "exaggeration": 0.8}
|
|
344
349
|
trades the tags for stronger emotion. elevenlabs needs an ELEVENLABS_API_KEY and uses the account's
|
|
345
350
|
quota; its speaker is a voice name or id, its options model (eleven_v4), stability, style and
|
|
@@ -387,8 +392,10 @@ COMMANDS
|
|
|
387
392
|
kine new scene.json write a starter scene
|
|
388
393
|
kine check scene.json JSON list of problems. error: must fix. warning: should fix:
|
|
389
394
|
text off the canvas or inside the outer 5% (safe-area),
|
|
390
|
-
overlapping text, contrast under 3:1,
|
|
391
|
-
than about 0.4 s + 0.3 s per word (read-time)
|
|
395
|
+
overlapping text, contrast under 3:1, text shown for less
|
|
396
|
+
than about 0.4 s + 0.3 s per word (read-time), or a video,
|
|
397
|
+
image or shader more than half off the canvas for 1 s or more
|
|
398
|
+
(media-offscreen). With sound (it is
|
|
392
399
|
mixed and measured, see SOUND): a voice cut off (audio-cut), two
|
|
393
400
|
voices at once (voice-overlap), music under 10 LU below a voice
|
|
394
401
|
(voice-masked), an sfx whose first sound is late (sfx-late), a
|
|
@@ -19,6 +19,10 @@ from .scene import Scene, SceneError, load
|
|
|
19
19
|
# Model triangles one frame may draw before check warns. 100,000 took 71 ms a frame at 1080p on
|
|
20
20
|
# the GPU in the 3D spike (2026-10-29), against about 15 ms at 20,000.
|
|
21
21
|
TRIANGLE_BUDGET = 60_000
|
|
22
|
+
# Layers check warns about when more than half of one stays off the canvas for MEDIA_OFF_FOR seconds; a slide or
|
|
23
|
+
# a transition passes through faster.
|
|
24
|
+
MEDIA = ("video", "image", "shader")
|
|
25
|
+
MEDIA_OFF_FOR = 1.0
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
def _times(scene: Scene, count: int, times=None) -> list[float]:
|
|
@@ -149,6 +153,7 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
|
|
|
149
153
|
pool.shutdown(wait=False)
|
|
150
154
|
r = Renderer(scene, track=True, scale=1.0)
|
|
151
155
|
found: dict[tuple, dict] = {}
|
|
156
|
+
off: dict[str, list] = {} # media layer (or its sequence) id -> (time, box, share off, layer) when mostly off
|
|
152
157
|
|
|
153
158
|
def add(level, code, lid, T, msg, fix="", until=None):
|
|
154
159
|
key = (code, lid)
|
|
@@ -183,8 +188,10 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
|
|
|
183
188
|
if b["alpha"] < 0.3:
|
|
184
189
|
continue
|
|
185
190
|
l, t, rr, bb = b["rect"]
|
|
186
|
-
layer = scene.by_id.get(lid)
|
|
187
|
-
allow =
|
|
191
|
+
layer = up = scene.by_id.get(lid)
|
|
192
|
+
allow = False
|
|
193
|
+
while up is not None and not allow: # set on a group or sequence, it covers what is inside
|
|
194
|
+
allow, up = bool(up.raw.get("allowOffscreen")), up.parent
|
|
188
195
|
if b["type"] in ("text", "counter", "captions"):
|
|
189
196
|
texts.append((lid, b))
|
|
190
197
|
if b.get("mirrored"):
|
|
@@ -200,7 +207,13 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
|
|
|
200
207
|
if px < H / 45 and b["alpha"] > 0.9:
|
|
201
208
|
add("warning", "tiny-text", lid, T, f"text renders at ~{px:.0f}px, small for {H}p video",
|
|
202
209
|
f"use at least {math.ceil(H / 45)}px")
|
|
203
|
-
elif b["type"]
|
|
210
|
+
elif b["type"] in MEDIA:
|
|
211
|
+
area = (rr - l) * (bb - t)
|
|
212
|
+
seen = max(0.0, min(rr, W) - max(l, 0.0)) * max(0.0, min(bb, H) - max(t, 0.0))
|
|
213
|
+
if area > 0 and seen < area / 2 and not allow and layer is not None:
|
|
214
|
+
seq = layer.parent if layer.parent is not None and layer.parent.raw.get("span") else None
|
|
215
|
+
off.setdefault(seq.id if seq else lid, []).append((T, b["rect"], 1 - seen / area, layer))
|
|
216
|
+
elif b["type"] != "group" and not allow:
|
|
204
217
|
if rr < 0 or bb < 0 or l > W or t > H:
|
|
205
218
|
add("info", "offscreen", lid, T, "layer is visible but entirely off canvas", "check x/y")
|
|
206
219
|
for i in range(len(texts)):
|
|
@@ -213,6 +226,17 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
|
|
|
213
226
|
if ix * iy / area > 0.15:
|
|
214
227
|
add("warning", "text-overlap", f"{texts[i][0]}+{texts[j][0]}", T,
|
|
215
228
|
"two text layers overlap", "offset one of them or separate them in time")
|
|
229
|
+
for lid, seen in off.items():
|
|
230
|
+
if len({s[0] for s in seen}) * step < MEDIA_OFF_FOR: # frames, once each where a sequence's clips overlap
|
|
231
|
+
continue
|
|
232
|
+
T, rect, share, layer = seen[0]
|
|
233
|
+
ty = layer.type
|
|
234
|
+
fix = ("a video or shader is placed by its top-left corner unless anchor says otherwise: set \"anchor\": "
|
|
235
|
+
"\"center\" to centre it on x and y, or move it inward"
|
|
236
|
+
if ty in ("video", "shader") and "anchor" not in layer.raw else
|
|
237
|
+
"move it inward, or set allowOffscreen if it is meant to be cut off")
|
|
238
|
+
add("warning", "media-offscreen", lid, T, f"{ty} box {rect} is {share:.0%} off the {W}x{H} canvas", fix,
|
|
239
|
+
until=seen[-1][0])
|
|
216
240
|
# Contrast: render the frame without the text layer and its successors, compare luminance under its box.
|
|
217
241
|
for layer in scene.all_layers():
|
|
218
242
|
if layer.type not in ("text", "counter"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinescript
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read.
|
|
5
5
|
Author: Dr. Know How
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,13 +135,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
136
|
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
137
|
(local, fast),
|
|
138
|
-
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
139
139
|
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
140
|
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
141
|
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
142
|
options changed is spoken again, and rendering never calls a speech engine.
|
|
143
|
-
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell
|
|
144
|
-
|
|
143
|
+
- **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
|
|
144
|
+
Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
|
|
145
|
+
goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
|
|
146
|
+
and Kokoro says the line without them.
|
|
145
147
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
146
148
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
147
149
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -167,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
167
169
|
|
|
168
170
|
`kine voice scene.json` speaks it once. The
|
|
169
171
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
170
|
-
to its scene file.
|
|
172
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
171
173
|
|
|
172
174
|
```bash
|
|
173
|
-
pip install kinescript-kokoro
|
|
175
|
+
pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
|
|
176
|
+
pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
|
|
177
|
+
pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
|
|
174
178
|
```
|
|
175
179
|
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
A plugin can also live in another Python, for Kokoro on Python 3.13 or newer or to keep PyTorch out of
|
|
181
|
+
Kinescript's environment: point `KINE_VOICE_CMD`, or `KINE_VOICE_CMD_<NAME>` for one backend, at it. Each plugin's
|
|
182
|
+
README has the details:
|
|
183
|
+
[Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro),
|
|
184
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox),
|
|
185
|
+
[ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs).
|
|
178
186
|
|
|
179
187
|
## Built for agents
|
|
180
188
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kinescript"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.1"
|
|
8
8
|
description = "Render motion graphics, video edits and voice-over from JSON scenes on the GPU, with checks an agent can read."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -326,6 +326,36 @@ def test_check_flags_offscreen_text_and_overlap():
|
|
|
326
326
|
assert "text-offscreen" in codes and "text-overlap" in codes and not res["ok"]
|
|
327
327
|
|
|
328
328
|
|
|
329
|
+
def test_check_flags_media_mostly_off_canvas(clip):
|
|
330
|
+
# A video sits by its top-left corner, so x and y at the middle without an anchor push it off to the bottom right.
|
|
331
|
+
placed = {"id": "v", "type": "video", "src": clip, "x": "50%", "y": "50%", "w": 320, "h": 180, "dur": 2}
|
|
332
|
+
res = check(scene([placed], duration=2))
|
|
333
|
+
hit = [i for i in res["issues"] if i["code"] == "media-offscreen"]
|
|
334
|
+
assert len(hit) == 1 and hit[0]["layer"] == "v" and hit[0]["level"] == "warning" and "anchor" in hit[0]["fix"]
|
|
335
|
+
assert "75% off" in hit[0]["message"] and hit[0]["to"] - hit[0]["from"] >= 1.5
|
|
336
|
+
for fixed in ({"anchor": "center"}, {"allowOffscreen": True}):
|
|
337
|
+
res = check(scene([dict(placed, **fixed)], duration=2))
|
|
338
|
+
assert not [i for i in res["issues"] if i["code"] == "media-offscreen"], fixed
|
|
339
|
+
# A sequence's clips get its x, y and anchor, so they are reported once, as the sequence; its allowOffscreen
|
|
340
|
+
# covers them.
|
|
341
|
+
seq = {"id": "cut", "type": "sequence", "x": "50%", "y": "50%", "w": 320, "h": 180,
|
|
342
|
+
"clips": [{"src": clip, "trim": [0, 1.5]}, {"src": clip, "trim": [1.5, 3]}]}
|
|
343
|
+
res = check(scene([seq], duration=3))
|
|
344
|
+
hit = [i for i in res["issues"] if i["code"] == "media-offscreen"]
|
|
345
|
+
assert [i["layer"] for i in hit] == ["cut"] and hit[0]["to"] > 2.5
|
|
346
|
+
res = check(scene([dict(seq, allowOffscreen=True)], duration=3))
|
|
347
|
+
assert not [i for i in res["issues"] if i["code"] == "media-offscreen"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_check_lets_media_pass_through_the_edge():
|
|
351
|
+
# Sliding in from off the canvas is mostly off for under a second, which is not a misplaced layer.
|
|
352
|
+
shader = {"id": "s", "type": "shader", "preset": "score", "w": 320, "h": 180, "dur": 2}
|
|
353
|
+
res = check(scene([dict(shader, x=[[0, -320], [0.5, 0, "outCubic"]])], duration=2))
|
|
354
|
+
assert not [i for i in res["issues"] if i["code"] == "media-offscreen"]
|
|
355
|
+
res = check(scene([dict(shader, x=-240)], duration=2)) # left there, the same layer is flagged
|
|
356
|
+
assert [i["layer"] for i in res["issues"] if i["code"] == "media-offscreen"] == ["s"]
|
|
357
|
+
|
|
358
|
+
|
|
329
359
|
def test_check_flags_low_contrast():
|
|
330
360
|
res = check(scene([{"id": "a", "type": "text", "text": "Grey", "x": 160, "y": 90, "size": 30, "fill": "#20232a"}],
|
|
331
361
|
bg="#1b1d22", duration=2))
|
|
@@ -359,6 +389,16 @@ def test_theme_names_work_inside_a_scenes_own_components():
|
|
|
359
389
|
assert [(s.by_id[i].raw["fill"], s.by_id[i].raw["stroke"]) for i in ("t1-w", "t2-w")] == [("#9299a8", "#123456")] * 2
|
|
360
390
|
|
|
361
391
|
|
|
392
|
+
def test_a_component_holding_a_group_can_be_used_twice():
|
|
393
|
+
spec = scene([{"use": "card", "id": "a", "label": "A"}, {"use": "card", "id": "ab", "label": "B"}])
|
|
394
|
+
spec["components"] = {"card": {"params": {"label": ""}, "layers": [
|
|
395
|
+
{"id": "box", "type": "group", "children": [{"id": "abc", "type": "text", "text": "{{label}}"},
|
|
396
|
+
{"type": "group", "children": [{"id": "dot", "type": "circle", "r": 4}]}]}]}}
|
|
397
|
+
s = k.Scene(spec)
|
|
398
|
+
assert {"a-box", "a-abc", "a-dot", "ab-box", "ab-abc", "ab-dot"} <= set(s.by_id)
|
|
399
|
+
assert s.by_id["ab-abc"].raw["text"] == "B"
|
|
400
|
+
|
|
401
|
+
|
|
362
402
|
def test_sequence_crossfades_and_video_renders(clip):
|
|
363
403
|
spec = scene([{"type": "sequence", "id": "cut", "clips": [{"src": clip, "trim": [0, 1.5]}, {"src": clip, "trim": [1, 3]}],
|
|
364
404
|
"transition": {"type": "fade", "dur": 0.5}}])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|