@olegkoval/agent-skills 1.59.3 → 1.61.0

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 (231) hide show
  1. package/.github/copilot-instructions.md +2 -0
  2. package/.github/prompts/carousel-maker.prompt.md +102 -0
  3. package/.github/prompts/self-critique.prompt.md +167 -43
  4. package/.github/prompts/shorts-animator.prompt.md +103 -0
  5. package/.kiro/steering/carousel-maker.md +101 -0
  6. package/.kiro/steering/shorts-animator.md +102 -0
  7. package/.windsurf/rules/carousel-maker.md +100 -0
  8. package/.windsurf/rules/shorts-animator.md +101 -0
  9. package/README.md +5 -3
  10. package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  11. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  12. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  13. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  14. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  15. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  16. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  17. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  18. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  19. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  20. package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  21. package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  22. package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  23. package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  24. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  25. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  26. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  27. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  28. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  29. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  30. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  31. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  32. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  33. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  34. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  35. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  36. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  37. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  38. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  39. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  40. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  41. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  42. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  43. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  44. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  45. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  46. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  47. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  48. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  49. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  50. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  51. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  52. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  53. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  54. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  55. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  56. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  57. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  58. package/adapters/claude/olko-reflection/skills/self-critique/SKILL.md +167 -43
  59. package/adapters/claude/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  60. package/adapters/claude/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  61. package/adapters/codex/olko-creative/README.md +2 -0
  62. package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  63. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  64. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  65. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  66. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  67. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  68. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  69. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  70. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  71. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  72. package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  73. package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  74. package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  75. package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  76. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  77. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  78. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  79. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  80. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  81. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  82. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  83. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  84. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  85. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  86. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  87. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  88. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  89. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  90. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  91. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  92. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  93. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  94. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  95. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  96. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  97. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  98. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  99. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  100. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  101. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  102. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  103. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  104. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  105. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  106. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  107. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  108. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  109. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  110. package/adapters/cursor/olko-reflection/skills/self-critique/SKILL.md +167 -43
  111. package/adapters/cursor/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  112. package/adapters/cursor/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  113. package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  114. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  115. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  116. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  117. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  118. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  119. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  120. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  121. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  122. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  123. package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  124. package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  125. package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  126. package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  127. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  128. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  129. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  130. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  131. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  132. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  133. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  134. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  135. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  136. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  137. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  138. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  139. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  140. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  141. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  142. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  143. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  144. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  145. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  146. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  147. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  148. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  149. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  150. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  151. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  152. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  153. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  154. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  155. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  156. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  157. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  158. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  159. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  160. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  161. package/adapters/grok/olko-reflection/skills/self-critique/SKILL.md +167 -43
  162. package/adapters/grok/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  163. package/adapters/grok/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  164. package/adapters/hermes/olko-creative/README.md +2 -0
  165. package/catalog/skills.json +47 -1
  166. package/docs/superpowers/plans/2026-10-27-self-critique-v2.md +306 -0
  167. package/docs/superpowers/specs/2026-10-27-self-critique-v2-spec.md +104 -0
  168. package/package.json +1 -1
  169. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  170. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  171. package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
  172. package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  173. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  174. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  175. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  176. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  177. package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  178. package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  179. package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  180. package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  181. package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
  182. package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  183. package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  184. package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  185. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  186. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  187. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  188. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  189. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  190. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  191. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  192. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  193. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  194. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  195. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  196. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  197. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  198. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  199. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  200. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  201. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  202. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  203. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  204. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  205. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  206. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  207. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  208. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  209. package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  210. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  211. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  212. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  213. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  214. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  215. package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  216. package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  217. package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  218. package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  219. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  220. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  221. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  222. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  223. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  224. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  225. package/plugins/olko-reflection/skills/self-critique/SKILL.md +167 -43
  226. package/plugins/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  227. package/plugins/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  228. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  229. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  230. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
  231. package/scripts/lib/catalog.mjs +2 -1
@@ -0,0 +1,105 @@
1
+ // ffmpeg and files: every process and disk write the pipeline makes, kept out of the domain.
2
+ import { spawn, spawnSync, type SpawnSyncOptions } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import type { Json, Timeline } from "../domain/types.ts";
6
+
7
+ export const SR = 48000;
8
+
9
+ export function run(cmd: string, args: readonly string[], opts: SpawnSyncOptions = {}): string {
10
+ const r = spawnSync(cmd, args, { encoding: "utf8", maxBuffer: 1 << 28, ...opts });
11
+ if (r.status !== 0) {
12
+ const out = String(r.stderr || r.stdout || r.error?.message || "");
13
+ throw new Error(`${cmd} ${args.slice(0, 3).join(" ")}... failed:\n${out.slice(-1500)}`);
14
+ }
15
+ return String(r.stdout);
16
+ }
17
+
18
+ export const has = (cmd: string, args: readonly string[] = ["-version"]): boolean => spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
19
+
20
+ export const readJson = (f: string): unknown => JSON.parse(fs.readFileSync(f, "utf8"));
21
+ export const writeJson = (f: string, v: Json | object): void => fs.writeFileSync(f, JSON.stringify(v, null, 2) + "\n");
22
+ export const mkdir = (...parts: string[]): string => {
23
+ const d = path.join(...parts);
24
+ fs.mkdirSync(d, { recursive: true });
25
+ return d;
26
+ };
27
+
28
+ export const duration = (f: string): number =>
29
+ parseFloat(run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", f]));
30
+
31
+ /** Mono 48 kHz float samples of a file, or of a slice of it. */
32
+ export function pcm(file: string, from = 0, dur?: number): Float32Array {
33
+ const args = ["-v", "error", ...(from ? ["-ss", String(from)] : []), ...(dur ? ["-t", String(dur)] : []), "-i", file, "-ac", "1", "-ar", String(SR), "-f", "f32le", "-"];
34
+ const b = spawnSync("ffmpeg", args, { maxBuffer: 1 << 30 }).stdout;
35
+ return new Float32Array(b.buffer, b.byteOffset, b.length / 4);
36
+ }
37
+
38
+ export function wav(file: string, data: Float32Array): void {
39
+ const n = data.length;
40
+ const buf = Buffer.alloc(44 + n * 2);
41
+ buf.write("RIFF", 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write("WAVE", 8); buf.write("fmt ", 12);
42
+ buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(SR, 24);
43
+ buf.writeUInt32LE(SR * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write("data", 36); buf.writeUInt32LE(n * 2, 40);
44
+ data.forEach((x, i) => buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(x * 32767))), 44 + i * 2));
45
+ fs.writeFileSync(file, buf);
46
+ }
47
+
48
+ export type Mix = { readonly voice: string; readonly music: string | null; readonly hasVoice: boolean; readonly volume: number; readonly duration: number };
49
+
50
+ /** Voice ducks the music (sidechain), then loudness to the -14 LUFS the platforms normalise to. */
51
+ const MIX_ARGS: Record<"both" | "music" | "voice", (m: Mix) => readonly string[]> = {
52
+ both: (m) => ["-i", m.voice, "-i", m.music ?? "", "-filter_complex",
53
+ `[1:a]volume=${m.volume}[m];[0:a]asplit=2[v][sc];[m][sc]sidechaincompress=threshold=0.04:ratio=3:attack=30:release=500[md];[v][md]amix=inputs=2:duration=longest:normalize=0,loudnorm=I=-14:TP=-1.5:LRA=11[o]`,
54
+ "-map", "[o]", "-ar", String(SR)],
55
+ music: (m) => ["-i", m.music ?? "", "-af", `volume=${m.volume},loudnorm=I=-15:TP=-1.5`],
56
+ voice: (m) => ["-i", m.voice, "-af", "loudnorm=I=-14:TP=-1.5"],
57
+ };
58
+
59
+ export function mix(m: Mix, out: string): string {
60
+ const kind = m.music && m.hasVoice ? "both" : m.music ? "music" : "voice";
61
+ run("ffmpeg", ["-v", "error", "-y", ...MIX_ARGS[kind](m), "-t", String(m.duration), out]);
62
+ return out;
63
+ }
64
+
65
+ const sheetWidth = (tl: Timeline): number => (tl.W > tl.H ? 480 : 300);
66
+ const tile = (tl: Timeline): string => {
67
+ const cols = Math.min(6, tl.beats.length);
68
+ return `scale=${sheetWidth(tl)}:-2,tile=${cols}x${Math.ceil(tl.beats.length / cols)}:padding=6:color=white`;
69
+ };
70
+
71
+ /** One contact sheet from numbered stills (frames/s000.jpg...). */
72
+ export const stillSheet = (tl: Timeline, pattern: string, out: string): void =>
73
+ void run("ffmpeg", ["-v", "error", "-y", "-framerate", "1", "-i", pattern, "-vf", tile(tl), "-frames:v", "1", out]);
74
+
75
+ /** One frame per shot, taken from the finished file at 60% of the shot: what a viewer actually sees. */
76
+ export function videoSheet(tl: Timeline, video: string, out: string): void {
77
+ const sel = tl.beats.map((b) => `between(t\\,${(b.start + b.dur * 0.6).toFixed(2)}\\,${(b.start + b.dur * 0.6 + 0.04).toFixed(2)})`).join("+");
78
+ run("ffmpeg", ["-v", "error", "-y", "-i", video, "-vf", `select='${sel}',${tile(tl)}`, "-frames:v", "1", "-fps_mode", "vfr", out]);
79
+ }
80
+
81
+ export type Encoder = { readonly write: (jpeg: Buffer) => Promise<void>; readonly done: () => Promise<number> };
82
+
83
+ /** Drawings in at fps/twos, out at fps: each drawing holds for `twos` frames, like hand animation. */
84
+ // Every drawing is re-stroked, so nothing carries over between them and quality alone sets the size:
85
+ // crf 17 made ~50 Mbit/s, crf 23 halves it with no visible change. The JPEGs arrive full-range;
86
+ // converting to tv range stops players and platforms from showing them with the wrong contrast.
87
+ export function encoder(tl: Timeline, audio: string, out: string): Encoder {
88
+ const ff = spawn("ffmpeg", ["-v", "error", "-y", "-f", "image2pipe", "-c:v", "mjpeg", "-framerate", String(tl.fps / tl.twos), "-i", "-", "-i", audio,
89
+ "-map", "0:v", "-map", "1:a", "-r", String(tl.fps), "-vf", "scale=out_range=tv", "-c:v", "libx264", "-preset", "medium", "-crf", "23",
90
+ "-pix_fmt", "yuv420p", "-color_range", "tv", "-movflags", "+faststart",
91
+ "-c:a", "aac", "-b:a", "192k", "-shortest", out], { stdio: ["pipe", "inherit", "inherit"] });
92
+ const closed = new Promise<number>((r) => ff.on("close", (code) => r(code ?? 1)));
93
+ return {
94
+ write: (buf) => new Promise((r) => (ff.stdin.write(buf) ? r() : ff.stdin.once("drain", () => r()))),
95
+ done: () => {
96
+ ff.stdin.end();
97
+ return closed;
98
+ },
99
+ };
100
+ }
101
+
102
+ export const jpeg = (dataUrl: unknown): Buffer => {
103
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) throw new Error("the page returned no image");
104
+ return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
105
+ };
@@ -0,0 +1,53 @@
1
+ // The page a short is painted on: the runtime (type-stripped to classic scripts), the scenes, the assets.
2
+ // Everything is written under <project>/build so the skill folder itself stays read-only.
3
+ import fs from "node:fs";
4
+ import { stripTypeScriptTypes } from "node:module";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import type { JsonMap } from "../domain/types.ts";
8
+ import { openPage, type Page } from "./chrome.ts";
9
+ import { mkdir } from "./media.ts";
10
+
11
+ const RUNTIME = path.join(import.meta.dirname, "..", "runtime");
12
+ const SCRIPTS = ["kit", "paint", "stage"];
13
+ const IMAGE = /\.(png|jpe?g|webp|svg)$/i;
14
+ const GPU = "(()=>{const c=document.createElement('canvas').getContext('webgl2');const d=c&&c.getExtension('WEBGL_debug_renderer_info');return c?c.getParameter(d?d.UNMASKED_RENDERER_WEBGL:c.RENDERER):'none'})()";
15
+
16
+ /** The browser loads these as classic scripts, so nothing may import or export. */
17
+ export function classicScript(file: string): string {
18
+ const js = stripTypeScriptTypes(fs.readFileSync(file, "utf8"), { mode: "strip" });
19
+ if (/^\s*(import|export)\s/m.test(js)) throw new Error(`${file}: import/export survives stripping`);
20
+ return js;
21
+ }
22
+
23
+ const html = (state: JsonMap, imgs: readonly string[]): string => `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:#000}</style></head><body>
24
+ <canvas id="out"></canvas>
25
+ <script>window.SHORT=${JSON.stringify(state)};window.IMAGES={};</script>
26
+ <script src="kit.js"></script><script src="paint.js"></script><script src="scenes.js"></script>
27
+ <script>
28
+ Promise.all(${JSON.stringify(imgs)}.map(f=>new Promise(r=>{const i=new Image();i.onload=()=>{IMAGES[f.replace(/\\.[^.]+$/,"")]=i;r()};i.onerror=r;i.src="assets/"+f}))).then(()=>{const s=document.createElement("script");s.src="stage.js";document.body.appendChild(s)});
29
+ </script></body></html>`;
30
+
31
+ /** Writes build/index.html and what it loads. `state` becomes window.SHORT. */
32
+ export function buildPage(dir: string, state: JsonMap): string {
33
+ const scenes = path.join(dir, "scenes.js");
34
+ if (!fs.existsSync(scenes)) throw new Error("no scenes.js");
35
+ const b = mkdir(dir, "build");
36
+ SCRIPTS.forEach((n) => fs.writeFileSync(path.join(b, `${n}.js`), classicScript(path.join(RUNTIME, `${n}.ts`))));
37
+ fs.copyFileSync(scenes, path.join(b, "scenes.js"));
38
+ const assets = path.join(dir, "assets");
39
+ const imgs = fs.existsSync(assets) ? fs.readdirSync(assets).filter((f) => IMAGE.test(f)) : [];
40
+ if (imgs.length) imgs.forEach((f) => fs.copyFileSync(path.join(assets, f), path.join(mkdir(b, "assets"), f)));
41
+ const index = path.join(b, "index.html");
42
+ fs.writeFileSync(index, html(state, imgs));
43
+ return index;
44
+ }
45
+
46
+ export async function paintPage(dir: string, state: JsonMap, width: number, height: number): Promise<Page> {
47
+ const pg = await openPage(pathToFileURL(buildPage(dir, state)).href, width, height);
48
+ const gl = await pg.evaluate(GPU);
49
+ if (/swiftshader|llvmpipe/i.test(String(gl))) console.log("· no GPU in this browser, painting on the CPU (slower, same picture)");
50
+ return pg;
51
+ }
52
+
53
+ export const pageErrors = (pg: Page): readonly string[] => pg.logs().filter((l) => /error/i.test(l)).slice(0, 8);
@@ -0,0 +1,75 @@
1
+ // `SA check` facts: run every scene once against a recording kit, without a browser.
2
+ // The kit records each call it sees, so the domain rules can judge calls, not pixels.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import vm from "node:vm";
6
+ import type { Call, Probe } from "../domain/check.ts";
7
+ import { isMap } from "../domain/schema.ts";
8
+ import type { JsonMap, Timeline } from "../domain/types.ts";
9
+
10
+ const KIT_SRC = path.join(import.meta.dirname, "..", "runtime", "kit.ts");
11
+ const IMAGE = /\.(png|jpe?g|webp|svg)$/i;
12
+ const VALUES = ["g", "W", "H", "u", "t", "p", "r", "pal", "beat", "mix", "alpha", "rng", "noise1", "X", "Y"];
13
+
14
+ /** Every name a scene may call or read on k, read from the kit itself so the two cannot drift. */
15
+ export const kitNames = (): readonly string[] => [
16
+ ...VALUES,
17
+ ...[...fs.readFileSync(KIT_SRC, "utf8").matchAll(/(\w+): \((?:o|list)\) =>/g)].flatMap((m) => (m[1] ? [m[1]] : [])),
18
+ ];
19
+
20
+ const plain = (o: unknown): JsonMap => {
21
+ try {
22
+ const v: unknown = JSON.parse(JSON.stringify(o ?? {}));
23
+ return isMap(v) ? v : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ };
28
+
29
+ /** A canvas context that accepts anything and draws nothing. */
30
+ const sink = (): unknown => {
31
+ const handler: ProxyHandler<() => void> = {
32
+ get: (_t, key) => (key === Symbol.toPrimitive ? () => 0 : proxy),
33
+ set: () => true,
34
+ apply: () => proxy,
35
+ };
36
+ const proxy: unknown = new Proxy(() => undefined, handler);
37
+ return proxy;
38
+ };
39
+
40
+ function recordingKit(tl: Timeline, calls: Call[]): object {
41
+ const { W, H } = tl;
42
+ const values: Readonly<Record<string, unknown>> = {
43
+ g: sink(), W, H, u: H / 1920, t: 0.6, p: 0.6, X: (x: number) => x * W, Y: (y: number) => y * H,
44
+ r: () => 0.5, rng: () => () => 0.5, noise1: () => 0.5, mix: () => "#808080", alpha: () => "#808080", pal: {}, beat: tl.beats[0] ?? {},
45
+ };
46
+ const record = (fn: string) => (o: unknown) => {
47
+ if (fn === "people" && Array.isArray(o)) o.forEach((x) => calls.push({ fn: "person", opts: plain(x) }));
48
+ else calls.push({ fn, opts: plain(o) });
49
+ };
50
+ return new Proxy({}, { get: (_t, key) => (typeof key !== "string" ? undefined : key in values ? values[key] : record(key)) });
51
+ }
52
+
53
+ const message = (e: unknown): string =>
54
+ typeof e === "object" && e !== null && "message" in e ? String(e.message) : String(e);
55
+
56
+ /** One probe per scene in scenes.js; a scene that throws (or runs past a second) reports that instead. */
57
+ export function probeScenes(dir: string, tl: Timeline): readonly Probe[] {
58
+ const assets = path.join(dir, "assets");
59
+ const names = fs.existsSync(assets) ? fs.readdirSync(assets).filter((f) => IMAGE.test(f)) : [];
60
+ // The page loads assets/ into IMAGES before the scenes run; a stand-in of the same shape keeps them honest here.
61
+ const IMAGES = Object.fromEntries(names.map((f) => [f.replace(/\.[^.]+$/, ""), { width: 512, height: 512 }]));
62
+ const ctx = vm.createContext({ window: { IMAGES }, IMAGES });
63
+ vm.runInContext(fs.readFileSync(path.join(dir, "scenes.js"), "utf8"), ctx, { filename: "scenes.js", timeout: 1000 });
64
+ const scenes: unknown = vm.runInContext("Object.keys(window.SCENES || {})", ctx);
65
+ if (!Array.isArray(scenes) || scenes.length === 0) throw new Error("scenes.js sets no window.SCENES");
66
+ return scenes.map(String).map((scene): Probe => {
67
+ const calls: Call[] = [];
68
+ try {
69
+ vm.runInContext(`window.SCENES[${JSON.stringify(scene)}](globalThis.__kit)`, Object.assign(ctx, { __kit: recordingKit(tl, calls) }), { timeout: 1000 });
70
+ return { scene, calls };
71
+ } catch (e) {
72
+ return { scene, calls, error: message(e) };
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,46 @@
1
+ // The soundtrack: every take at its moment on one voice track, the score, and the ducked mix.
2
+ import path from "node:path";
3
+ import { PRESETS, renderSoundtrack } from "../audio/soundtrack.ts";
4
+ import type { Short, Take, Timeline } from "../domain/types.ts";
5
+ import { mix, mkdir, pcm, SR, wav } from "./media.ts";
6
+
7
+ export const MOODS = ["tender", "hopeful", "night", "wonder", "playful"];
8
+ const LEAD_IN = 0.05;
9
+
10
+ type Placed = { readonly at: number; readonly data: Float32Array };
11
+
12
+ const place = (tl: Timeline) => (t: Take, i: number): readonly Placed[] => {
13
+ if (!t.file) return [];
14
+ const at = tl.voiceAt[i] ?? 0;
15
+ return t.slice
16
+ ? [{ at: Math.round((at - LEAD_IN) * SR), data: pcm(t.file, Math.max(0, (t.from ?? 0) - LEAD_IN), t.dur + 0.15) }]
17
+ : [{ at: Math.round(at * SR), data: pcm(t.file) }];
18
+ };
19
+
20
+ /** Sums the takes into one buffer; a take that runs past the end is cut there. */
21
+ function voiceTrack(tl: Timeline, takes: readonly Placed[]): Float32Array {
22
+ const track = new Float32Array(Math.ceil((tl.duration + 0.5) * SR));
23
+ takes.forEach(({ at, data }) => data.subarray(0, Math.max(0, track.length - at)).forEach((x, j) => { track[at + j] = (track[at + j] ?? 0) + x; }));
24
+ return track;
25
+ }
26
+
27
+ /** Returns the mixed WAV the encoder muxes in. */
28
+ export function soundtrack(dir: string, s: Short, tl: Timeline, takes: readonly Take[]): string {
29
+ const a = mkdir(dir, "build", "audio");
30
+ const placed = takes.flatMap(place(tl));
31
+ const voice = path.join(a, "voice.wav");
32
+ wav(voice, voiceTrack(tl, placed));
33
+ const m = s.music;
34
+ const own = !MOODS.includes(m.mood) && !(m.mood in PRESETS);
35
+ const music = m.mood === "none" ? null : path.join(a, "music.wav");
36
+ if (music) {
37
+ renderSoundtrack(
38
+ { duration: tl.duration, preset: own ? "tender" : m.mood, music: own ? path.resolve(dir, m.mood) : "auto", cues: [], outroAt: tl.duration - 1.5, musicStart: m.start },
39
+ music,
40
+ { seed: m.seed ?? 11 },
41
+ );
42
+ }
43
+ const hasVoice = placed.length > 0;
44
+ const volume = m.volume ?? (hasVoice ? 0.42 : 0.9);
45
+ return mix({ voice, music, hasVoice, volume, duration: tl.duration }, path.join(a, "mix.wav"));
46
+ }
@@ -0,0 +1,183 @@
1
+ // The narrator: Kokoro-82M in a private Python, or the person's own recording, timed by transcript.
2
+ // Everything is pinned and the model files are checked by sha256, so a setup is the same setup.
3
+ import { spawnSync } from "node:child_process";
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { words } from "../domain/captions.ts";
9
+ import { isMap } from "../domain/schema.ts";
10
+ import type { Json, Short, Take } from "../domain/types.ts";
11
+ import { duration, mkdir, run } from "./media.ts";
12
+
13
+ const HOME = path.join(os.homedir(), ".cache", "shorts-animator");
14
+ const VENV = path.join(HOME, "venv");
15
+ const PY = path.join(VENV, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
16
+ const TTS = path.join(import.meta.dirname, "..", "tts.py");
17
+ const PACKAGES = ["kokoro-onnx==0.6.1", "soundfile==0.14.0"];
18
+ export const HYPERFRAMES = "hyperframes@0.8.150";
19
+ const NPX = process.platform === "win32" ? "npx.cmd" : "npx";
20
+ const RELEASE = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0";
21
+ const MODEL = {
22
+ "kokoro-v1.0.onnx": "7d5df8ecf7d4b1878015a32686053fd0eebe2bc377234608764cc0ef3636a6c5",
23
+ "voices-v1.0.bin": "bca610b8308e8d99f32e6fe4197e7ec01679264efed0cac9140fe9c29f1fbf7d",
24
+ };
25
+ type ModelFile = keyof typeof MODEL;
26
+
27
+ export const hash = (...xs: readonly Json[]): string =>
28
+ crypto.createHash("sha1").update(xs.map((x) => JSON.stringify(x)).join("|")).digest("hex").slice(0, 10);
29
+
30
+ const sha256 = (f: string): string => crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex");
31
+
32
+ /** HyperFrames' cache first (it ships the same files), then ours. */
33
+ const modelFiles = (): readonly [string, string] | undefined => {
34
+ const hf = path.join(os.homedir(), ".cache", "hyperframes", "tts");
35
+ const pairs: readonly (readonly [string, string])[] = [
36
+ [path.join(hf, "models", "kokoro-v1.0.onnx"), path.join(hf, "voices", "voices-v1.0.bin")],
37
+ [path.join(HOME, "kokoro", "kokoro-v1.0.onnx"), path.join(HOME, "kokoro", "voices-v1.0.bin")],
38
+ ];
39
+ return pairs.find(([m, v]) => fs.existsSync(m) && fs.existsSync(v));
40
+ };
41
+
42
+ const pythonReady = (): boolean => fs.existsSync(PY) && spawnSync(PY, ["-I", "-c", "import kokoro_onnx, soundfile"]).status === 0;
43
+ export const narratorReady = (): boolean => pythonReady() && modelFiles() !== undefined;
44
+
45
+ const PYTHONS = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python", "py"];
46
+ const modernPython = (): string | undefined =>
47
+ PYTHONS.find((b) => spawnSync(b, ["-c", "import sys; print(sys.version_info >= (3, 10) and sys.version_info < (3, 14))"], { encoding: "utf8" }).stdout?.trim() === "True");
48
+
49
+ function installPython(log: (m: string) => void): string | undefined {
50
+ mkdir(HOME);
51
+ if (spawnSync("uv", ["--version"]).status === 0) {
52
+ log("· setting up the narrator's Python (uv)...");
53
+ spawnSync("uv", ["venv", "-q", "--allow-existing", "-p", "3.12", VENV], { stdio: "inherit" });
54
+ spawnSync("uv", ["pip", "install", "-q", "-p", PY, ...PACKAGES], { stdio: "inherit" });
55
+ } else {
56
+ const py = modernPython();
57
+ if (!py) return 'the narrator needs Python 3.10 to 3.13, or uv (https://docs.astral.sh/uv/). Or set voice to "none", or use your own recording';
58
+ log(`· setting up the narrator's Python (${py})...`);
59
+ spawnSync(py, ["-m", "venv", VENV], { stdio: "inherit" });
60
+ spawnSync(PY, ["-m", "pip", "install", "-q", ...PACKAGES], { stdio: "inherit" });
61
+ }
62
+ return pythonReady() ? undefined : `couldn't install ${PACKAGES.join(" ")} into ${VENV}`;
63
+ }
64
+
65
+ async function download(dir: string, f: ModelFile, log: (m: string) => void): Promise<string | undefined> {
66
+ log(`· downloading the voice model: ${f}...`);
67
+ const res = await fetch(`${RELEASE}/${f}`);
68
+ if (!res.ok) return `couldn't download ${f} (${res.status})`;
69
+ const part = path.join(dir, f + ".part");
70
+ fs.writeFileSync(part, Buffer.from(await res.arrayBuffer()));
71
+ if (sha256(part) !== MODEL[f]) {
72
+ fs.rmSync(part);
73
+ return `${f} failed its checksum; nothing was installed`;
74
+ }
75
+ fs.renameSync(part, path.join(dir, f));
76
+ return undefined;
77
+ }
78
+
79
+ /** Sets the narrator up. Returns undefined when ready, or what is missing. */
80
+ export async function setupNarrator(log: (m: string) => void = console.log): Promise<string | undefined> {
81
+ const py = pythonReady() ? undefined : installPython(log);
82
+ if (py || modelFiles()) return py;
83
+ const dir = mkdir(HOME, "kokoro");
84
+ const files: readonly ModelFile[] = ["voices-v1.0.bin", "kokoro-v1.0.onnx"];
85
+ return files.reduce<Promise<string | undefined>>(async (prev, f) => (await prev) ?? download(dir, f, log), Promise.resolve(undefined));
86
+ }
87
+
88
+ /* ------------------------------------------------------------------ takes */
89
+
90
+ const silent = (s: Short): readonly Take[] => s.lines.map((l) => ({ file: null, dur: Math.max(1.3, words(l.say).length * 0.36) }));
91
+
92
+ type Job = { readonly text: string; readonly file: string };
93
+
94
+ const kokoroJobs = (s: Short, vdir: string, id: string, speed: number): readonly (Job & { readonly fresh: boolean })[] =>
95
+ s.lines.map((l, i) => {
96
+ const text = (l.speak ?? l.say).replace(/\*|\|/g, "");
97
+ const n = String(i + 1).padStart(2, "0");
98
+ const file = path.join(vdir, `${n}-${hash(text, id, speed)}.wav`);
99
+ return { text, file, fresh: fs.existsSync(file) };
100
+ });
101
+
102
+ /** A line's text changed: its old take goes, so the folder never holds two takes of one line. */
103
+ const dropStale = (vdir: string, file: string): void => {
104
+ const prefix = path.basename(file).slice(0, 3);
105
+ fs.readdirSync(vdir).filter((f) => f.startsWith(prefix) && f !== path.basename(file)).forEach((f) => fs.rmSync(path.join(vdir, f)));
106
+ };
107
+
108
+ async function kokoro(s: Short, vdir: string, id: string, speed: number, quiet: boolean): Promise<readonly Take[]> {
109
+ const jobs = kokoroJobs(s, vdir, id, speed);
110
+ const todo = jobs.filter((j) => !j.fresh);
111
+ if (todo.length) {
112
+ const why = narratorReady() ? undefined : await setupNarrator();
113
+ const files = modelFiles();
114
+ if (why || !files) throw new Error(why ?? "no voice model");
115
+ todo.forEach((j) => dropStale(vdir, j.file));
116
+ if (!quiet) console.log(`· narrating ${todo.length} line${todo.length > 1 ? "s" : ""} with ${id} (Kokoro, on this computer)...`);
117
+ run(PY, ["-I", TTS, ...files, id, String(speed)], { input: JSON.stringify(todo.map((j) => [j.text, j.file])) });
118
+ }
119
+ return jobs.map((j) => ({ file: j.file, dur: duration(j.file) }));
120
+ }
121
+
122
+ type Word = { readonly start: number; readonly end: number };
123
+
124
+ /** Every {word|text, start, end} in a transcript, wherever the tool nested it. */
125
+ const pickWords = (o: Json | undefined): readonly Word[] => {
126
+ if (Array.isArray(o)) return o.flatMap(pickWords);
127
+ if (!isMap(o)) return [];
128
+ const { start, end } = o;
129
+ if ((o.word || o.text) && typeof start === "number" && typeof end === "number" && !o.words) return [{ start, end }];
130
+ return Object.values(o).flatMap(pickWords);
131
+ };
132
+
133
+ const parseTranscript = (raw: string): Json | undefined => {
134
+ try {
135
+ const v: unknown = JSON.parse(raw.slice(raw.indexOf("{")));
136
+ return isMap(v) ? v : undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ };
141
+
142
+ function transcriptWords(src: string, vdir: string, quiet: boolean): readonly Word[] {
143
+ const tj = path.join(vdir, `transcript-${hash(fs.statSync(src).mtimeMs, src)}.json`);
144
+ if (!fs.existsSync(tj)) {
145
+ if (!quiet) console.log("· transcribing your recording for the timing...");
146
+ fs.writeFileSync(tj, run(NPX, ["--yes", HYPERFRAMES, "transcribe", src, "--json", "-d", vdir], { cwd: vdir }));
147
+ }
148
+ const direct = pickWords(parseTranscript(fs.readFileSync(tj, "utf8")));
149
+ const side = fs.readdirSync(vdir).find((f) => f.endsWith("transcript.json") && !f.startsWith("transcript-"));
150
+ const ws = direct.length || !side ? direct : pickWords(parseTranscript(fs.readFileSync(path.join(vdir, side), "utf8")));
151
+ return ws.toSorted((a, b) => a.start - b.start);
152
+ }
153
+
154
+ /** Their own recording: each line takes the next run of words, in order. */
155
+ function ownRecording(s: Short, dir: string, vdir: string, file: string, quiet: boolean): readonly Take[] {
156
+ const src = path.resolve(dir, file);
157
+ if (!fs.existsSync(src)) throw new Error(`voice.file ${file} not found`);
158
+ const ws = transcriptWords(src, vdir, quiet);
159
+ if (!ws.length) throw new Error("couldn't read word timings from the transcript");
160
+ const word = (i: number): Word => ws[Math.min(i, ws.length - 1)] ?? { start: 0, end: 0 };
161
+ const firsts = s.lines.map((_, i) => s.lines.slice(0, i).reduce((n, l) => n + words(l.say).length, 0));
162
+ return s.lines.map((l, i) => {
163
+ const a = word(firsts[i] ?? 0);
164
+ const b = word((firsts[i] ?? 0) + words(l.say).length - 1);
165
+ return { file: src, from: a.start, dur: Math.max(0.6, b.end - a.start), slice: true };
166
+ });
167
+ }
168
+
169
+ export async function narrate(dir: string, s: Short, quiet = false): Promise<readonly Take[]> {
170
+ const vdir = mkdir(dir, "voice");
171
+ const v = s.voice;
172
+ if (v.kind === "none") return silent(s);
173
+ if (v.kind === "file") return ownRecording(s, dir, vdir, v.file, quiet);
174
+ return kokoro(s, vdir, v.id, v.speed, quiet);
175
+ }
176
+
177
+ /** Timing without narrating: cached Kokoro takes when every line has one, word-count estimates otherwise. */
178
+ export function cachedTakes(dir: string, s: Short): readonly Take[] {
179
+ const v = s.voice;
180
+ if (v.kind !== "kokoro") return silent(s);
181
+ const jobs = kokoroJobs(s, path.join(dir, "voice"), v.id, v.speed);
182
+ return jobs.every((j) => j.fresh) ? jobs.map((j) => ({ file: j.file, dur: duration(j.file) })) : silent(s);
183
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "Gouache storybook: flat matte shapes, crisp edges, a little brush texture and paper. Calm and graphic.",
3
+ "mode": "strokes", "brush": 6, "length": 2.2, "layers": 2, "swirl": 0.2, "flow": 300, "curve": 0.3,
4
+ "bristle": 0.35, "impasto": 0.1, "hueJitter": 0.05, "valueJitter": 0.07, "satJitter": 0.1, "respect": 1,
5
+ "boil": 0.3, "paper": 0.8, "grain": 0.35, "saturation": 1.02, "contrast": 1.02, "warmth": 0.1, "vignette": 0.15, "bloom": 0.05,
6
+ "captions": { "style": "box", "size": 44 }
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "about": "Ink and wash, like a graphic novel: wobbly hand-inked outlines that boil, flat washes of colour, paper.",
3
+ "mode": "wash", "wash": 0.5, "bleed": 0.3, "outline": 0.95, "ink": "#1b1712", "paper": 0.7, "paperColor": "#f4efe3",
4
+ "grain": 0.3, "boil": 1, "posterize": 0, "saturation": 0.85, "contrast": 1.05, "warmth": 0.1, "vignette": 0.1, "bloom": 0,
5
+ "captions": { "style": "box", "size": 44, "box": "rgba(27,23,18,0.8)" }
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "Oil paint on canvas: swirling strokes, deep saturated colour, a slow shimmer. The painted-shorts look.",
3
+ "mode": "strokes", "brush": 11, "length": 2.8, "layers": 3, "swirl": 0.7, "flow": 160, "curve": 0.9,
4
+ "bristle": 0.75, "impasto": 0.6, "hueJitter": 0.2, "valueJitter": 0.22, "satJitter": 0.35, "respect": 0.85,
5
+ "boil": 0.5, "paper": 0.6, "grain": 0.5, "saturation": 1.18, "contrast": 1.06, "warmth": 0.2, "vignette": 0.35, "bloom": 0.3,
6
+ "captions": { "style": "box", "size": 44, "y": 0.775 }
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "about": "Soft pastel / crayon on toned paper: straight chalky hatching, paper tooth everywhere, pale gentle colour.",
3
+ "mode": "strokes", "brush": 6, "length": 4.6, "layers": 3, "swirl": 0.05, "flow": 600, "curve": 0,
4
+ "bristle": 1, "impasto": 0, "hueJitter": 0.1, "valueJitter": 0.3, "satJitter": 0.2, "respect": 0.5,
5
+ "boil": 0.8, "paper": 1.6, "grain": 0.8, "saturation": 0.82, "contrast": 0.9, "warmth": 0.35, "vignette": 0.2, "bloom": 0.1,
6
+ "captions": { "style": "box", "size": 44 }
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "about": "Watercolour on cold-press paper: soft bleeding washes, pigment pooled at the edges, granulation, light and airy.",
3
+ "mode": "wash", "wash": 0.9, "bleed": 0.8, "paper": 0.9, "paperColor": "#f6f1e6", "grain": 0.25, "boil": 0.4,
4
+ "outline": 0, "saturation": 0.92, "contrast": 0.96, "warmth": 0.1, "vignette": 0.12, "bloom": 0.1,
5
+ "captions": { "style": "box", "size": 44, "box": "rgba(34,38,58,0.58)" }
6
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "module"
4
+ }