kinescript 1.3.0__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {kinescript-1.3.0 → kinescript-1.3.1}/PKG-INFO +16 -8
  2. {kinescript-1.3.0 → kinescript-1.3.1}/README.md +15 -7
  3. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/__init__.py +1 -1
  4. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/components.py +12 -1
  5. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/docs.py +14 -7
  6. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/verify.py +27 -3
  7. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/PKG-INFO +16 -8
  8. {kinescript-1.3.0 → kinescript-1.3.1}/pyproject.toml +1 -1
  9. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_core.py +40 -0
  10. {kinescript-1.3.0 → kinescript-1.3.1}/LICENSE +0 -0
  11. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/audio.py +0 -0
  12. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/cli.py +0 -0
  13. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/color.py +0 -0
  14. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/ease.py +0 -0
  15. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  16. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  17. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-OFL.txt +0 -0
  18. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  19. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  20. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  21. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  22. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-OFL.txt +0 -0
  23. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  24. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  25. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/listen.py +0 -0
  26. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/mcp.py +0 -0
  27. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/media.py +0 -0
  28. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/mesh.py +0 -0
  29. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/models.py +0 -0
  30. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/output.py +0 -0
  31. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/presets.py +0 -0
  32. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/props.py +0 -0
  33. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/render.py +0 -0
  34. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/render3d.py +0 -0
  35. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/scene.py +0 -0
  36. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/schema.py +0 -0
  37. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/shaders.py +0 -0
  38. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/space.py +0 -0
  39. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/svgpath.py +0 -0
  40. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/timing.py +0 -0
  41. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/voice.py +0 -0
  42. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript/words.py +0 -0
  43. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/SOURCES.txt +0 -0
  44. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/dependency_links.txt +0 -0
  45. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/entry_points.txt +0 -0
  46. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/requires.txt +0 -0
  47. {kinescript-1.3.0 → kinescript-1.3.1}/kinescript.egg-info/top_level.txt +0 -0
  48. {kinescript-1.3.0 → kinescript-1.3.1}/setup.cfg +0 -0
  49. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_color_pipeline.py +0 -0
  50. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_docs.py +0 -0
  51. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_models.py +0 -0
  52. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_motion_blur.py +0 -0
  53. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_packaging.py +0 -0
  54. {kinescript-1.3.0 → kinescript-1.3.1}/tests/test_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinescript
3
- Version: 1.3.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
@@ -135,13 +135,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
135
135
  - **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
136
136
  the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
137
  (local, fast),
138
- [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
138
+ [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
139
139
  cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
140
140
  (cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
141
141
  word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
142
142
  options changed is spoken again, and rendering never calls a speech engine.
143
- - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
144
- what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
143
+ - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
144
+ Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
145
+ goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
146
+ and Kokoro says the line without them.
145
147
  - **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
146
148
  -16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
147
149
  leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
@@ -167,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
167
169
 
168
170
  `kine voice scene.json` speaks it once. The
169
171
  [voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
170
- to its scene file. 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:
171
173
 
172
174
  ```bash
173
- pip install kinescript-kokoro
175
+ pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
176
+ pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
177
+ pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
174
178
  ```
175
179
 
176
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
177
- ([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).
178
186
 
179
187
  ## Built for agents
180
188
 
@@ -88,13 +88,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
88
88
  - **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
89
89
  the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
90
90
  (local, fast),
91
- [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
91
+ [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
92
92
  cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
93
93
  (cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
94
94
  word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
95
95
  options changed is spoken again, and rendering never calls a speech engine.
96
- - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
97
- what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
96
+ - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
97
+ Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
98
+ goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
99
+ and Kokoro says the line without them.
98
100
  - **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
99
101
  -16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
100
102
  leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
@@ -120,14 +122,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
120
122
 
121
123
  `kine voice scene.json` speaks it once. The
122
124
  [voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
123
- to its scene file. 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:
124
126
 
125
127
  ```bash
126
- pip install kinescript-kokoro
128
+ pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
129
+ pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
130
+ pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
127
131
  ```
128
132
 
129
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
130
- ([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).
131
139
 
132
140
  ## Built for agents
133
141
 
@@ -9,5 +9,5 @@ except ImportError as e: # on Linux the wheel loads the system's EGL and GL, ev
9
9
  from .output import render, still
10
10
  from .scene import Scene, SceneError, load
11
11
 
12
- __version__ = "1.3.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:
@@ -338,8 +341,10 @@ SOUND
338
341
  backend (kokoro, chatterbox, elevenlabs, each a plugin) and hands it settings with options:
339
342
  {"id": "vo2", "role": "voice", "text": "[whispers] Nobody knows. [laughs] Yet.", "backend": "elevenlabs",
340
343
  "speaker": "George", "options": {"stability": 0.3}}
341
- chatterbox acts on [laugh], [chuckle], [sigh], [gasp], [cough], [clear throat], [whispering], [angry],
342
- [happy], [sarcastic], [surprised], [crying] and a few more ([laughs] reads as [laugh]); its speaker
344
+ chatterbox takes [laugh], [chuckle], [sigh], [gasp], [cough], [clear throat], [whispering], [angry],
345
+ [happy], [sarcastic], [surprised], [crying] and a few more ([laughs] reads as [laugh]), but acts them
346
+ only some of the time ([laugh] made a sound in 6 of 9 test takes, [whispering] whispered in 0 of 7),
347
+ so use elevenlabs when a line needs its tags heard; its speaker
343
348
  is a clip of 5 s or more whose voice it copies, and "options": {"model": "standard", "exaggeration": 0.8}
344
349
  trades the tags for stronger emotion. elevenlabs needs an ELEVENLABS_API_KEY and uses the account's
345
350
  quota; its speaker is a voice name or id, its options model (eleven_v4), stability, style and
@@ -387,8 +392,10 @@ COMMANDS
387
392
  kine new scene.json write a starter scene
388
393
  kine check scene.json JSON list of problems. error: must fix. warning: should fix:
389
394
  text off the canvas or inside the outer 5% (safe-area),
390
- overlapping text, contrast under 3:1, or text shown for less
391
- 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
392
399
  mixed and measured, see SOUND): a voice cut off (audio-cut), two
393
400
  voices at once (voice-overlap), music under 10 LU below a voice
394
401
  (voice-masked), an sfx whose first sound is late (sfx-late), a
@@ -19,6 +19,10 @@ from .scene import Scene, SceneError, load
19
19
  # Model triangles one frame may draw before check warns. 100,000 took 71 ms a frame at 1080p on
20
20
  # the GPU in the 3D spike (2026-10-29), against about 15 ms at 20,000.
21
21
  TRIANGLE_BUDGET = 60_000
22
+ # Layers check warns about when more than half of one stays off the canvas for MEDIA_OFF_FOR seconds; a slide or
23
+ # a transition passes through faster.
24
+ MEDIA = ("video", "image", "shader")
25
+ MEDIA_OFF_FOR = 1.0
22
26
 
23
27
 
24
28
  def _times(scene: Scene, count: int, times=None) -> list[float]:
@@ -149,6 +153,7 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
149
153
  pool.shutdown(wait=False)
150
154
  r = Renderer(scene, track=True, scale=1.0)
151
155
  found: dict[tuple, dict] = {}
156
+ off: dict[str, list] = {} # media layer (or its sequence) id -> (time, box, share off, layer) when mostly off
152
157
 
153
158
  def add(level, code, lid, T, msg, fix="", until=None):
154
159
  key = (code, lid)
@@ -183,8 +188,10 @@ def check(src, step: float = 0.25, safe: float = 0.05) -> dict:
183
188
  if b["alpha"] < 0.3:
184
189
  continue
185
190
  l, t, rr, bb = b["rect"]
186
- layer = scene.by_id.get(lid)
187
- allow = 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"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinescript
3
- Version: 1.3.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
@@ -135,13 +135,15 @@ Sounds are a top-level `audio` list, timed like layers and mixed by ffmpeg while
135
135
  - **Voice-over from text.** Write what a voice says in `text` and `kine voice` speaks it through a plugin, writing
136
136
  the file and its word times: [Kokoro](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-kokoro)
137
137
  (local, fast),
138
- [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local, tags,
138
+ [Chatterbox](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-chatterbox) (local,
139
139
  cloned voices) or [ElevenLabs](https://github.com/drknowhow/kinescript/tree/main/plugins/kinescript-elevenlabs)
140
140
  (cloud, needs an API key), picked per sound with `backend`. `[[go]]` in the text names the next
141
141
  word, so a cue to `vo1:go` survives a rewrite of the line. Only a line whose text, speaker, rate, backend or
142
142
  options changed is spoken again, and rendering never calls a speech engine.
143
- - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell Chatterbox and ElevenLabs how to say
144
- what follows. Tags are not words, so cues, marks and captions skip them, and Kokoro says the line without them.
143
+ - **Delivery tags.** `[whispers]`, `[laughs]` or `[sighs]` in the text tell ElevenLabs how to say what follows.
144
+ Chatterbox takes them too but acts them only some of the time: in test takes it made a sound where `[laughs]`
145
+ goes in six of nine and whispered in none of seven. Tags are not words, so cues, marks and captions skip them,
146
+ and Kokoro says the line without them.
145
147
  - **A mix without listening.** Give a sound a `role` and it is set to that role's level: voice -18 LUFS, music
146
148
  -16, ambience -30, effects peaking at -6 dBFS. Music and ambience dip 14 dB while a voice speaks, an effect's
147
149
  leading silence is skipped so it lands on its cue, and the whole mix ends at -16 LUFS with peaks under
@@ -167,14 +169,20 @@ A narrated beat with a click on a marked word, over music that ducks under the v
167
169
 
168
170
  `kine voice scene.json` speaks it once. The
169
171
  [voice demo](https://drknowhow.github.io/kinescript/#sound) on the project site plays the result with sound, next
170
- to its scene file. 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:
171
173
 
172
174
  ```bash
173
- pip install kinescript-kokoro
175
+ pip install kinescript-kokoro # local and fast; Python 3.10 to 3.12
176
+ pip install kinescript-chatterbox # local, copies a voice from a clip, tags hit or miss; installs PyTorch
177
+ pip install kinescript-elevenlabs # ElevenLabs' API, acts on tags; needs ELEVENLABS_API_KEY
174
178
  ```
175
179
 
176
- On a newer Python, install it into another one and point `KINE_VOICE_CMD` at it
177
- ([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).
178
186
 
179
187
  ## Built for agents
180
188
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kinescript"
7
- version = "1.3.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}}])
File without changes
File without changes
File without changes
File without changes