kinescript 1.2.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.2.0 → kinescript-1.3.1}/PKG-INFO +26 -10
- {kinescript-1.2.0 → kinescript-1.3.1}/README.md +25 -9
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/__init__.py +1 -1
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/components.py +12 -1
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/docs.py +36 -12
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/mcp.py +3 -1
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/scene.py +3 -2
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/schema.py +10 -3
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/verify.py +27 -3
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/voice.py +88 -31
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/PKG-INFO +26 -10
- {kinescript-1.2.0 → kinescript-1.3.1}/pyproject.toml +1 -1
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_core.py +230 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_packaging.py +29 -19
- {kinescript-1.2.0 → kinescript-1.3.1}/LICENSE +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/audio.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/cli.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/color.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/ease.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/listen.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/media.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/mesh.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/models.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/output.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/presets.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/props.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/render.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/render3d.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/shaders.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/space.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/svgpath.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/timing.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/words.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/SOURCES.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/dependency_links.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/entry_points.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/requires.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/top_level.txt +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/setup.cfg +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_color_pipeline.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_docs.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_models.py +0 -0
- {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_motion_blur.py +0 -0
- {kinescript-1.2.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
|
+
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
|
|
@@ -133,9 +133,17 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
133
133
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
134
134
|
captions it, lighting each word as it is said.
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
|
-
the file and its word times
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
|
+
(local, fast),
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
139
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
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.
|
|
139
147
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
140
148
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
141
149
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -161,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
161
169
|
|
|
162
170
|
`kine voice scene.json` speaks it once. The
|
|
163
171
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
164
|
-
to its scene file.
|
|
172
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
165
173
|
|
|
166
174
|
```bash
|
|
167
|
-
pip install
|
|
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
|
|
168
178
|
```
|
|
169
179
|
|
|
170
|
-
|
|
171
|
-
|
|
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).
|
|
172
186
|
|
|
173
187
|
## Built for agents
|
|
174
188
|
|
|
@@ -300,7 +314,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
300
314
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
301
315
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
302
316
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
303
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
317
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
318
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
304
319
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
305
320
|
decoder, which is slower than sequential playback.
|
|
306
321
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -313,7 +328,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
313
328
|
mesh and models (3D), output (encoding and the audio mix),
|
|
314
329
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
315
330
|
examples/ example scenes and the generator for sample media
|
|
316
|
-
plugins/
|
|
331
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
332
|
+
kinescript-elevenlabs
|
|
317
333
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
318
334
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
319
335
|
brand/ logo files, generated art and the brand guide
|
|
@@ -86,9 +86,17 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
86
86
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
87
87
|
captions it, lighting each word as it is said.
|
|
88
88
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
89
|
-
the file and its word times
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
90
|
+
(local, fast),
|
|
91
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
92
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
93
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
94
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
95
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
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.
|
|
92
100
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
93
101
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
94
102
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -114,14 +122,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
114
122
|
|
|
115
123
|
`kine voice scene.json` speaks it once. The
|
|
116
124
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
117
|
-
to its scene file.
|
|
125
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
118
126
|
|
|
119
127
|
```bash
|
|
120
|
-
pip install
|
|
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
|
|
121
131
|
```
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
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).
|
|
125
139
|
|
|
126
140
|
## Built for agents
|
|
127
141
|
|
|
@@ -253,7 +267,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
253
267
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
254
268
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
255
269
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
256
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
270
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
271
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
257
272
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
258
273
|
decoder, which is slower than sequential playback.
|
|
259
274
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -266,7 +281,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
266
281
|
mesh and models (3D), output (encoding and the audio mix),
|
|
267
282
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
268
283
|
examples/ example scenes and the generator for sample media
|
|
269
|
-
plugins/
|
|
284
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
285
|
+
kinescript-elevenlabs
|
|
270
286
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
271
287
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
272
288
|
brand/ logo files, generated art and the brand guide
|
|
@@ -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.
|
|
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:
|
|
@@ -327,8 +330,25 @@ SOUND
|
|
|
327
330
|
It writes voice/vo1.wav (or the sound's src) and its words file, so cues and captions work as above.
|
|
328
331
|
[[name]] gives the next word that id: "at": "vo1:go" starts as it says Render, whatever the line
|
|
329
332
|
says later. speaker is the backend's voice (Kokoro: af_heart, af_bella, am_adam, bf_emma, bm_george),
|
|
330
|
-
rate (0.5 to 2, default 1) how fast it speaks.
|
|
331
|
-
|
|
333
|
+
rate (0.5 to 2, default 1) how fast it speaks. With Kokoro, a speaker of "af_heart,af_bella" blends
|
|
334
|
+
the two voices, [word](/phonemes/) in text says that word with the IPA phonemes you give (for a name it
|
|
335
|
+
says wrong), and [word](-1) or [word](+1) lowers or raises its stress; captions and word ids keep the
|
|
336
|
+
word. kine check reports a sound with text that is not spoken yet (voice-missing) and one whose text,
|
|
337
|
+
speaker, rate, backend or options changed since it was spoken (voice-stale).
|
|
338
|
+
A tag in the text says how to speak what follows, for a backend that acts on tags: [whispers], [laughs],
|
|
339
|
+
[sighs]. Tags are not words: marks and word ids skip them, a backend without tags says the line without
|
|
340
|
+
them, and kine voice lists what a backend could not do under "ignored". A sound picks its backend with
|
|
341
|
+
backend (kokoro, chatterbox, elevenlabs, each a plugin) and hands it settings with options:
|
|
342
|
+
{"id": "vo2", "role": "voice", "text": "[whispers] Nobody knows. [laughs] Yet.", "backend": "elevenlabs",
|
|
343
|
+
"speaker": "George", "options": {"stability": 0.3}}
|
|
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
|
|
348
|
+
is a clip of 5 s or more whose voice it copies, and "options": {"model": "standard", "exaggeration": 0.8}
|
|
349
|
+
trades the tags for stronger emotion. elevenlabs needs an ELEVENLABS_API_KEY and uses the account's
|
|
350
|
+
quota; its speaker is a voice name or id, its options model (eleven_v4), stability, style and
|
|
351
|
+
similarity_boost. kokoro has no tags.
|
|
332
352
|
You cannot hear the mix, so read it: kine check measures it and names each sound problem with its
|
|
333
353
|
time range, and kine look --audio draws it under the frames, with each sound's loudness in the JSON.
|
|
334
354
|
Sound is mixed into .mp4, .mov and .webm, and into --from/--to renders. -o mix.wav (or .m4a,
|
|
@@ -372,8 +392,10 @@ COMMANDS
|
|
|
372
392
|
kine new scene.json write a starter scene
|
|
373
393
|
kine check scene.json JSON list of problems. error: must fix. warning: should fix:
|
|
374
394
|
text off the canvas or inside the outer 5% (safe-area),
|
|
375
|
-
overlapping text, contrast under 3:1,
|
|
376
|
-
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
|
|
377
399
|
mixed and measured, see SOUND): a voice cut off (audio-cut), two
|
|
378
400
|
voices at once (voice-overlap), music under 10 LU below a voice
|
|
379
401
|
(voice-masked), an sfx whose first sound is late (sfx-late), a
|
|
@@ -396,11 +418,13 @@ COMMANDS
|
|
|
396
418
|
.wav, .m4a, .mp3 or .flac: the sound alone.
|
|
397
419
|
--scale 0.5 halves the size; --from 2 --to 4 renders a part.
|
|
398
420
|
kine voice scene.json speaks each sound's text into its file and times its words; a sound
|
|
399
|
-
is spoken again only when its text, speaker
|
|
400
|
-
(--force: all of them, --only vo1: one). Needs a
|
|
401
|
-
|
|
402
|
-
command)
|
|
403
|
-
|
|
421
|
+
is spoken again only when its text, speaker, rate, backend or
|
|
422
|
+
options changed (--force: all of them, --only vo1: one). Needs a
|
|
423
|
+
voice backend plugin (with none installed, kine voice prints the
|
|
424
|
+
pip command); --backend picks one for sounds without their own.
|
|
425
|
+
KINE_VOICE_CMD (or KINE_VOICE_CMD_CHATTERBOX for one backend) runs
|
|
426
|
+
a backend in another Python, as JSON requests on stdin.
|
|
427
|
+
Rendering never speaks.
|
|
404
428
|
kine watch scene.json re-makes the contact sheet on every save
|
|
405
429
|
kine doctor checks ffmpeg, the GPU and fonts
|
|
406
430
|
kine mcp the same tools for MCP clients
|
|
@@ -43,7 +43,9 @@ TOOLS = [
|
|
|
43
43
|
"required": ["scene"]}},
|
|
44
44
|
{"name": "kine_voice", "description": "Speak each sound's `text` into its file (voice/<id>.wav unless it has a "
|
|
45
45
|
"src) and write its word times, so layers can cue from words (\"vo1:render\") and captions can follow it. Only "
|
|
46
|
-
"sounds whose text, speaker or
|
|
46
|
+
"sounds whose text, speaker, rate, backend or options changed are spoken again, unless force. A sound's own "
|
|
47
|
+
"\"backend\" picks its voice plugin; backend here is the one for sounds without. Tags such as [laughs] are "
|
|
48
|
+
"acted by backends that can and listed under \"ignored\" by the rest. Run it before check and render.",
|
|
47
49
|
"inputSchema": {"type": "object", "properties": {"scene": _SCENE, "base": {"type": "string"},
|
|
48
50
|
"only": {"type": "array", "items": {"type": "string"}},
|
|
49
51
|
"force": {"type": "boolean", "default": False},
|
|
@@ -232,8 +232,9 @@ class Scene:
|
|
|
232
232
|
continue
|
|
233
233
|
rec = V.made(near) if a.get("text") else None
|
|
234
234
|
if rec and rec.get("hash") != V.fingerprint(a):
|
|
235
|
-
self.note("warning", "voice-stale", sid, f"the text, speaker or
|
|
236
|
-
"was spoken, so it says the old text",
|
|
235
|
+
self.note("warning", "voice-stale", sid, f"the text, speaker, rate, backend or options of '{sid}' "
|
|
236
|
+
f"changed after {src} was spoken, so it says the old text",
|
|
237
|
+
"run kine voice on this scene to speak it again")
|
|
237
238
|
given = a.get("words")
|
|
238
239
|
if given is None and not near.exists():
|
|
239
240
|
continue
|
|
@@ -48,13 +48,14 @@ TWEEN = {"from", "to", "dur", "delay", "ease", "keys"}
|
|
|
48
48
|
TWEEN_ALIAS = {"duration": "dur", "easing": "ease", "start": "from", "end": "to", "begin": "from"}
|
|
49
49
|
GRADIENT = {"linear", "radial", "stops"}
|
|
50
50
|
AUDIO = {"src", "at", "trim", "gain", "volume", "fadeIn", "fadeOut", "id", "role", "dur", "until", "loop", "pan",
|
|
51
|
-
"words", "text", "speaker", "rate"}
|
|
51
|
+
"words", "text", "speaker", "rate", "backend", "options"}
|
|
52
52
|
AUDIO_ALIAS = {"source": "src", "file": "src", "path": "src", "start": "at", "fade_in": "fadeIn",
|
|
53
53
|
"fade_out": "fadeOut", "fadein": "fadeIn", "fadeout": "fadeOut", "duration": "dur", "length": "dur",
|
|
54
54
|
"end": "until", "type": "role", "kind": "role", "balance": "pan", "repeat": "loop",
|
|
55
|
-
"script": "text", "say": "text", "tts": "text", "narrator": "speaker", "pace": "rate"
|
|
55
|
+
"script": "text", "say": "text", "tts": "text", "narrator": "speaker", "pace": "rate",
|
|
56
|
+
"engine": "backend", "settings": "options", "voiceSettings": "options"}
|
|
56
57
|
# A layer's "sound": cued from the layer, so it has no id, until, loop, words or text of its own.
|
|
57
|
-
SOUND = AUDIO - {"id", "until", "loop", "words", "text", "speaker", "rate"}
|
|
58
|
+
SOUND = AUDIO - {"id", "until", "loop", "words", "text", "speaker", "rate", "backend", "options"}
|
|
58
59
|
MIX = {"loudness", "peak", "levels", "duck"}
|
|
59
60
|
MIX_ALIAS = {"lufs": "loudness", "target": "loudness", "ceiling": "peak", "truePeak": "peak", "ducking": "duck",
|
|
60
61
|
"level": "levels"}
|
|
@@ -598,6 +599,12 @@ def _audio_values(a: dict, rep: Report, lid, where: str):
|
|
|
598
599
|
rep.add("error", "bad-value", lid, f"{where}text is {t!r}; it is the words to speak, as a string")
|
|
599
600
|
if r is not None and (isinstance(r, bool) or not isinstance(r, (int, float)) or not 0.5 <= r <= 2):
|
|
600
601
|
rep.add("error", "bad-value", lid, f"{where}rate is {r!r}; it is how fast the text is spoken, from 0.5 to 2")
|
|
602
|
+
b, o = a.get("backend"), a.get("options")
|
|
603
|
+
if b is not None and not (isinstance(b, str) and b.strip()):
|
|
604
|
+
rep.add("error", "bad-value", lid, f"{where}backend is {b!r}; it names the voice backend, like \"kokoro\"")
|
|
605
|
+
if o is not None and not isinstance(o, dict):
|
|
606
|
+
rep.add("error", "bad-value", lid, f"{where}options is {o!r}; it is an object the backend reads, like "
|
|
607
|
+
"{\"exaggeration\": 0.7}")
|
|
601
608
|
if not a.get("src") and not a.get("text"):
|
|
602
609
|
rep.add("error", "missing-key", lid, f"{where}an audio entry needs 'src' (or 'text' to speak)",
|
|
603
610
|
"\"src\": \"music.mp3\"")
|
|
@@ -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"):
|
|
@@ -5,30 +5,41 @@ A sound with text is spoken by a backend:
|
|
|
5
5
|
{"id": "vo1", "role": "voice", "text": "Write a scene. [[check]]Check it.", "speaker": "af_heart"}
|
|
6
6
|
|
|
7
7
|
writes voice/vo1.wav (or its src) and voice/vo1.words.json. [[name]] gives the next word that id, so a cue can
|
|
8
|
-
say "vo1:check" whatever the word is. A
|
|
9
|
-
|
|
8
|
+
say "vo1:check" whatever the word is. A tag such as [laughs] or [whispers] tells a backend that acts on tags how
|
|
9
|
+
to say what follows; it is not a word, and other backends never see it. A sound is spoken again only when its text,
|
|
10
|
+
speaker, rate, backend or options changed since its file was made (or with force); `kine check` warns when they
|
|
11
|
+
changed and the file was not remade.
|
|
10
12
|
|
|
11
13
|
A backend is a function registered under the entry-point group "kinescript.voice" (kinescript-kokoro is one) that
|
|
12
14
|
takes {"text", "speaker", "rate", "out"}, writes a WAV to out, and returns {"words": [{"word", "start", "end"}],
|
|
13
|
-
"speaker", "version", "seconds"} (seconds, the file's length, is optional).
|
|
14
|
-
|
|
15
|
+
"speaker", "version", "seconds"} (seconds, the file's length, is optional). The request also carries "tagged" (the
|
|
16
|
+
text with its tags), "options" (the sound's options, as given) and "base" (the scene's folder, for files the options
|
|
17
|
+
or speaker name). A backend that acts on tags returns "tags": true, anything it could not do in "ignored", and
|
|
18
|
+
words it could not time in "note".
|
|
19
|
+
A sound picks its backend with "backend"; KINE_VOICE_CMD_<NAME> runs that backend in another Python, and
|
|
20
|
+
KINE_VOICE_CMD runs the default one (or the backend whose module it runs, python -m kinescript_<name>). A command
|
|
21
|
+
reads a JSON list of those requests on stdin and prints the list of results. Scene text never reaches a shell.
|
|
15
22
|
"""
|
|
16
23
|
from __future__ import annotations
|
|
17
24
|
|
|
25
|
+
import contextlib
|
|
18
26
|
import hashlib
|
|
19
27
|
import json
|
|
20
28
|
import os
|
|
21
29
|
import re
|
|
22
30
|
import shlex
|
|
23
31
|
import subprocess
|
|
32
|
+
import sys
|
|
24
33
|
import time
|
|
25
34
|
from pathlib import Path
|
|
26
35
|
|
|
27
36
|
from . import words as W
|
|
28
37
|
|
|
29
38
|
GROUP = "kinescript.voice"
|
|
30
|
-
KOKORO = "kinescript-kokoro
|
|
39
|
+
KOKORO = "kinescript-kokoro"
|
|
31
40
|
MARK = re.compile(r"\[\[\s*(\w+)\s*\]\]")
|
|
41
|
+
# [laughs], [clears throat]: in single brackets, and neither a mark ([[name]]) nor a pronunciation ([word](/ph/)).
|
|
42
|
+
TAG = re.compile(r"(?<!\[)\[(?!\[)[^\[\]()]+\](?")
|
|
32
43
|
|
|
33
44
|
|
|
34
45
|
def default_src(sid: str) -> str:
|
|
@@ -36,17 +47,29 @@ def default_src(sid: str) -> str:
|
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def fingerprint(a: dict) -> str:
|
|
39
|
-
"""What the file was made from: text, speaker
|
|
50
|
+
"""What the file was made from: text, speaker, rate, and the backend and options when the sound names them.
|
|
51
|
+
A change in any means it must be spoken again."""
|
|
40
52
|
spec = {"text": a.get("text"), "speaker": a.get("speaker"), "rate": a.get("rate", 1)}
|
|
53
|
+
spec.update({k: a[k] for k in ("backend", "options") if a.get(k)}) # absent: the same hash as before they existed
|
|
41
54
|
return hashlib.sha256(json.dumps(spec, sort_keys=True).encode()).hexdigest()[:16]
|
|
42
55
|
|
|
43
56
|
|
|
57
|
+
def tags(text: str) -> list[str]:
|
|
58
|
+
return TAG.findall(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def untag(text: str) -> str:
|
|
62
|
+
"""The text without its tags: what a backend that does not act on tags says, and the words marks count."""
|
|
63
|
+
return re.sub(r"\s+", " ", TAG.sub("", text)).strip()
|
|
64
|
+
|
|
65
|
+
|
|
44
66
|
def split_marks(text: str) -> tuple[str, dict[int, str]]:
|
|
45
|
-
"""'Go [[x]]now' -> ('Go now', {1: 'x'}): the text to speak, and the word index each mark names.
|
|
67
|
+
"""'Go [[x]]now' -> ('Go now', {1: 'x'}): the text to speak, and the word index each mark names. Tags stay in
|
|
68
|
+
the text and are not counted as words."""
|
|
46
69
|
out, marks = [], {}
|
|
47
70
|
for k, part in enumerate(MARK.split(text)):
|
|
48
71
|
if k % 2:
|
|
49
|
-
n = len(" ".join(out).split())
|
|
72
|
+
n = len(untag(" ".join(out)).split())
|
|
50
73
|
if n in marks:
|
|
51
74
|
raise ValueError(f"two marks, [[{marks[n]}]] and [[{part}]], name the same word")
|
|
52
75
|
marks[n] = part
|
|
@@ -124,39 +147,61 @@ def backends() -> dict:
|
|
|
124
147
|
return {e.name: e for e in entry_points(group=GROUP)}
|
|
125
148
|
|
|
126
149
|
|
|
127
|
-
def
|
|
128
|
-
cmd = os.environ.get("KINE_VOICE_CMD", "").strip()
|
|
129
|
-
if not cmd:
|
|
130
|
-
return None
|
|
150
|
+
def _argv(cmd: str) -> list[str]:
|
|
131
151
|
if cmd.startswith("["):
|
|
132
152
|
return [str(x) for x in json.loads(cmd)]
|
|
133
153
|
return [t.strip('"') for t in shlex.split(cmd, posix=os.name != "nt")]
|
|
134
154
|
|
|
135
155
|
|
|
156
|
+
def _env_name(name: str) -> str:
|
|
157
|
+
return "KINE_VOICE_CMD_" + re.sub(r"\W", "_", name).upper()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _command(name: str | None = None) -> tuple[list[str], str] | None:
|
|
161
|
+
"""The command that runs backend `name` in another Python, and the variable it came from: KINE_VOICE_CMD_<NAME>,
|
|
162
|
+
else KINE_VOICE_CMD when no backend is named or it runs that backend's module (-m kinescript_<name>)."""
|
|
163
|
+
own = os.environ.get(_env_name(name), "").strip() if name else ""
|
|
164
|
+
if own:
|
|
165
|
+
return _argv(own), _env_name(name)
|
|
166
|
+
cmd = os.environ.get("KINE_VOICE_CMD", "").strip()
|
|
167
|
+
if cmd and (not name or f"kinescript_{name}" in _argv(cmd)):
|
|
168
|
+
return _argv(cmd), "KINE_VOICE_CMD"
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
136
172
|
def _speaker(name: str | None):
|
|
137
173
|
"""The function that speaks a list of requests, and its name."""
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
found = backends()
|
|
175
|
+
cmd = _command(name)
|
|
176
|
+
if cmd and (not name or name not in found or cmd[1] != "KINE_VOICE_CMD"):
|
|
177
|
+
argv, var = cmd
|
|
178
|
+
|
|
140
179
|
def run(reqs):
|
|
141
|
-
res = subprocess.run(
|
|
180
|
+
res = subprocess.run(argv, input=json.dumps(reqs), capture_output=True, text=True, encoding="utf-8")
|
|
142
181
|
if res.returncode:
|
|
143
|
-
raise RuntimeError(f"
|
|
182
|
+
raise RuntimeError(f"{var} failed ({res.returncode}): {res.stderr.strip()[-600:]}")
|
|
144
183
|
return json.loads(res.stdout)
|
|
145
|
-
return run, Path(
|
|
146
|
-
found = backends()
|
|
184
|
+
return run, name or Path(argv[-1]).name
|
|
147
185
|
if name:
|
|
148
186
|
if name not in found:
|
|
149
|
-
raise ValueError(f"no voice backend '{name}'
|
|
187
|
+
raise ValueError(f"no voice backend '{name}': pip install kinescript-{name}, or set {_env_name(name)} to a "
|
|
188
|
+
f"command that runs it in another Python; installed: {', '.join(found) or 'none'}")
|
|
150
189
|
key = name
|
|
151
190
|
elif len(found) == 1:
|
|
152
191
|
key = next(iter(found))
|
|
153
192
|
elif found:
|
|
154
193
|
raise ValueError(f"several voice backends are installed ({', '.join(found)}); pick one with --backend")
|
|
155
194
|
else:
|
|
156
|
-
raise ValueError(f"no voice backend is installed: pip install
|
|
195
|
+
raise ValueError(f"no voice backend is installed: pip install {KOKORO} (Python 3.10 to 3.12), "
|
|
157
196
|
"or set KINE_VOICE_CMD to a command that speaks")
|
|
158
|
-
|
|
159
|
-
|
|
197
|
+
# What a backend prints goes to stderr: kine voice answers in JSON on stdout, and kine mcp speaks JSON-RPC there.
|
|
198
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
199
|
+
fn = found[key].load()
|
|
200
|
+
|
|
201
|
+
def run(reqs):
|
|
202
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
203
|
+
return [fn(r) for r in reqs]
|
|
204
|
+
return run, key
|
|
160
205
|
|
|
161
206
|
|
|
162
207
|
def generate(scene, only=None, force: bool = False, backend: str | None = None, base=None) -> dict:
|
|
@@ -181,24 +226,36 @@ def generate(scene, only=None, force: bool = False, backend: str | None = None,
|
|
|
181
226
|
continue
|
|
182
227
|
src.parent.mkdir(parents=True, exist_ok=True)
|
|
183
228
|
todo.append((sid, a, src, words_file, text, marks))
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
groups: dict = {} # a sound's own backend, else the one asked for, else the default
|
|
230
|
+
for job in todo:
|
|
231
|
+
groups.setdefault(job[1].get("backend") or backend, []).append(job)
|
|
232
|
+
for want, jobs in groups.items():
|
|
233
|
+
speak, name = _speaker(want)
|
|
186
234
|
t0 = time.perf_counter()
|
|
187
|
-
results = speak([{"text": text, "speaker": a.get("speaker"), "rate": a.get("rate", 1),
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
235
|
+
results = speak([{"text": untag(text), "tagged": text, "speaker": a.get("speaker"), "rate": a.get("rate", 1),
|
|
236
|
+
"options": a.get("options") or {}, "out": str(src), "base": str(Path(base).resolve())}
|
|
237
|
+
for _, a, src, _, text, _ in jobs])
|
|
238
|
+
took = (time.perf_counter() - t0) / len(jobs)
|
|
239
|
+
for (sid, a, src, words_file, text, marks), res in zip(jobs, results):
|
|
191
240
|
if not src.exists():
|
|
192
241
|
raise RuntimeError(f"the voice backend {name} did not write {src} for '{sid}'")
|
|
193
242
|
timed = [{"word": w["word"], "start": w["start"], "end": w["end"]} for w in W.read(res.get("words") or [])]
|
|
194
|
-
said = place_marks(text, marks, timed)
|
|
243
|
+
said = place_marks(untag(text), marks, timed)
|
|
195
244
|
W.read(said) # the ids are valid and unique before the file is written
|
|
196
245
|
rec = {"hash": fingerprint(a), "backend": name, "version": res.get("version"),
|
|
197
246
|
"speaker": res.get("speaker") or a.get("speaker")}
|
|
198
247
|
words_file.write_text(json.dumps({"words": said, "made": rec}, indent=1, ensure_ascii=False),
|
|
199
248
|
encoding="utf-8")
|
|
200
249
|
secs = res.get("seconds") or (said[-1]["end"] if said else 0.0)
|
|
201
|
-
|
|
202
|
-
|
|
250
|
+
item = {"id": sid, "src": str(a.get("src") or default_src(sid)), "status": "made", "words": len(said),
|
|
251
|
+
"seconds": round(float(secs), 2), "took": round(took, 2)}
|
|
252
|
+
ignored = list(res.get("ignored") or []) + ([] if res.get("tags") else tags(text))
|
|
253
|
+
if ignored:
|
|
254
|
+
item["ignored"] = ignored # said without these: the backend cannot do them
|
|
255
|
+
if res.get("note"):
|
|
256
|
+
item["note"] = str(res["note"])
|
|
257
|
+
elif not said and untag(text).strip():
|
|
258
|
+
item["note"] = f"{name} gave no word times, so word cues and voice captions of '{sid}' do not work"
|
|
259
|
+
out.append(item)
|
|
203
260
|
order = {sid: k for k, (sid, _) in enumerate(entries)}
|
|
204
261
|
return {"ok": True, "voices": sorted(out, key=lambda v: order[v["id"]])}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinescript
|
|
3
|
-
Version: 1.
|
|
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
|
|
@@ -133,9 +133,17 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
|
|
|
133
133
|
word: `"at": "vo1:render"` starts a layer as the voice says "render", and `{"type": "captions", "voice": "vo1"}`
|
|
134
134
|
captions it, lighting each word as it is said.
|
|
135
135
|
- **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
|
|
136
|
-
the file and its word times
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
|
|
137
|
+
(local, fast),
|
|
138
|
+
[Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
|
|
139
|
+
cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
|
|
140
|
+
(cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
|
|
141
|
+
word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
|
|
142
|
+
options changed is spoken again, and rendering never calls a speech engine.
|
|
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.
|
|
139
147
|
- **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
|
|
140
148
|
-16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
|
|
141
149
|
leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
|
|
@@ -161,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
|
|
|
161
169
|
|
|
162
170
|
`kine voice scene.json` speaks it once. The
|
|
163
171
|
[voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
|
|
164
|
-
to its scene file.
|
|
172
|
+
to its scene file. Install the plugin for the voice you want next to Kinescript:
|
|
165
173
|
|
|
166
174
|
```bash
|
|
167
|
-
pip install
|
|
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
|
|
168
178
|
```
|
|
169
179
|
|
|
170
|
-
|
|
171
|
-
|
|
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).
|
|
172
186
|
|
|
173
187
|
## Built for agents
|
|
174
188
|
|
|
@@ -300,7 +314,8 @@ Within one process, such as the MCP server, the workers stay warm for two minute
|
|
|
300
314
|
- Audio is mixing and levelling (trim, gain over time, fades, speed, loop, pan, role levels, ducking, a loudness
|
|
301
315
|
target), with no effects such as EQ, compression of single sounds or reverb.
|
|
302
316
|
- Kinescript does not speak by itself: `kine voice` needs a plugin or a command. The Kokoro plugin times words
|
|
303
|
-
for English voices only and needs Python 3.10 to 3.12
|
|
317
|
+
for English voices only and needs Python 3.10 to 3.12; the Chatterbox plugin speaks and times English only;
|
|
318
|
+
the ElevenLabs plugin needs an API key and a network connection.
|
|
304
319
|
- Video is decoded and resampled by ffmpeg to the scene frame rate. Jumping backwards or far ahead reopens the
|
|
305
320
|
decoder, which is slower than sequential playback.
|
|
306
321
|
- Text layout is single-script left-to-right with greedy wrapping. No shaping for complex scripts or bidi.
|
|
@@ -313,7 +328,8 @@ kinescript/ engine: timing, props, scene, components, render (Skia), space, re
|
|
|
313
328
|
mesh and models (3D), output (encoding and the audio mix),
|
|
314
329
|
verify (check, look, probe), schema, docs, mcp, cli
|
|
315
330
|
examples/ example scenes and the generator for sample media
|
|
316
|
-
plugins/
|
|
331
|
+
plugins/ voices for kine voice, each installed on its own: kinescript-kokoro, kinescript-chatterbox,
|
|
332
|
+
kinescript-elevenlabs
|
|
317
333
|
marketing/ the launch video as a scene and its builder; build_site.py renders the site
|
|
318
334
|
site/ the project site: demo scenes next to the videos rendered from them
|
|
319
335
|
brand/ logo files, generated art and the brand guide
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kinescript"
|
|
7
|
-
version = "1.
|
|
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}}])
|
|
@@ -886,6 +926,196 @@ def test_marks_find_their_word_when_the_voice_reads_it_differently():
|
|
|
886
926
|
split_marks("[[a]]x [[a]]y")
|
|
887
927
|
|
|
888
928
|
|
|
929
|
+
def test_tags_are_not_words_and_reach_only_backends_that_act_on_them(monkeypatch, tmp_path):
|
|
930
|
+
from kinescript import voice
|
|
931
|
+
text, marks = voice.split_marks("Well [sighs] okay, [[go]]go [laughs]now.")
|
|
932
|
+
assert (text, marks) == ("Well [sighs] okay, go [laughs]now.", {2: "go"})
|
|
933
|
+
assert voice.untag(text) == "Well okay, go now."
|
|
934
|
+
# marks and pronunciations are not tags
|
|
935
|
+
assert voice.tags("Meet [Kinescript](/kˈɪn/) [[a]]now [clears throat] ok") == ["[clears throat]"]
|
|
936
|
+
got = []
|
|
937
|
+
|
|
938
|
+
def entry(acts):
|
|
939
|
+
def speak(req):
|
|
940
|
+
got.append((req["text"], req["tagged"]))
|
|
941
|
+
return {**_fake_speak(req), "tags": acts}
|
|
942
|
+
return type("E", (), {"load": lambda self: speak})()
|
|
943
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
944
|
+
monkeypatch.setattr(voice, "backends", lambda: {"plain": entry(False), "acts": entry(True)})
|
|
945
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
946
|
+
spec["audio"] = [{"id": "a", "text": "Well [sighs] okay. [[go]]Go.", "backend": "plain"},
|
|
947
|
+
{"id": "b", "text": "Well [sighs] okay. [[go]]Go.", "backend": "acts"}]
|
|
948
|
+
a, b = voice.generate(spec, base=tmp_path)["voices"]
|
|
949
|
+
assert got == [("Well okay. Go.", "Well [sighs] okay. Go.")] * 2
|
|
950
|
+
assert a["ignored"] == ["[sighs]"] and "ignored" not in b # said without it, and said so
|
|
951
|
+
words = json.loads((tmp_path / "voice" / "b.words.json").read_text())["words"]
|
|
952
|
+
assert [w["word"] for w in words] == ["Well", "okay.", "Go."] and words[2]["id"] == "go"
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def test_what_a_backend_prints_stays_off_stdout(monkeypatch, tmp_path, capsys):
|
|
956
|
+
# kine voice answers in JSON on stdout and kine mcp speaks JSON-RPC there; Chatterbox prints as it loads
|
|
957
|
+
from kinescript import voice
|
|
958
|
+
|
|
959
|
+
def speak(req):
|
|
960
|
+
print("loaded PerthNet")
|
|
961
|
+
return _fake_speak(req)
|
|
962
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
963
|
+
monkeypatch.setattr(voice, "backends", lambda: {"loud": type("E", (), {"load": lambda self: speak})()})
|
|
964
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
965
|
+
spec["audio"] = [{"id": "a", "text": "hello"}]
|
|
966
|
+
assert voice.generate(spec, base=tmp_path)["voices"][0]["status"] == "made"
|
|
967
|
+
out, err = capsys.readouterr()
|
|
968
|
+
assert out == "" and "loaded PerthNet" in err
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def test_each_sound_picks_its_backend_and_options(monkeypatch, tmp_path):
|
|
972
|
+
import hashlib
|
|
973
|
+
from kinescript import voice
|
|
974
|
+
seen = []
|
|
975
|
+
|
|
976
|
+
def entry(name):
|
|
977
|
+
def speak(req):
|
|
978
|
+
seen.append((name, req["options"], req["base"]))
|
|
979
|
+
return {**_fake_speak(req), **({"note": "half untimed"} if name == "two" else {})}
|
|
980
|
+
return type("E", (), {"load": lambda self: speak})()
|
|
981
|
+
monkeypatch.delenv("KINE_VOICE_CMD", raising=False)
|
|
982
|
+
monkeypatch.setattr(voice, "backends", lambda: {"one": entry("one"), "two": entry("two")})
|
|
983
|
+
spec = scene([{"type": "rect"}], duration="auto")
|
|
984
|
+
spec["audio"] = [{"id": "a", "text": "first"},
|
|
985
|
+
{"id": "b", "text": "second", "engine": "two", "settings": {"seed": 3}}] # aliases read
|
|
986
|
+
with pytest.raises(ValueError, match="several voice backends are installed"):
|
|
987
|
+
voice.generate(spec, base=tmp_path)
|
|
988
|
+
got = voice.generate(spec, base=tmp_path, backend="one")
|
|
989
|
+
assert [v.get("note") for v in got["voices"]] == [None, "half untimed"] # the backend's own note
|
|
990
|
+
assert seen == [("one", {}, str(tmp_path.resolve())), ("two", {"seed": 3}, str(tmp_path.resolve()))]
|
|
991
|
+
assert json.loads((tmp_path / "voice" / "b.words.json").read_text())["made"]["backend"] == "two"
|
|
992
|
+
# options are part of what the file was made from; a sound without backend or options keeps its old hash
|
|
993
|
+
f = tmp_path / "s.json"
|
|
994
|
+
spec["audio"] = [{"id": "b", "text": "second", "backend": "two", "options": {"seed": 4}}]
|
|
995
|
+
f.write_text(json.dumps(spec))
|
|
996
|
+
assert [i["code"] for i in k.load(str(f)).issues] == ["voice-stale"]
|
|
997
|
+
old = {"text": "x", "speaker": None, "rate": 1}
|
|
998
|
+
assert voice.fingerprint({"text": "x"}) == hashlib.sha256(json.dumps(old, sort_keys=True).encode()).hexdigest()[:16]
|
|
999
|
+
with pytest.raises(ValueError, match="no voice backend 'three': pip install kinescript-three, or set "
|
|
1000
|
+
"KINE_VOICE_CMD_THREE"):
|
|
1001
|
+
voice.generate({**spec, "audio": [{"id": "c", "text": "x", "backend": "three"}]}, base=tmp_path)
|
|
1002
|
+
# a backend in another Python, by name: its own variable, or KINE_VOICE_CMD when it runs that module
|
|
1003
|
+
tool = tmp_path / "speak.py"
|
|
1004
|
+
tool.write_text("import json, sys\nsys.path.insert(0, %r)\nfrom test_core import _fake_speak\n"
|
|
1005
|
+
"print(json.dumps([_fake_speak(r) for r in json.load(sys.stdin)]))\n" % str(Path(__file__).parent))
|
|
1006
|
+
monkeypatch.setenv("KINE_VOICE_CMD_THREE", json.dumps([sys.executable, str(tool)]))
|
|
1007
|
+
got = voice.generate({**spec, "audio": [{"id": "c", "text": "x y", "backend": "three"}]}, base=tmp_path)
|
|
1008
|
+
assert got["voices"][0]["words"] == 2
|
|
1009
|
+
assert voice._command("kokoro") is None
|
|
1010
|
+
monkeypatch.setenv("KINE_VOICE_CMD", "py -m kinescript_kokoro")
|
|
1011
|
+
assert voice._command("kokoro") == (["py", "-m", "kinescript_kokoro"], "KINE_VOICE_CMD")
|
|
1012
|
+
assert voice._command("other") is None and voice._command(None)[1] == "KINE_VOICE_CMD"
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _plugin(monkeypatch, name):
|
|
1016
|
+
monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "plugins" / f"kinescript-{name}"))
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def test_forced_alignment_places_words_around_a_laugh_and_a_number(monkeypatch):
|
|
1020
|
+
_plugin(monkeypatch, "chatterbox")
|
|
1021
|
+
from kinescript_chatterbox.align import ctc_path, times, units
|
|
1022
|
+
vocab = {"<pad>": 0, "|": 1, "A": 2, "B": 3, "C": 4, "D": 5}
|
|
1023
|
+
words, seq = units("AB [laugh] C 12.", vocab)
|
|
1024
|
+
assert words == ["AB", "C", "12."] and [s[0] for s in seq] == [2, 3, -2, 4, 1, -1]
|
|
1025
|
+
|
|
1026
|
+
def frames(said): # what each 20 ms frame sounds like most: a vocabulary id, 0 for nothing
|
|
1027
|
+
lp = np.full((len(said), len(vocab)), np.log(0.02))
|
|
1028
|
+
lp[np.arange(len(said)), said] = np.log(0.9)
|
|
1029
|
+
return lp
|
|
1030
|
+
# silence, A A B, a laugh (heard as D D D), silence, C C, a word gap, "twelve" (D D D), silence
|
|
1031
|
+
said = [0, 0, 2, 2, 3, 5, 5, 5, 0, 4, 4, 1, 5, 5, 5, 0]
|
|
1032
|
+
got = times(ctc_path(frames(said), seq, 0), seq, words, offset=1)
|
|
1033
|
+
assert got == [{"word": "AB", "start": 1.04, "end": 1.1}, {"word": "C", "start": 1.18, "end": 1.22},
|
|
1034
|
+
{"word": "12.", "start": 1.24, "end": 1.3}]
|
|
1035
|
+
# no laugh at all: the tag takes no time
|
|
1036
|
+
said = [0, 2, 3, 0, 4, 1, 5, 0]
|
|
1037
|
+
assert [w["start"] for w in times(ctc_path(frames(said), seq, 0), seq, words)] == [0.02, 0.08, 0.12]
|
|
1038
|
+
with pytest.raises(ValueError, match="too short"):
|
|
1039
|
+
ctc_path(frames([2, 3]), seq, 0)
|
|
1040
|
+
# a line of more than 127 states (63 letters and gaps) backtracks without overflowing
|
|
1041
|
+
words, seq = units(" ".join(["AB"] * 30), vocab)
|
|
1042
|
+
said = [0] + [2, 3, 1] * 30
|
|
1043
|
+
assert [w["start"] for w in times(ctc_path(frames(said), seq, 0), seq, words)][-1] == 1.76
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_forced_alignment_reads_numbers_as_they_are_said(monkeypatch):
|
|
1047
|
+
_plugin(monkeypatch, "chatterbox")
|
|
1048
|
+
from kinescript_chatterbox.align import spoken, units
|
|
1049
|
+
said = {w: " ".join(spoken(w)) for w in ["250", "2026", "2005", "1,000", "3.5", "v1.2.0", "$5", "40%", "21st",
|
|
1050
|
+
"100th", "Render.", "—"]}
|
|
1051
|
+
assert said == {"250": "TWO HUNDRED FIFTY", "2026": "TWENTY TWENTY SIX", "2005": "TWO THOUSAND FIVE",
|
|
1052
|
+
"1,000": "ONE THOUSAND", "3.5": "THREE POINT FIVE", "v1.2.0": "V ONE POINT TWO POINT ZERO",
|
|
1053
|
+
"$5": "FIVE DOLLARS", "40%": "FORTY PERCENT", "21st": "TWENTY FIRST", "100th": "ONE HUNDREDTH",
|
|
1054
|
+
"Render.": "RENDER", "—": ""}
|
|
1055
|
+
vocab = {"<pad>": 0, "|": 1, **{c: 2 + i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ'")}}
|
|
1056
|
+
words, seq = units("took 12 — now", vocab)
|
|
1057
|
+
# each word of a number is its own run of letters, a lone dash says nothing
|
|
1058
|
+
assert words == ["took", "12", "now"]
|
|
1059
|
+
assert "".join("|" if s[0] == 1 else "?" if s[0] < 0 else chr(63 + s[0]) for s in seq) == "TOOK|TWELVE|NOW"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_chatterbox_reads_tags_written_for_other_backends(monkeypatch):
|
|
1063
|
+
_plugin(monkeypatch, "chatterbox")
|
|
1064
|
+
import kinescript_chatterbox as cb
|
|
1065
|
+
monkeypatch.setattr(cb, "TURBO_TAGS", {"[laugh]", "[sigh]", "[chuckle]", "[clear throat]", "[whispering]"})
|
|
1066
|
+
written = ["[laugh]", "[Laughs]", "[laughing]", "[sighs]", "[chuckles]", "[whispers]", "[clears throat]", "[sings]"]
|
|
1067
|
+
assert [cb.turbo_tag(t) for t in written] == ["[laugh]", "[laugh]", "[laugh]", "[sigh]", "[chuckle]",
|
|
1068
|
+
"[whispering]", "[clear throat]", None]
|
|
1069
|
+
long = "One two three. " * 30
|
|
1070
|
+
assert all(len(c) <= 300 for c in cb.chunks(long)) and " ".join(cb.chunks(long)) == long.strip()
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def test_elevenlabs_times_words_from_its_character_times(monkeypatch, tmp_path):
|
|
1074
|
+
import base64
|
|
1075
|
+
import wave
|
|
1076
|
+
_plugin(monkeypatch, "elevenlabs")
|
|
1077
|
+
import kinescript_elevenlabs as el
|
|
1078
|
+
text = "Hi [laughs] there, all."
|
|
1079
|
+
chars = list(text)
|
|
1080
|
+
starts = [0.1 * k for k in range(len(chars))]
|
|
1081
|
+
words = el.words_from_chars(chars, starts, [s + 0.08 for s in starts])
|
|
1082
|
+
assert [w["word"] for w in words] == ["Hi", "there,", "all."]
|
|
1083
|
+
assert words[1] == {"word": "there,", "start": 1.2, "end": 1.68} # first to last letter, not the comma
|
|
1084
|
+
calls = []
|
|
1085
|
+
|
|
1086
|
+
def call(path, key, body=None, ctype="application/json"):
|
|
1087
|
+
calls.append((path, json.loads(body) if ctype == "application/json" and body else body))
|
|
1088
|
+
if path == "/v1/voices":
|
|
1089
|
+
return {"voices": [{"voice_id": "a" * 20, "name": "Aria - calm"}]}
|
|
1090
|
+
if path.startswith("/v1/forced-alignment"):
|
|
1091
|
+
return {"words": [{"text": "Hi", "start": 0.0, "end": 0.2}, {"text": " ", "start": 0.2, "end": 0.3},
|
|
1092
|
+
{"text": "there,", "start": 0.3, "end": 0.6}]}
|
|
1093
|
+
timing = None if "multilingual" in json.loads(body)["model_id"] else {
|
|
1094
|
+
"characters": chars, "character_start_times_seconds": starts,
|
|
1095
|
+
"character_end_times_seconds": [s + 0.08 for s in starts]}
|
|
1096
|
+
return {"audio_base64": base64.b64encode(b"\0\0" * 24000).decode(), "alignment": timing}
|
|
1097
|
+
monkeypatch.setattr(el, "_call", call)
|
|
1098
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "k")
|
|
1099
|
+
out = tmp_path / "v.wav"
|
|
1100
|
+
req = {"text": "Hi there, all.", "tagged": text, "speaker": "Aria", "rate": 1.1, "out": str(out),
|
|
1101
|
+
"options": {"stability": 0.3, "model": "eleven_v4", "loud": 1}}
|
|
1102
|
+
res = el.synth(req)
|
|
1103
|
+
assert calls[1] == (f"/v1/text-to-speech/{'a' * 20}/with-timestamps?output_format=pcm_24000",
|
|
1104
|
+
{"text": text, "model_id": "eleven_v4", "voice_settings": {"stability": 0.3, "speed": 1.1}})
|
|
1105
|
+
assert res["tags"] and res["ignored"] == ["options.loud"] and res["seconds"] == 1.0 and len(res["words"]) == 3
|
|
1106
|
+
with wave.open(str(out)) as f:
|
|
1107
|
+
assert (f.getframerate(), f.getnframes()) == (24000, 24000)
|
|
1108
|
+
# a model without tags gets the text without them; with no character times, forced alignment times the words
|
|
1109
|
+
calls.clear()
|
|
1110
|
+
res = el.synth({**req, "rate": 2, "speaker": "b" * 20, "options": {"model": "eleven_multilingual_v2"}})
|
|
1111
|
+
assert calls[0][1]["text"] == "Hi there, all." and not res["tags"]
|
|
1112
|
+
assert res["ignored"] == ["rate 2: ElevenLabs speaks at 0.7 to 1.2"] and calls[1][0] == "/v1/forced-alignment"
|
|
1113
|
+
assert [w["word"] for w in res["words"]] == ["Hi", "there,"]
|
|
1114
|
+
monkeypatch.delenv("ELEVENLABS_API_KEY")
|
|
1115
|
+
with pytest.raises(RuntimeError, match="set ELEVENLABS_API_KEY"):
|
|
1116
|
+
el.synth(req)
|
|
1117
|
+
|
|
1118
|
+
|
|
889
1119
|
def test_kokoro_tokens_become_the_words_of_the_text(monkeypatch):
|
|
890
1120
|
from types import SimpleNamespace as T
|
|
891
1121
|
monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "plugins" / "kinescript-kokoro"))
|
|
@@ -13,7 +13,10 @@ from urllib.parse import unquote, urlparse
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
15
|
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
-
|
|
16
|
+
# Every package this repository publishes to PyPI: the folder with its pyproject.toml and README.md.
|
|
17
|
+
PACKAGES = {"kinescript": ROOT, **{f"kinescript-{p}": ROOT / "plugins" / f"kinescript-{p}"
|
|
18
|
+
for p in ("kokoro", "chatterbox", "elevenlabs")}}
|
|
19
|
+
each_package = pytest.mark.parametrize("pkg", list(PACKAGES))
|
|
17
20
|
|
|
18
21
|
# Link targets that work on PyPI. Everything else (docs/hero.gif, ../x, /x, //host/x, a bare filename) is
|
|
19
22
|
# resolved against pypi.org and breaks.
|
|
@@ -49,14 +52,15 @@ def relative_targets(text):
|
|
|
49
52
|
return [t for t in link_targets(text) if not ABSOLUTE.match(t)]
|
|
50
53
|
|
|
51
54
|
|
|
52
|
-
def _readme():
|
|
53
|
-
return README.read_text(encoding="utf-8")
|
|
55
|
+
def _readme(pkg="kinescript"):
|
|
56
|
+
return (PACKAGES[pkg] / "README.md").read_text(encoding="utf-8")
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
# --- README.md ---------------------------------------------------------------------------------------------
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
@each_package
|
|
62
|
+
def test_readme_has_no_relative_links_or_images(pkg):
|
|
63
|
+
bad = relative_targets(_readme(pkg))
|
|
60
64
|
assert not bad, (
|
|
61
65
|
"README.md is the PyPI long description, so a relative target resolves against pypi.org and breaks, "
|
|
62
66
|
"and PyPI drops any other scheme (data:, javascript:). "
|
|
@@ -72,10 +76,11 @@ REPO_FILE = re.compile(
|
|
|
72
76
|
)
|
|
73
77
|
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
@each_package
|
|
80
|
+
def test_readme_points_at_files_that_exist_in_the_repo(pkg):
|
|
76
81
|
# The README's images are served from the main branch. PyPI keeps the README of every release forever, so a
|
|
77
82
|
# file that is renamed or deleted breaks the page of every release that links to it.
|
|
78
|
-
hits = [m for m in map(REPO_FILE.match, link_targets(_readme())) if m]
|
|
83
|
+
hits = [m for m in map(REPO_FILE.match, link_targets(_readme(pkg))) if m]
|
|
79
84
|
missing = [m.group(0) for m in hits if not (ROOT / unquote(m.group(1))).exists()]
|
|
80
85
|
assert not missing, f"README.md links to repo files that do not exist: {missing}"
|
|
81
86
|
|
|
@@ -102,8 +107,9 @@ def _render(text):
|
|
|
102
107
|
return rm.render(text)
|
|
103
108
|
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
@each_package
|
|
111
|
+
def test_readme_renders_the_way_pypi_renders_it(pkg):
|
|
112
|
+
text = _readme(pkg)
|
|
107
113
|
out = _render(text)
|
|
108
114
|
assert out, "readme_renderer returned nothing: PyPI would show the plain README text instead of HTML"
|
|
109
115
|
out = unquote(html.unescape(out)) # comrak percent-encodes some characters in URLs
|
|
@@ -115,24 +121,26 @@ def test_readme_renders_the_way_pypi_renders_it():
|
|
|
115
121
|
|
|
116
122
|
# --- pyproject.toml ----------------------------------------------------------------------------------------
|
|
117
123
|
|
|
118
|
-
def _project():
|
|
124
|
+
def _project(pkg="kinescript"):
|
|
119
125
|
try:
|
|
120
126
|
import tomllib
|
|
121
127
|
except ModuleNotFoundError: # Python 3.10
|
|
122
128
|
tomllib = pytest.importorskip("tomli")
|
|
123
|
-
return tomllib.loads((
|
|
129
|
+
return tomllib.loads((PACKAGES[pkg] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
|
124
130
|
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
@each_package
|
|
133
|
+
def test_summary_is_one_short_plain_line(pkg):
|
|
134
|
+
summary = _project(pkg)["description"]
|
|
128
135
|
assert 20 <= len(summary) <= 120, f"{len(summary)} characters: {summary!r}"
|
|
129
136
|
assert "\n" not in summary and not re.search(r"[`*\[\]<>]", summary), "the summary is plain text, not Markdown"
|
|
130
137
|
assert not summary.endswith(" "), summary
|
|
131
138
|
|
|
132
139
|
|
|
133
|
-
|
|
140
|
+
@each_package
|
|
141
|
+
def test_classifiers_are_all_valid(pkg):
|
|
134
142
|
trove = pytest.importorskip("trove_classifiers")
|
|
135
|
-
listed = _project()["classifiers"]
|
|
143
|
+
listed = _project(pkg)["classifiers"]
|
|
136
144
|
unknown = [c for c in listed if c not in trove.classifiers]
|
|
137
145
|
deprecated = [c for c in listed if c in trove.deprecated_classifiers]
|
|
138
146
|
assert not unknown, f"PyPI rejects the upload for classifiers it does not know: {unknown}"
|
|
@@ -142,13 +150,15 @@ def test_classifiers_are_all_valid():
|
|
|
142
150
|
assert not [c for c in listed if c.startswith("License ::")]
|
|
143
151
|
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
@each_package
|
|
154
|
+
def test_keywords_are_unique(pkg):
|
|
155
|
+
words = [k.lower() for k in _project(pkg)["keywords"]]
|
|
147
156
|
assert words and len(set(words)) == len(words)
|
|
148
157
|
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
@each_package
|
|
160
|
+
def test_project_links_are_complete_and_verifiable(pkg):
|
|
161
|
+
urls = _project(pkg)["urls"]
|
|
152
162
|
# These labels get an icon on PyPI (see docs.pypi.org/project_metadata, "Link icons").
|
|
153
163
|
for label in ("Homepage", "Documentation", "Source", "Issues", "Changelog"):
|
|
154
164
|
assert label in urls, f"missing project link {label!r}"
|
|
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
|