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.
Files changed (54) hide show
  1. {kinescript-1.2.0 → kinescript-1.3.1}/PKG-INFO +26 -10
  2. {kinescript-1.2.0 → kinescript-1.3.1}/README.md +25 -9
  3. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/__init__.py +1 -1
  4. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/components.py +12 -1
  5. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/docs.py +36 -12
  6. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/mcp.py +3 -1
  7. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/scene.py +3 -2
  8. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/schema.py +10 -3
  9. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/verify.py +27 -3
  10. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/voice.py +88 -31
  11. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/PKG-INFO +26 -10
  12. {kinescript-1.2.0 → kinescript-1.3.1}/pyproject.toml +1 -1
  13. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_core.py +230 -0
  14. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_packaging.py +29 -19
  15. {kinescript-1.2.0 → kinescript-1.3.1}/LICENSE +0 -0
  16. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/audio.py +0 -0
  17. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/cli.py +0 -0
  18. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/color.py +0 -0
  19. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/ease.py +0 -0
  20. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  21. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  22. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
  23. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  24. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  25. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  26. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  27. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
  28. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  29. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  30. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/listen.py +0 -0
  31. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/media.py +0 -0
  32. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/mesh.py +0 -0
  33. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/models.py +0 -0
  34. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/output.py +0 -0
  35. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/presets.py +0 -0
  36. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/props.py +0 -0
  37. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/render.py +0 -0
  38. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/render3d.py +0 -0
  39. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/shaders.py +0 -0
  40. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/space.py +0 -0
  41. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/svgpath.py +0 -0
  42. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/timing.py +0 -0
  43. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript/words.py +0 -0
  44. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/SOURCES.txt +0 -0
  45. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/dependency_links.txt +0 -0
  46. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/entry_points.txt +0 -0
  47. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/requires.txt +0 -0
  48. {kinescript-1.2.0 → kinescript-1.3.1}/kinescript.egg-info/top_level.txt +0 -0
  49. {kinescript-1.2.0 → kinescript-1.3.1}/setup.cfg +0 -0
  50. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_color_pipeline.py +0 -0
  51. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_docs.py +0 -0
  52. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_models.py +0 -0
  53. {kinescript-1.2.0 → kinescript-1.3.1}/tests/test_motion_blur.py +0 -0
  54. {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.2.0
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
138
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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. The Kokoro plugin needs Python 3.10 to 3.12:
172
+ to its scene file. Install the plugin for the voice you want next to Kinescript:
165
173
 
166
174
  ```bash
167
- pip install "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/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
168
178
  ```
169
179
 
170
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
171
- ([details](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)).
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
90
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
91
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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. The Kokoro plugin needs Python 3.10 to 3.12:
125
+ to its scene file. Install the plugin for the voice you want next to Kinescript:
118
126
 
119
127
  ```bash
120
- pip install "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/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
121
131
  ```
122
132
 
123
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
124
- ([details](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)).
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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.2.0"
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
- k["id"] = f"{params['id']}-{k['id']}" if not str(k["id"]).startswith(params["id"]) else k["id"]
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, shader, and image without x default to topleft.
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. kine check reports a sound with text that is not spoken
331
- yet (voice-missing) and one whose text, speaker or rate changed since it was spoken (voice-stale).
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, or text shown for less
376
- than about 0.4 s + 0.3 s per word (read-time). With sound (it is
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 or rate changed
400
- (--force: all of them, --only vo1: one). Needs a voice backend,
401
- the Kokoro plugin (with none installed, kine voice prints the pip
402
- command), or KINE_VOICE_CMD set to a command that reads JSON
403
- requests on stdin. Rendering never speaks.
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 rate changed are spoken again, unless force. Run it before check and render.",
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 rate of '{sid}' changed after {src} "
236
- "was spoken, so it says the old text", "run kine voice on this scene to speak it again")
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 = layer is not None and layer.raw.get("allowOffscreen")
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"] not in ("video", "group") and not allow:
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 sound is spoken again only when its text, speaker or rate changed since
9
- its file was made (or with force); `kine check` warns when they changed and the file was not remade.
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). KINE_VOICE_CMD names a command instead, for a backend in another Python: it reads a JSON
14
- list of those requests on stdin and prints the list of results. Scene text never reaches a shell.
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 @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/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 and rate. A change in any means it must be spoken again."""
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 _command() -> list[str] | None:
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
- cmd = _command()
139
- if cmd and not name:
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(cmd, input=json.dumps(reqs), capture_output=True, text=True, encoding="utf-8")
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"KINE_VOICE_CMD failed ({res.returncode}): {res.stderr.strip()[-600:]}")
182
+ raise RuntimeError(f"{var} failed ({res.returncode}): {res.stderr.strip()[-600:]}")
144
183
  return json.loads(res.stdout)
145
- return run, Path(cmd[-1]).name
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}'; installed: {', '.join(found) or 'none'}")
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 \"{KOKORO}\" (Python 3.10 to 3.12), "
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
- fn = found[key].load()
159
- return (lambda reqs: [fn(r) for r in reqs]), key
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
- if todo:
185
- speak, name = _speaker(backend)
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), "out": str(src)}
188
- for _, a, src, _, text, _ in todo])
189
- took = (time.perf_counter() - t0) / len(todo)
190
- for (sid, a, src, words_file, text, marks), res in zip(todo, results):
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
- out.append({"id": sid, "src": str(a.get("src") or default_src(sid)), "status": "made", "words": len(said),
202
- "seconds": round(float(secs), 2), "took": round(took, 2)})
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.2.0
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. [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
- is the first. `[[go]]` in the text names the next word, so a cue to `vo1:go` survives a rewrite of the line.
138
- Only a line whose text, speaker or rate changed is spoken again, and rendering never calls a speech engine.
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. The Kokoro plugin needs Python 3.10 to 3.12:
172
+ to its scene file. Install the plugin for the voice you want next to Kinescript:
165
173
 
166
174
  ```bash
167
- pip install "kinescript-kokoro @ git+https://github.com/drknowhow/kinescript#subdirectory=plugins/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
168
178
  ```
169
179
 
170
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
171
- ([details](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)).
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/ kinescript-kokoro, the Kokoro voice for kine voice (installed on its own)
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.2.0"
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
- README = ROOT / "README.md"
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
- def test_readme_has_no_relative_links_or_images():
59
- bad = relative_targets(_readme())
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
- def test_readme_points_at_files_that_exist_in_the_repo():
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
- def test_readme_renders_the_way_pypi_renders_it():
106
- text = _readme()
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((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
129
+ return tomllib.loads((PACKAGES[pkg] / "pyproject.toml").read_text(encoding="utf-8"))["project"]
124
130
 
125
131
 
126
- def test_summary_is_one_short_plain_line():
127
- summary = _project()["description"]
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
- def test_classifiers_are_all_valid():
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
- def test_keywords_are_unique():
146
- words = [k.lower() for k in _project()["keywords"]]
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
- def test_project_links_are_complete_and_verifiable():
151
- urls = _project()["urls"]
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