@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

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 (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/audio-voice.js ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Generate a voice, from the audio workspace.
3
+ *
4
+ * The form offers the engines this machine can use, and what each offers follows the keys it holds:
5
+ * ElevenLabs appears when a key is set, lists the voices that key may read, and says how many credits
6
+ * are left. Piper is the voice-narration skill's local voice. The dashboard decides nothing about
7
+ * either: `audio/cmd-tools-voice.sh` does the work, the same command a person runs by hand.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execFile } = require('child_process');
13
+
14
+ const { esc } = require('./html');
15
+ const { ROOT, workspaceDir } = require('./workspaces');
16
+ const transcripts = require('./transcripts');
17
+ const { VIDEO_RE } = require('./format');
18
+
19
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
20
+ const MAX_TEXT = 5000;
21
+ const MAX_SCRIPT = 60000;
22
+ const MAX_PROMPT = 1000;
23
+ const HEADING = /^##\s+(\S+)\s+(.+?)\s+\(((?:\d+:)?\d{1,2}:\d{2}(?:\.\d+)?)\s+to\s+((?:\d+:)?\d{1,2}:\d{2}(?:\.\d+)?)\)\s*$/;
24
+ const SPEECH_CHARS_PER_SECOND = 15;
25
+
26
+ const script = () => path.join(workspaceDir('audio'), 'cmd-tools-voice.sh');
27
+
28
+ /** What the command says about each engine, as parsed JSON; `error` when it could not be asked. */
29
+ function loadEngines() {
30
+ return new Promise((resolve) => {
31
+ execFile('bash', [script(), '-a', 'engines', '--json'],
32
+ { cwd: workspaceDir('audio'), timeout: 45000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
33
+ if (err) return resolve({ engines: [], error: String(stderr || err.message).trim().slice(-300) });
34
+ try { resolve({ engines: JSON.parse(stdout).engines || [] }); } catch (_) { resolve({ engines: [], error: 'The voice command answered with something unreadable.' }); }
35
+ });
36
+ });
37
+ }
38
+
39
+ /**
40
+ * The engines the form offers. Piper runs on the host through the screen runner, and from the
41
+ * dashboard's container its binary and models are not visible, so it is offered whenever the
42
+ * voice-narration catalog is, with the models checked when it runs on the host.
43
+ */
44
+ function offered(engines) {
45
+ return engines.map((e) => {
46
+ if (e.id === 'piper' && !e.available && ['no-binary', 'no-voices'].includes(e.reason_code) && e.voices.length) {
47
+ return { ...e, available: true, hostOnly: true, formats: ['wav', 'mp3'] };
48
+ }
49
+ return e;
50
+ });
51
+ }
52
+
53
+
54
+ /** What the video mode can pick from: the recordings and the scripts already in the workspace. */
55
+ function choices() {
56
+ const list = (key, keep) => {
57
+ const dir = workspaceDir(key);
58
+ try {
59
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && keep(e.name))
60
+ .map((e) => ({ name: e.name, mtime: fs.statSync(path.join(dir, e.name)).mtimeMs }))
61
+ .sort((a, b) => b.mtime - a.mtime).slice(0, 80).map((e) => e.name);
62
+ } catch (_) { return []; }
63
+ };
64
+ return {
65
+ videos: list('videos', (n) => VIDEO_RE.test(n) && !n.startsWith('.')),
66
+ scripts: list('docs', (n) => /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(n) && /(voiceover|script|transcript)/i.test(n)),
67
+ };
68
+ }
69
+
70
+ const seconds = (stamp) => stamp.split(':').reduce((t, part) => t * 60 + parseFloat(part), 0);
71
+
72
+ /** The spoken lines of a script, by section. A transcript with no timed headings is one untimed block. */
73
+ function parseScript(text) {
74
+ const clean = (line) => line.replace(/^>\s?/, '').replace(/\*\(optional\)\*\s*/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').trim();
75
+ const lines = String(text).split('\n');
76
+ if (!lines.some((l) => HEADING.test(l))) {
77
+ const spoken = lines.map((l) => l.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+|>\s*)/, '').trim()).filter((l) => l && !l.startsWith('#'));
78
+ return { timed: false, sections: [], characters: spoken.join(' ').length };
79
+ }
80
+ const sections = [];
81
+ for (const line of lines) {
82
+ const h = line.match(HEADING);
83
+ if (h) { sections.push({ id: h[1], title: h[2], window: seconds(h[4]) - seconds(h[3]), chars: 0 }); continue; }
84
+ if (sections.length && line.startsWith('> ')) {
85
+ const t = clean(line);
86
+ sections[sections.length - 1].chars += t.length + (sections[sections.length - 1].chars ? 1 : 0);
87
+ }
88
+ }
89
+ return { timed: true, sections, characters: sections.reduce((n, x) => n + x.chars, 0) };
90
+ }
91
+
92
+ /** A file name not taken in `dir`. */
93
+ function freeNameIn(dir, base, ext) {
94
+ let name = base;
95
+ for (let n = 2; fs.existsSync(path.join(dir, `${name}.${ext}`)); n += 1) name = `${base}-${n}`;
96
+ return `${name}.${ext}`;
97
+ }
98
+
99
+ const slugOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
100
+
101
+ /** A file name in audio/voices/ that is not taken. */
102
+ function freeName(base, ext) {
103
+ const dir = path.join(workspaceDir('audio'), 'voices');
104
+ let name = base;
105
+ for (let n = 2; fs.existsSync(path.join(dir, `${name}.${ext}`)); n += 1) name = `${base}-${n}`;
106
+ return `${name}.${ext}`;
107
+ }
108
+
109
+ /** `piper:<id>` or `elevenlabs:<id>`, the value of the voice dropdown, or null. */
110
+ function splitChoice(value) {
111
+ const found = String(value || '').match(/^(piper|elevenlabs):(.*)$/);
112
+ return found ? { engine: found[1], id: found[2] } : null;
113
+ }
114
+
115
+ /**
116
+ * The one voice dropdown: ElevenLabs voices first (what the key may read), then Piper's. `selected` is the
117
+ * value to preselect: the configured ElevenLabs default when there is one, else the first Piper voice.
118
+ */
119
+ function voiceChoices(engines) {
120
+ const piper = engines.find((e) => e.id === 'piper' && e.available);
121
+ const eleven = engines.find((e) => e.id === 'elevenlabs' && e.available);
122
+ const els = !eleven ? [] : eleven.can_list_voices
123
+ ? eleven.voices.map((v) => [`elevenlabs:${v.id}`, voiceLabel(v)])
124
+ : [[`elevenlabs:${eleven.default_voice || ''}`, eleven.default_voice ? `Voice ${eleven.default_voice}` : 'ElevenLabs: paste a voice id below']];
125
+ const pipers = !piper ? [] : piper.voices.filter((v) => v.installed || piper.hostOnly).map((v) => [`piper:${v.id}`, `${v.number ? `${v.number}. ` : ''}${v.id} — ${v.label}`]);
126
+ const preferred = eleven && eleven.default_voice ? `elevenlabs:${eleven.default_voice}` : '';
127
+ const selected = [preferred, (pipers[0] || [])[0], (els[0] || [])[0]].find((v) => v && [...els, ...pipers].some((o) => o[0] === v)) || '';
128
+ const group = (label, items) => (items.length ? `<optgroup label="${esc(label)}">${items.map((o) => option(o[0], o[1], o[0] === selected)).join('')}</optgroup>` : '');
129
+ const credits = eleven && eleven.remaining != null ? ` · ${Number(eleven.remaining).toLocaleString('en-US')} left on the ${eleven.plan} plan` : '';
130
+ return { html: `${group(`ElevenLabs — uses credits${credits}`, els)}${group('Piper — free, on this machine', pipers)}`, selected, count: els.length + pipers.length };
131
+ }
132
+
133
+ /** The voice and model a form chose for an engine, or `{ error }`. */
134
+ function resolveVoice(form, engine) {
135
+ if (engine.id === 'piper') {
136
+ const voice = String(form.piper_voice || '');
137
+ if (!engine.voices.some((v) => v.id === voice)) return { error: 'Choose one of the Piper voices.' };
138
+ return { voice, model: '' };
139
+ }
140
+ const voice = String(form.el_voice_id || form.el_voice || '').trim();
141
+ if (!ID_RE.test(voice)) return { error: 'Choose a voice, or give a voice id.' };
142
+ if (engine.can_list_voices && !engine.voices.some((v) => v.id === voice)) return { error: 'That voice is not on this account.' };
143
+ // A model typed in beside the list wins: ElevenLabs adds models faster than a list can follow.
144
+ const other = String(form.el_model_other || '').trim();
145
+ if (other) return ID_RE.test(other) ? { voice, model: other } : { error: 'That is not a model id: letters, digits, dots, dashes and underscores.' };
146
+ const model = String(form.el_model || engine.default_model || '');
147
+ if (!ID_RE.test(model) || !engine.models.some((m) => m.id === model)) return { error: 'Choose one of the models.' };
148
+ return { voice, model };
149
+ }
150
+
151
+ /**
152
+ * What a submitted form asks for, checked against the engines on offer. Returns `{ error }` or the
153
+ * request with its output file; nothing is written or run here. `kind` is a text spoken to a file, or
154
+ * (`video`) a script spoken over a video and saved as a new video.
155
+ */
156
+ function planSound(form, engines) {
157
+ const music = form.kind === 'music';
158
+ const text = String(form.text || '').replace(/\r\n/g, '\n').replace(/\0/g, '').trim();
159
+ if (!text) return { error: music ? 'Describe the music.' : 'Describe the sound.' };
160
+ if (text.length > MAX_PROMPT) return { error: `That is ${text.length} characters; a description takes at most ${MAX_PROMPT}.` };
161
+ const engine = offered(engines).find((e) => e.id === 'elevenlabs');
162
+ if (!engine || !engine.available) return { error: `ElevenLabs is not available: ${(engine && engine.reason) || 'no key is set'}` };
163
+ const name = slugOf(form.name);
164
+ const base = name || `${slugOf(text.split(/\s+/).slice(0, 4).join(' ')) || (music ? 'music' : 'sound')}-${music ? 'music' : 'sound'}`;
165
+ const out = { engine: 'elevenlabs', kind: form.kind, text, characters: text.length, ext: 'mp3', voice: '', model: '', name,
166
+ file: freeNameIn(path.join(workspaceDir('audio'), 'sounds'), base, 'mp3') };
167
+ if (music) {
168
+ const length = parseFloat(form.length || '30');
169
+ if (!(length >= 3 && length <= 600)) return { error: 'Music runs from 3 to 600 seconds.' };
170
+ return { ...out, length, instrumental: form.instrumental === 'yes' };
171
+ }
172
+ const duration = form.duration && form.duration !== 'auto' ? parseFloat(form.duration) : null;
173
+ if (duration !== null && !(duration >= 0.5 && duration <= 30)) return { error: 'A sound runs from 0.5 to 30 seconds.' };
174
+ const influence = form.influence === undefined || form.influence === '' ? 0.3 : parseFloat(form.influence);
175
+ if (!(influence >= 0 && influence <= 1)) return { error: 'How closely to follow the description is between 0 and 1.' };
176
+ return { ...out, duration, influence, loop: form.loop === 'yes' };
177
+ }
178
+
179
+ function plan(form, engines) {
180
+ if (form.kind === 'sound' || form.kind === 'music') return planSound(form, engines);
181
+ const video = form.kind === 'video';
182
+ const text = String(form.text || '').replace(/\r\n/g, '\n').replace(/\0/g, '').trim();
183
+ if (!video) {
184
+ if (!text) return { error: 'Type what the voice should say.' };
185
+ if (text.length > MAX_TEXT) return { error: `That is ${text.length} characters; one voice takes at most ${MAX_TEXT}.` };
186
+ }
187
+ const picked = splitChoice(form.voice_choice);
188
+ const asked = picked ? { ...form, engine: picked.engine, ...(picked.engine === 'piper' ? { piper_voice: picked.id } : { el_voice: picked.id }) } : form;
189
+ const engine = offered(engines).find((e) => e.id === asked.engine);
190
+ if (!engine) return { error: 'Choose a voice.' };
191
+ if (!engine.available) return { error: `${engine.label} is not available: ${engine.reason}` };
192
+ const chosen = resolveVoice(asked, engine);
193
+ if (chosen.error) return chosen;
194
+ const name = slugOf(form.name);
195
+ const common = { engine: engine.id, voice: chosen.voice, model: chosen.model, name };
196
+ if (!video) {
197
+ const ext = engine.id === 'piper' ? ((engine.formats || ['wav']).includes(form.piper_format) ? form.piper_format : 'wav') : 'mp3';
198
+ const base = name || `${slugOf(text.split(/\s+/).slice(0, 4).join(' ')) || 'voice'}-${engine.id}`;
199
+ return { ...common, kind: 'text', text, ext, characters: text.length, file: freeName(base, ext) };
200
+ }
201
+ const have = choices();
202
+ const clip = String(form.video || '');
203
+ if (!have.videos.includes(clip)) return { error: 'Choose a video.' };
204
+ // A script chosen from the list wins; pasted text is used only when none is chosen.
205
+ const scriptName = String(form.script_file || '');
206
+ let script = text;
207
+ let scriptPath = null;
208
+ if (scriptName) {
209
+ if (!have.scripts.includes(scriptName)) return { error: 'Choose a script, or paste one.' };
210
+ scriptPath = path.join(workspaceDir('docs'), scriptName);
211
+ try { script = fs.readFileSync(scriptPath, 'utf8'); } catch (_) { return { error: 'That script cannot be read.' }; }
212
+ } else if (!script) {
213
+ return { error: 'Choose a script, or paste one.' };
214
+ }
215
+ if (script.length > MAX_SCRIPT) return { error: `That script is ${script.length} characters; the limit is ${MAX_SCRIPT}.` };
216
+ const parsed = parseScript(script);
217
+ if (!parsed.characters) return { error: 'The script has nothing to say.' };
218
+ const stem = clip.replace(/\.[^.]+$/, '');
219
+ const base = name || `${slugOf(stem)}-${engine.id}-voice`;
220
+ return { ...common, kind: 'video', ext: 'mp4', video: clip, script, scriptPath, scriptName, scriptLabel: scriptName || 'pasted text', parsed, characters: parsed.characters,
221
+ file: freeNameIn(workspaceDir('videos'), base, 'mp4') };
222
+ }
223
+
224
+ /** The line the host runs. Every value in it has passed ID_RE or is a path this module made. */
225
+ function commandLine(p, textFile, confirm) {
226
+ const q = (a) => JSON.stringify(a);
227
+ let args;
228
+ let out;
229
+ if (p.kind === 'sound' || p.kind === 'music') {
230
+ out = path.join(workspaceDir('audio'), 'sounds', p.file);
231
+ args = ['-a', p.kind, '--text-file', textFile, '--out', out];
232
+ if (p.kind === 'music') args.push('--length', String(p.length), ...(p.instrumental ? ['--instrumental'] : []));
233
+ else args.push('--influence', String(p.influence), ...(p.duration !== null ? ['--duration', String(p.duration)] : []), ...(p.loop ? ['--loop'] : []));
234
+ if (confirm) args.push('--confirm');
235
+ } else if (p.kind === 'video') {
236
+ out = path.join(workspaceDir('videos'), p.file);
237
+ args = ['-a', 'voiceover', '--engine', p.engine, '--script', p.scriptPath || textFile,
238
+ '--video', path.join(workspaceDir('videos'), p.video), '--out', out, '--voice', p.voice];
239
+ } else {
240
+ out = path.join(workspaceDir('audio'), 'voices', p.file);
241
+ args = ['-a', 'speak', '--engine', p.engine, '--text-file', textFile, '--out', out, '--voice', p.voice];
242
+ }
243
+ if (p.model) args.push('--model', p.model);
244
+ if (p.kind !== 'sound' && p.kind !== 'music' && confirm && (p.engine === 'elevenlabs' || p.kind === 'video')) args.push('--confirm');
245
+ const run = `bash cmd-tools-voice.sh ${args.map(q).join(' ')}`;
246
+ const made = `mkdir -p ${q(path.dirname(out))}`;
247
+ if (!textFile) return `${made} && ${run}`;
248
+ // The text file is removed whether it worked or not, and the exit status is the command's own.
249
+ const dir = path.dirname(textFile);
250
+ return `${made} && { ${run} && rm -rf ${q(dir)} || { rm -rf ${q(dir)}; false; }; }`;
251
+ }
252
+
253
+ /**
254
+ * The text goes through a file in a private directory, never through a command line. The directory is
255
+ * under the workspace, not /tmp: the dashboard runs in a container and the command on the host, and
256
+ * the workspace is the one place both see.
257
+ */
258
+ function writeText(text) {
259
+ const parent = path.join(ROOT, '.screens', 'voice-text');
260
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
261
+ const dir = fs.mkdtempSync(path.join(parent, 'v-'));
262
+ const file = path.join(dir, 'text.txt');
263
+ fs.writeFileSync(file, `${text}\n`, { mode: 0o600 });
264
+ return file;
265
+ }
266
+
267
+ const option = (value, label, selected) => `<option value="${esc(value)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
268
+
269
+ function voiceLabel(v) {
270
+ const l = v.labels || {};
271
+ const about = [l.gender, l.accent, l.descriptive].filter(Boolean).join(', ');
272
+ return `${v.label}${about ? ` — ${about}` : ''}`;
273
+ }
274
+
275
+
276
+ /** The form for a sound effect or a piece of music: a description, and what ElevenLabs lets you set. */
277
+ function soundFormHtml(result, mode) {
278
+ const music = mode === 'music';
279
+ const eleven = offered(result.engines).find((e) => e.id === 'elevenlabs');
280
+ const title = music ? 'Generate music' : 'Generate a sound';
281
+ if (!eleven || !eleven.available) {
282
+ return `
283
+ <div class="uk-card uk-card-default uk-card-body uk-margin-top editor-card">
284
+ <h3 class="uk-margin-small-bottom">${title}</h3>
285
+ <div class="msg error">${music ? 'Music' : 'Sounds'} come from ElevenLabs, which is not available: ${esc((eleven && eleven.reason) || 'no key is set')}</div>
286
+ <p class="uk-text-meta">Add an ElevenLabs key at /settings. It needs permission for ${music ? 'music' : 'sound effects'}.</p>
287
+ <button type="button" class="uk-button uk-button-default uk-modal-close">Close</button>
288
+ </div>`;
289
+ }
290
+ // The label sits beside the control, not on it, so the control needs its own name.
291
+ const NAMES = { duration: 'Length of the sound', influence: 'How closely to follow the description', length: 'Length of the music' };
292
+ const choices = (name, values, label, selected) => `<select class="uk-select" name="${name}" aria-label="${esc(NAMES[name] || name)}">${values.map((v) => option(v[0], v[1], v[0] === selected)).join('')}</select>`;
293
+ const options = music
294
+ ? `<div class="uk-grid-small" uk-grid>
295
+ <div class="uk-width-1-3@s"><label class="uk-form-label">Length</label>${choices('length', [['10', '10 seconds'], ['15', '15 seconds'], ['30', '30 seconds'], ['60', '1 minute'], ['120', '2 minutes'], ['180', '3 minutes']], '', '30')}</div>
296
+ <div class="uk-width-2-3@s uk-flex uk-flex-bottom"><label><input class="uk-checkbox" type="checkbox" name="instrumental" value="yes" checked> Instrumental, no vocals</label></div>
297
+ </div>`
298
+ : `<div class="uk-grid-small" uk-grid>
299
+ <div class="uk-width-1-3@s"><label class="uk-form-label">Length</label>${choices('duration', [['auto', 'Chosen by ElevenLabs'], ['1', '1 second'], ['2', '2 seconds'], ['3', '3 seconds'], ['5', '5 seconds'], ['10', '10 seconds'], ['15', '15 seconds'], ['30', '30 seconds']], '', 'auto')}</div>
300
+ <div class="uk-width-1-3@s"><label class="uk-form-label">Follow the description</label>${choices('influence', [['0.1', 'Loosely'], ['0.3', 'Normally'], ['0.7', 'Closely']], '', '0.3')}</div>
301
+ <div class="uk-width-1-3@s uk-flex uk-flex-bottom"><label><input class="uk-checkbox" type="checkbox" name="loop" value="yes"> A seamless loop</label></div>
302
+ </div>`;
303
+ return `
304
+ <div class="uk-card uk-card-default uk-card-body uk-margin-top editor-card">
305
+ <h3 class="uk-margin-small-bottom">${title}</h3>
306
+ <p class="uk-text-meta">Saved in <code>audio/sounds/</code> with its description and a record of how it was made. ${eleven.remaining != null ? `${Number(eleven.remaining).toLocaleString('en-US')} credits left on the ${esc(eleven.plan)} plan.` : ''}</p>
307
+ <form id="voice-form" class="voice-form" hx-post="/actions/generate-voice" hx-target="#voice-result" hx-swap="innerHTML">
308
+ <input type="hidden" name="workspace" value="audio">
309
+ <input type="hidden" name="kind" value="${music ? 'music' : 'sound'}">
310
+ <input type="hidden" name="engine" value="elevenlabs">
311
+ <textarea class="uk-textarea" name="text" rows="4" maxlength="${MAX_PROMPT}" required data-mic="1" data-voice-text
312
+ placeholder="${music ? 'Describe the music: mood, instruments, tempo, what it is for.' : 'Describe the sound: what makes it, where, how.'}"></textarea>
313
+ <p class="uk-text-meta uk-margin-remove-top"><span data-voice-count>0</span> of ${MAX_PROMPT} characters</p>
314
+ <input class="uk-input uk-margin-small-bottom" name="name" placeholder="file name (optional)" pattern="[A-Za-z0-9 _.\\-]{0,60}">
315
+ ${options}
316
+ <p class="uk-text-meta uk-margin-small-top">Spends ElevenLabs credits, by the length of what it makes, and needs the ${music ? 'music' : 'sound effects'} permission on the key. You are asked before anything is sent.</p>
317
+ <div class="uk-margin-small-top">
318
+ <button type="button" class="uk-button uk-button-primary arm-step" hx-post="/actions/generate-voice" hx-include="#voice-form" hx-vals='{"confirm":"yes"}'
319
+ hx-target="#voice-result" hx-swap="innerHTML" hx-trigger="confirmed-remove" title="${music ? 'Generate music' : 'Generate a sound'} with ElevenLabs, which spends credits"><span uk-icon="icon: ${music ? 'play' : 'bell'}; ratio: .8"></span> ${music ? 'Generate music' : 'Generate the sound'}</button>
320
+ <button type="button" class="uk-button uk-button-default uk-modal-close">Close</button>
321
+ </div>
322
+ </form>
323
+ <div id="voice-result" class="uk-margin-top"></div>
324
+ </div>`;
325
+ }
326
+
327
+ /** The Generate form, from what the command found. */
328
+ function formHtml(result, mode = 'voice', preset = {}) {
329
+ if (mode === 'sound' || mode === 'music') return soundFormHtml(result, mode);
330
+ const video = mode === 'video';
331
+ const engines = offered(result.engines);
332
+ const have = choices();
333
+ const choice = voiceChoices(engines);
334
+ const first = engines.find((e) => e.id === (choice.selected.split(':')[0] || ''));
335
+ const tag = (id) => (id === (first && first.id) ? '' : ' hidden');
336
+ const piper = engines.find((e) => e.id === 'piper');
337
+ const eleven = engines.find((e) => e.id === 'elevenlabs');
338
+ const off = engines.filter((e) => !e.available).map((e) => `<p class="uk-text-meta uk-margin-remove"><strong>${esc(e.label)}</strong> is not available: ${esc(e.reason)}</p>`).join('');
339
+ const picker = `
340
+ <div class="uk-grid-small" uk-grid><div class="uk-width-expand@s"><label class="uk-form-label" for="voice_choice">Voice</label>
341
+ <select class="uk-select" id="voice_choice" name="voice_choice"${choice.count ? '' : ' disabled'}>${choice.html}</select></div></div>${off}`;
342
+
343
+ const piperPane = piper && piper.available ? `
344
+ <div data-engine-pane="piper"${tag('piper')}>
345
+ <div class="uk-grid-small" uk-grid>
346
+ <div class="uk-width-small@s"><label class="uk-form-label" for="piper_format">Format</label>
347
+ <select class="uk-select" id="piper_format" name="piper_format">${(piper.formats || ['wav']).map((f) => option(f, f.toUpperCase(), f === 'wav')).join('')}</select></div>
348
+ </div>
349
+ <p class="uk-text-meta uk-margin-small-top">${piper.hostOnly ? 'Runs on your machine through the host runner, with the voices the voice-narration skill installed there.' : 'Runs here, offline.'}</p>
350
+ </div>` : '';
351
+
352
+ const elevenPane = eleven && eleven.available ? `
353
+ <div data-engine-pane="elevenlabs"${tag('elevenlabs')}>
354
+ <div class="uk-grid-small" uk-grid>
355
+ ${eleven.can_list_voices ? '' : `<div class="uk-width-expand@s"><label class="uk-form-label" for="el_voice_id">Voice id</label>
356
+ <input class="uk-input" id="el_voice_id" name="el_voice_id" placeholder="voice id" pattern="[A-Za-z0-9][A-Za-z0-9_.\\-]{0,79}" value="${esc(eleven.default_voice || '')}"></div>`}
357
+ <div class="uk-width-expand@s"><label class="uk-form-label" for="el_model">Model</label>
358
+ <select class="uk-select" id="el_model" name="el_model">${eleven.models.map((m) => option(m.id, m.label, m.id === eleven.default_model)).join('')}</select></div>
359
+ <div class="uk-width-medium@s"><label class="uk-form-label" for="el_model_other">Or another model id</label>
360
+ <input class="uk-input" id="el_model_other" name="el_model_other" placeholder="eleven_…" pattern="[A-Za-z0-9][A-Za-z0-9_.\\-]{0,79}"></div>
361
+ </div>
362
+ <p class="uk-text-meta uk-margin-small-top">Spends ElevenLabs credits. ${eleven.can_list_voices ? '' : 'This key cannot list voices: paste a voice id from your ElevenLabs account. '}${eleven.can_read_account ? '' : 'This key cannot read the account, so the credits left are not shown. '}You are asked before anything is sent.</p>
363
+ </div>` : '';
364
+
365
+ const none = !first ? '<div class="msg error">No engine is available. Add an ElevenLabs key at /settings, or install the voice-narration skill.</div>' : '';
366
+ const failed = result.error ? `<div class="msg error">Could not ask the voice command: ${esc(result.error)}</div>` : '';
367
+
368
+ return `
369
+ <div class="uk-card uk-card-default uk-card-body uk-margin-top editor-card">
370
+ ${preset.head || `<div class="modal-head"><h3 class="uk-margin-small-bottom">${video ? 'Voice over a video' : 'Generate a voice'}</h3></div>`}
371
+ ${failed}${none}
372
+ <form id="voice-form" class="voice-form" hx-post="/actions/generate-voice" hx-target="#voice-result" hx-swap="innerHTML">
373
+ <input type="hidden" name="workspace" value="audio">
374
+ <input type="hidden" name="kind" value="${video ? 'video' : 'text'}">
375
+ ${video ? `<div data-mode-pane="video">
376
+ <div class="uk-grid-small" uk-grid>
377
+ <div class="uk-width-1-2@s"><label class="uk-form-label" for="voice_video">Video</label>
378
+ <select class="uk-select" id="voice_video" name="video">${have.videos.map((v, i) => option(v, v, preset.video ? v === preset.video : !i)).join('')}</select></div>
379
+ <div class="uk-width-1-2@s"><label class="uk-form-label" for="voice_script">Script</label>
380
+ <select class="uk-select" id="voice_script" name="script_file">${option('', '— paste one below —', false)}${have.scripts.map((v) => option(v, v, v === preset.script)).join('')}</select></div>
381
+ </div>
382
+ <p class="uk-text-meta uk-margin-small-top">${have.videos.length ? 'A timed voiceover script puts each section in its window. Any other transcript is spread over the whole video. The original video is not changed.' : 'There is no video in the videos workspace yet.'}</p>
383
+ </div>` : ''}
384
+ <textarea class="uk-textarea" name="text" rows="5" maxlength="${video ? MAX_SCRIPT : MAX_TEXT}" data-mic="1" data-voice-text
385
+ placeholder="${video ? 'Paste a voiceover script or a transcript here, or choose one above.' : 'What should the voice say?'}"></textarea>
386
+ <p class="uk-text-meta uk-margin-remove-top"><span data-voice-count>0</span> characters</p>
387
+ <input class="uk-input uk-margin-small-bottom" name="name" placeholder="name of the new file (optional)" pattern="[A-Za-z0-9 _.\\-]{0,60}">
388
+ ${picker}
389
+ ${piperPane}${elevenPane}
390
+ ${preset.ask || ''}
391
+ <div class="modal-actions">
392
+ ${piper && piper.available ? `<button type="submit" class="uk-button uk-button-primary" data-engine-button="piper" data-mode-button="text"${tag('piper')}${video ? ' hidden' : ''}><span uk-icon="icon: microphone; ratio: .8"></span> Generate</button>` : ''}
393
+ ${eleven && eleven.available ? `<button type="button" class="uk-button uk-button-primary arm-step" data-engine-button="elevenlabs" data-mode-button="text"${tag('elevenlabs')}${video ? ' hidden' : ''}
394
+ hx-post="/actions/generate-voice" hx-include="#voice-form" hx-vals='{"confirm":"yes"}' hx-target="#voice-result" hx-swap="innerHTML"
395
+ hx-trigger="confirmed-remove" title="Generate a voice with ElevenLabs, which spends credits"><span uk-icon="icon: microphone; ratio: .8"></span> Generate with ElevenLabs</button>` : ''}
396
+ ${video ? `<button type="button" class="uk-button uk-button-primary" data-plan-button hx-post="/actions/voiceover-plan" hx-include="#voice-form"
397
+ hx-target="#voice-plan" hx-swap="innerHTML"${have.videos.length ? '' : ' disabled'}><span uk-icon="icon: list; ratio: .8"></span> Check the plan</button>` : ''}
398
+ ${preset.askButton || ''}
399
+ <button type="button" class="uk-button uk-button-default uk-modal-close">Close</button>
400
+ </div>
401
+ </form>
402
+ <div id="voice-plan" class="uk-margin-top"></div>
403
+ <div id="voice-result" class="uk-margin-top"></div>
404
+ </div>`;
405
+ }
406
+
407
+ // ---- generating, inside the modal --------------------------------------------------------------------
408
+
409
+ const KIND_DIR = { text: ['audio', 'voices'], sound: ['audio', 'sounds'], music: ['audio', 'sounds'], video: ['videos', ''] };
410
+ const FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
411
+ const SESSION_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
412
+ const GRACE_MS = 4000;
413
+ const GIVE_UP_MS = 15 * 60 * 1000;
414
+
415
+ /** The last few lines of the session's log, without terminal escapes. */
416
+ function logTail(session) {
417
+ try {
418
+ const raw = fs.readFileSync(path.join(ROOT, '.screens', `${session}.log`), 'utf8').slice(-4000);
419
+ return raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean).slice(-4)
420
+ .map((l) => l.slice(0, 220)).join('\n');
421
+ } catch (_) { return ''; }
422
+ }
423
+
424
+ /** Where a generation stands: running, done (the file is there with its record) or failed (the session is gone, the file is not). */
425
+ function generationState(q) {
426
+ const where = KIND_DIR[q.kind];
427
+ if (!where || !FILE_RE.test(String(q.file || '')) || !SESSION_RE.test(String(q.session || ''))) return null;
428
+ const dir = where[1] ? path.join(workspaceDir(where[0]), where[1]) : workspaceDir(where[0]);
429
+ const file = String(q.file);
430
+ const stem = file.replace(/\.[^.]+$/, '');
431
+ const age = Date.now() - (parseInt(q.t0, 10) || Date.now());
432
+ const { hostSessionsFile } = require('./jobs');
433
+ const hosts = hostSessionsFile();
434
+ const running = hosts ? (hosts.sessions || []).some((s) => s.session === q.session) : null;
435
+ const made = fs.existsSync(path.join(dir, file));
436
+ const record = fs.existsSync(path.join(dir, `${stem}.json`));
437
+ const base = { dir, where, file, stem, age, log: logTail(q.session) };
438
+ if (made && (q.kind === 'video' ? (running === false || (running === null && age > 60000)) : (record || running === false))) return { ...base, status: 'done' };
439
+ if (!made && running === false && age > GRACE_MS) return { ...base, status: 'failed' };
440
+ if (age > GIVE_UP_MS) return { ...base, status: 'failed', slow: true };
441
+ return { ...base, status: 'running' };
442
+ }
443
+
444
+ const ENGINE_LABELS = { piper: 'Piper', elevenlabs: 'ElevenLabs' };
445
+ const generationUrl = (q) => `/fragments/audio/generation?${new URLSearchParams({ kind: q.kind, file: q.file, session: q.session, t0: String(q.t0), engine: q.engine || '', edit: q.edit || '' })}`;
446
+
447
+ /** The answer to pressing Generate: the modal says it is working, and asks again every second. */
448
+ function progressHtml(q, state) {
449
+ const label = ENGINE_LABELS[q.engine] || 'the engine';
450
+ const secs = state ? Math.round(state.age / 1000) : 0;
451
+ return `
452
+ <div class="voice-progress" hx-get="${esc(generationUrl(q))}" hx-trigger="load delay:1s" hx-swap="outerHTML" hx-target="this">
453
+ <p><span uk-spinner="ratio: .7"></span> Making <strong>${esc(q.file)}</strong>${q.edit ? '' : ` with ${esc(label)}`} <span class="uk-text-meta">${secs} s</span></p>
454
+ ${state && state.log ? `<pre class="voice-log">${esc(state.log)}</pre>` : ''}
455
+ <p class="uk-text-meta">It runs on this machine and keeps going if you close this window. <a href="/screens">Watch the session</a></p>
456
+ </div>`;
457
+ }
458
+
459
+ /**
460
+ * What the polling asks for. `hrefFor` turns a file in the output folder into its address.
461
+ * Returns `{ html, done }`; `done` is true when the list behind the modal should refresh.
462
+ */
463
+ function generationHtml(q, hrefFor) {
464
+ const state = generationState(q);
465
+ if (!state) return null;
466
+ if (state.status === 'running') return { html: progressHtml(q, state), done: false };
467
+ if (state.status === 'failed') {
468
+ return { done: false, html: `
469
+ <div class="msg error voice-failed">
470
+ <p><strong>${esc(q.file)}</strong> was not made${state.slow ? ': it is taking too long to wait for here' : ''}.</p>
471
+ ${state.log ? `<pre class="voice-log">${esc(state.log)}</pre>` : '<p class="uk-text-meta">The session left no log.</p>'}
472
+ </div>` };
473
+ }
474
+ const video = q.kind === 'video';
475
+ const src = hrefFor(state.file);
476
+ const sound = video ? '' : transcripts.soundCardHtml(state.dir, state.stem, hrefFor) || '';
477
+ const again = { text: 'voice', video: 'video', sound: 'sound', music: 'music' }[q.kind];
478
+ return { done: true, html: `
479
+ <div class="voice-done">
480
+ <p><span uk-icon="icon: check; ratio: .9"></span> <strong>${esc(state.file)}</strong> is ready, in <code>${esc(state.where[0])}/${esc(state.where[1] ? `${state.where[1]}/` : '')}</code>.</p>
481
+ ${video ? `<video class="video-player" controls preload="metadata" playsinline src="${esc(src)}"></video>` : `<audio class="sound-player" controls preload="metadata" src="${esc(src)}"></audio>`}
482
+ ${sound}
483
+ <div class="uk-margin-small-top">
484
+ ${q.edit ? `<button type="button" class="uk-button uk-button-default" hx-get="${esc(`/fragments/videos/edit?${new URLSearchParams({ file: state.file, mode: 'pace' })}`)}" hx-target="#editor-modal-body" hx-swap="innerHTML">Edit this version</button>`
485
+ : `<button type="button" class="uk-button uk-button-default" hx-get="/fragments/audio/new?mode=${again}" hx-target="#editor-modal-body" hx-swap="innerHTML">Make another</button>`}
486
+ <button type="button" class="uk-button uk-button-default uk-modal-close">Close</button>
487
+ </div>
488
+ </div>` };
489
+ }
490
+
491
+ const money = (n) => Number(n).toLocaleString('en-US');
492
+
493
+ /** What a video voiceover would do, shown before anything runs, with the button that runs it. */
494
+ function planHtml(p, found) {
495
+ const engine = offered(found.engines).find((e) => e.id === p.engine);
496
+ const spends = p.engine === 'elevenlabs';
497
+ const rows = p.parsed.timed ? p.parsed.sections.map((x) => {
498
+ const rough = x.chars / SPEECH_CHARS_PER_SECOND;
499
+ const note = rough > x.window * 1.05 ? 'may run long' : '';
500
+ return `<tr><td>${esc(x.id)}</td><td>${esc(x.title)}</td><td class="uk-text-right">${x.window.toFixed(1)}s</td><td class="uk-text-right">${x.chars}</td>
501
+ <td class="uk-text-right">${rough.toFixed(1)}s</td><td>${note ? `<span class="uk-text-warning">${note}</span>` : ''}</td></tr>`;
502
+ }).join('') : '';
503
+ const table = p.parsed.timed ? `
504
+ <table class="uk-table uk-table-small uk-table-divider">
505
+ <thead><tr><th>#</th><th>Section</th><th class="uk-text-right">Window</th><th class="uk-text-right">Characters</th><th class="uk-text-right">About</th><th></th></tr></thead>
506
+ <tbody>${rows}</tbody></table>` : '<p>A transcript with no timings: it is spread over the whole video as one section.</p>';
507
+ const short = spends && engine && engine.remaining != null && engine.remaining < p.characters;
508
+ const credits = spends
509
+ ? `<p>Up to <strong>${money(p.characters)}</strong> characters${engine && engine.remaining != null ? ` of the ${money(engine.remaining)} left on the ${esc(engine.plan)} plan` : ''}. ElevenLabs may count credits differently from characters, so check your usage afterwards.${short ? ' <span class="uk-text-danger">That is more than the credits left.</span>' : ''}</p>`
510
+ : '<p>Piper is free and runs on your machine.</p>';
511
+ const label = spends ? `Make the voiceover with ElevenLabs, about ${money(p.characters)} characters of credits` : 'Make the voiceover with Piper';
512
+ const button = spends
513
+ ? `<button type="button" class="uk-button uk-button-primary arm-step" title="${esc(label)}" hx-post="/actions/generate-voice" hx-include="#voice-form"
514
+ hx-vals='{"confirm":"yes"}' hx-trigger="confirmed-remove" hx-target="#voice-result" hx-swap="innerHTML"${short ? ' disabled' : ''}>
515
+ <span uk-icon="icon: play; ratio: .8"></span> Generate with ElevenLabs</button>`
516
+ : `<button type="button" class="uk-button uk-button-primary" title="${esc(label)}" hx-post="/actions/generate-voice" hx-include="#voice-form"
517
+ hx-target="#voice-result" hx-swap="innerHTML"><span uk-icon="icon: play; ratio: .8"></span> Generate with Piper</button>`;
518
+ return `
519
+ <div class="uk-card uk-card-default uk-card-body">
520
+ <h4 class="uk-margin-small-bottom">The plan</h4>
521
+ <p><code>${esc(p.video)}</code> &rarr; <code>videos/${esc(p.file)}</code>, script <code>${esc(p.scriptLabel)}</code>, voice <code>${esc(p.voice)}</code>${p.model ? ` on <code>${esc(p.model)}</code>` : ''}.</p>
522
+ ${table}${credits}
523
+ <p class="uk-text-meta">A section that runs past its window is sped up by at most 8%; more than that stops the run and says which section, and nothing is lost. The picture is copied unchanged.</p>
524
+ ${button}
525
+ </div>`;
526
+ }
527
+
528
+ module.exports = { voiceChoices, splitChoice, generationHtml, progressHtml, generationState, KIND_DIR, loadEngines, offered, plan, commandLine, writeText, formHtml, planHtml, choices, parseScript, ID_RE, MAX_TEXT, MAX_SCRIPT, MAX_PROMPT };