@gentbajko/slopify 3.0.0 → 3.0.1

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 (195) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/key-probes.js +57 -8
  4. package/dist/adapters/tts/gemini.js +206 -0
  5. package/dist/adapters/tts/pcm-mp3.js +62 -0
  6. package/dist/adapters/tts/system.js +147 -0
  7. package/dist/assets/models.yaml +38 -0
  8. package/dist/assets/style-preview/narration.mp3 +0 -0
  9. package/dist/assets/style-preview/tall-1.jpg +0 -0
  10. package/dist/assets/style-preview/tall-2.jpg +0 -0
  11. package/dist/assets/style-preview/tall-3.jpg +0 -0
  12. package/dist/assets/style-preview/wide-1.jpg +0 -0
  13. package/dist/assets/style-preview/wide-2.jpg +0 -0
  14. package/dist/assets/style-preview/wide-3.jpg +0 -0
  15. package/dist/catalog/registry.js +4 -1
  16. package/dist/catalog/validate.js +2 -2
  17. package/dist/edge/autostart/docker-record.js +2 -2
  18. package/dist/edge/autostart/docker.js +68 -0
  19. package/dist/edge/autostart/prompt.js +7 -1
  20. package/dist/edge/autostart/service.js +18 -9
  21. package/dist/edge/cli-args.js +71 -0
  22. package/dist/edge/cli.js +11 -16
  23. package/dist/edge/docker-install/run.js +7 -1
  24. package/dist/edge/events/hub.js +3 -1
  25. package/dist/edge/http/actions.js +6 -1
  26. package/dist/edge/http/app.js +2 -0
  27. package/dist/edge/http/byte-range.js +42 -0
  28. package/dist/edge/http/diagnostics.js +1 -0
  29. package/dist/edge/http/draft-files.js +2 -4
  30. package/dist/edge/http/files.js +10 -6
  31. package/dist/edge/http/onboarding.js +64 -4
  32. package/dist/edge/http/project-templates.js +19 -2
  33. package/dist/edge/http/projects.js +6 -1
  34. package/dist/edge/http/providers.js +39 -9
  35. package/dist/edge/http/reviews.js +18 -8
  36. package/dist/edge/http/revision-files.js +2 -4
  37. package/dist/edge/http/schedules.js +9 -5
  38. package/dist/edge/http/storage.js +16 -2
  39. package/dist/edge/http/studio.js +111 -31
  40. package/dist/edge/http/style-preview.js +3 -42
  41. package/dist/edge/http/tutorials.js +59 -0
  42. package/dist/edge/http/youtube-edits.js +11 -2
  43. package/dist/extension/slopify-studio-chrome.zip +0 -0
  44. package/dist/extension/slopify-studio-firefox.zip +0 -0
  45. package/dist/host-cli/gemini-login.js +83 -0
  46. package/dist/host-cli/status.js +4 -2
  47. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  48. package/dist/kernel/ports/model.js +5 -3
  49. package/dist/kernel/ports/system-speech.js +203 -0
  50. package/dist/kernel/runner/graph.js +17 -1
  51. package/dist/kernel/runner/index.js +4 -4
  52. package/dist/kernel/runner/providers.js +1 -1
  53. package/dist/main.js +5 -0
  54. package/dist/model-catalog.js +2 -0
  55. package/dist/patch-notes/3.0.1.md +105 -0
  56. package/dist/patch-notes/index.json +6 -0
  57. package/dist/slices/admission/rules.js +3 -3
  58. package/dist/slices/admission/start.js +1 -1
  59. package/dist/slices/channels/cast-match.js +1 -1
  60. package/dist/slices/channels/repo.js +3 -0
  61. package/dist/slices/channels/schema.js +23 -0
  62. package/dist/slices/channels/service.js +6 -6
  63. package/dist/slices/control/index.js +3 -1
  64. package/dist/slices/control/providers.js +4 -1
  65. package/dist/slices/episodes/related.js +2 -2
  66. package/dist/slices/estimate/requests.js +9 -1
  67. package/dist/slices/eta/history.js +56 -0
  68. package/dist/slices/eta/model.js +63 -0
  69. package/dist/slices/eta/view.js +25 -0
  70. package/dist/slices/fixes/rules.js +1 -1
  71. package/dist/slices/fonts/coverage.js +1 -1
  72. package/dist/slices/loudness/model.js +1 -1
  73. package/dist/slices/model-upkeep/usage.js +4 -2
  74. package/dist/slices/narration/pauses-model.js +1 -1
  75. package/dist/slices/narration/pronunciation.js +2 -2
  76. package/dist/slices/notifications/notifier.js +28 -2
  77. package/dist/slices/notifications/rules.js +26 -2
  78. package/dist/slices/onboarding/first-run.js +40 -14
  79. package/dist/slices/onboarding/full-video.js +81 -0
  80. package/dist/slices/onboarding/model.js +21 -0
  81. package/dist/slices/onboarding/quick-short.js +25 -10
  82. package/dist/slices/play-drafts/convert.js +3 -3
  83. package/dist/slices/play-drafts/readiness.js +1 -1
  84. package/dist/slices/project-templates/from-project.js +10 -3
  85. package/dist/slices/project-templates/next-chapter.js +94 -0
  86. package/dist/slices/project-templates/one-off.js +1 -1
  87. package/dist/slices/project-templates/schema.js +7 -1
  88. package/dist/slices/rebuild/recipe-exports.js +5 -0
  89. package/dist/slices/rebuild/recipe-voices.js +1 -1
  90. package/dist/slices/rebuild/review-redo.js +15 -0
  91. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  92. package/dist/slices/rebuild/runtime-review.js +35 -5
  93. package/dist/slices/rebuild/runtime-store.js +23 -1
  94. package/dist/slices/rebuild/runtime-voices.js +2 -2
  95. package/dist/slices/rebuild/service-readiness.js +3 -3
  96. package/dist/slices/reviews/repo.js +16 -4
  97. package/dist/slices/run-cost/panel.js +30 -8
  98. package/dist/slices/run-cost/pricing.js +4 -1
  99. package/dist/slices/schedules/agenda.js +66 -2
  100. package/dist/slices/schedules/repo.js +1 -1
  101. package/dist/slices/schedules/scheduler.js +1 -1
  102. package/dist/slices/schedules/schema.js +43 -2
  103. package/dist/slices/schedules/similar.js +41 -3
  104. package/dist/slices/schedules/topic-list.js +2 -2
  105. package/dist/slices/schedules/topics.js +118 -17
  106. package/dist/slices/settings/first-run.js +5 -1
  107. package/dist/slices/settings/health.js +64 -7
  108. package/dist/slices/settings/key-guides.js +17 -7
  109. package/dist/slices/settings/key-test.js +12 -4
  110. package/dist/slices/settings/keys.js +10 -5
  111. package/dist/slices/settings/model-reach.js +52 -0
  112. package/dist/slices/settings/model.js +49 -0
  113. package/dist/slices/settings/models.js +5 -1
  114. package/dist/slices/settings/readiness.js +14 -2
  115. package/dist/slices/settings/tutorial-schema.js +5 -0
  116. package/dist/slices/shorts/render.js +18 -17
  117. package/dist/slices/storage/documents.js +11 -3
  118. package/dist/slices/storage/downloads.js +33 -1
  119. package/dist/slices/storage/portable.js +4 -2
  120. package/dist/slices/studio/model.js +2 -0
  121. package/dist/slices/studio/pack.js +6 -4
  122. package/dist/slices/studio/queue.js +69 -0
  123. package/dist/slices/studio/settings.js +42 -5
  124. package/dist/slices/style-preview/narration.js +33 -0
  125. package/dist/slices/style-preview/render.js +43 -56
  126. package/dist/slices/style-preview/schema.js +17 -3
  127. package/dist/slices/style-preview/service.js +3 -4
  128. package/dist/slices/style-preview/settings.js +75 -25
  129. package/dist/slices/tutorials/anchors.js +57 -0
  130. package/dist/slices/tutorials/library.js +185 -0
  131. package/dist/slices/voices/audio-files.js +7 -2
  132. package/dist/slices/voices/cast.js +26 -8
  133. package/dist/slices/voices/grouping.js +10 -0
  134. package/dist/slices/voices/model.js +49 -10
  135. package/dist/slices/voices/script.js +1 -1
  136. package/dist/slices/youtube/edits-repo.js +19 -4
  137. package/dist/slices/youtube/placeholders.js +2 -2
  138. package/dist/tutorials/AI-CLIs.md +146 -0
  139. package/dist/tutorials/Backups.md +121 -0
  140. package/dist/tutorials/Calendar.md +103 -0
  141. package/dist/tutorials/Cast-Library.md +112 -0
  142. package/dist/tutorials/Channels.md +148 -0
  143. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  144. package/dist/tutorials/Docker.md +171 -0
  145. package/dist/tutorials/Document-Themes.md +207 -0
  146. package/dist/tutorials/Editing-a-Project.md +204 -0
  147. package/dist/tutorials/FAQ.md +134 -0
  148. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  149. package/dist/tutorials/Home-and-Projects.md +174 -0
  150. package/dist/tutorials/Home.md +119 -0
  151. package/dist/tutorials/Install.md +154 -0
  152. package/dist/tutorials/Intros-and-Outros.md +86 -0
  153. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  154. package/dist/tutorials/Library-Overview.md +75 -0
  155. package/dist/tutorials/Models.md +133 -0
  156. package/dist/tutorials/Multiple-Voices.md +161 -0
  157. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  158. package/dist/tutorials/Notifications.md +86 -0
  159. package/dist/tutorials/Other-Languages.md +110 -0
  160. package/dist/tutorials/PDF-Documents.md +100 -0
  161. package/dist/tutorials/Play-Images.md +135 -0
  162. package/dist/tutorials/Play-Narration.md +167 -0
  163. package/dist/tutorials/Play-Outputs.md +109 -0
  164. package/dist/tutorials/Play-Overview.md +216 -0
  165. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  166. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  167. package/dist/tutorials/Project-Page.md +228 -0
  168. package/dist/tutorials/Prompts.md +136 -0
  169. package/dist/tutorials/Providers-and-Keys.md +290 -0
  170. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  171. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  172. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  173. package/dist/tutorials/Schedules.md +154 -0
  174. package/dist/tutorials/Settings-Reference.md +258 -0
  175. package/dist/tutorials/Shorts.md +116 -0
  176. package/dist/tutorials/Start-at-Login.md +126 -0
  177. package/dist/tutorials/Studio-Extension.md +146 -0
  178. package/dist/tutorials/Templates.md +146 -0
  179. package/dist/tutorials/Trash.md +87 -0
  180. package/dist/tutorials/Troubleshooting.md +149 -0
  181. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  182. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  183. package/dist/tutorials/Video-Editing.md +135 -0
  184. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  185. package/dist/tutorials/YouTube-Description.md +132 -0
  186. package/dist/tutorials/Your-First-Short.md +125 -0
  187. package/dist/tutorials/_Footer.md +1 -0
  188. package/dist/tutorials/_Sidebar.md +69 -0
  189. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  190. package/dist/web/assets/index-DFdIhmm0.js +307 -0
  191. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-B9OY1ioX.js} +1 -1
  192. package/dist/web/index.html +2 -2
  193. package/package.json +2 -2
  194. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  195. package/dist/web/assets/index-CX590kE-.js +0 -303
@@ -0,0 +1,59 @@
1
+ import { Hono } from "hono";
2
+ import { tutorialPageIdPattern } from "../../slices/tutorials/anchors.js";
3
+ import { bundledTutorialsDir, loadTutorials, searchTutorials, TutorialsMissing, } from "../../slices/tutorials/library.js";
4
+ import { problem, titleOf } from "./problem.js";
5
+ // A build without its tutorials says so in plain words; anything else is the app's error handler's.
6
+ function missing(c, error) {
7
+ if (!(error instanceof TutorialsMissing))
8
+ throw error;
9
+ return problem(c, { status: 500, title: titleOf(500), detail: error.message });
10
+ }
11
+ // Help → Tutorials: the sidebar and page list, a search across every page, and one page's
12
+ // Markdown. The pages ship with the app, so they are read once and kept.
13
+ // Preserve Hono's inferred route types for the web client.
14
+ export function tutorialPagesRoutes(deps) {
15
+ const dir = deps.tutorialsDir ?? bundledTutorialsDir();
16
+ let book;
17
+ const read = () => {
18
+ book ??= loadTutorials(dir).catch((error) => {
19
+ book = undefined;
20
+ throw error;
21
+ });
22
+ return book;
23
+ };
24
+ return new Hono()
25
+ .get("/", async (c) => {
26
+ try {
27
+ return c.json((await read()).index);
28
+ }
29
+ catch (error) {
30
+ return missing(c, error);
31
+ }
32
+ })
33
+ .get("/search", async (c) => {
34
+ const query = (c.req.query("q") ?? "").slice(0, 200);
35
+ try {
36
+ return c.json({ hits: searchTutorials(await read(), query) });
37
+ }
38
+ catch (error) {
39
+ return missing(c, error);
40
+ }
41
+ })
42
+ .get("/:page", async (c) => {
43
+ const id = c.req.param("page");
44
+ let markdown;
45
+ try {
46
+ markdown = tutorialPageIdPattern.test(id) ? (await read()).pages.get(id) : undefined;
47
+ }
48
+ catch (error) {
49
+ return missing(c, error);
50
+ }
51
+ if (markdown === undefined)
52
+ return problem(c, {
53
+ status: 404,
54
+ title: titleOf(404),
55
+ detail: `There is no tutorial called ${id} in this version of Slopify. Pick one from the list on Help → Tutorials, or search there.`,
56
+ });
57
+ return c.body(markdown, 200, { "content-type": "text/markdown; charset=utf-8" });
58
+ });
59
+ }
@@ -3,8 +3,9 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { transact } from "../../kernel/db/tx.js";
5
5
  import { projectExists } from "../../slices/admission/repo.js";
6
+ import { projectChannelId } from "../../slices/channels/repo.js";
6
7
  import { descriptionFields, withEdit, withoutEdit } from "../../slices/youtube/edits.js";
7
- import { readDescriptionEdits, writeDescriptionEdits } from "../../slices/youtube/edits-repo.js";
8
+ import { readChannelLinksFor, readDescriptionEdits, writeDescriptionEdits, } from "../../slices/youtube/edits-repo.js";
8
9
  import { descriptionMaxCharacters } from "../../slices/youtube/model.js";
9
10
  import { channelLinksProblem } from "../../slices/youtube/placeholders.js";
10
11
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -28,6 +29,14 @@ export function youtubeEditRoutes(deps) {
28
29
  .get("/:id/youtube-edits", zValidator("param", idParam, onInvalid), (c) => {
29
30
  const { id } = c.req.valid("param");
30
31
  return exists(id) ? c.json(readDescriptionEdits(deps.db, id)) : missing(c);
32
+ })
33
+ // The links of the project's channel, which its placeholders fill from after its own.
34
+ .get("/:id/channel-links", zValidator("param", idParam, onInvalid), (c) => {
35
+ const { id } = c.req.valid("param");
36
+ if (!exists(id))
37
+ return missing(c);
38
+ const channelId = projectChannelId(deps.db, id);
39
+ return c.json({ channelId, links: readChannelLinksFor(deps.db, channelId) });
31
40
  })
32
41
  // Saves the user's text for one field with the generated text it was edited from; text
33
42
  // equal to that generated text clears the edit. "Keep mine" is this call with the new
@@ -58,7 +67,7 @@ export function youtubeEditRoutes(deps) {
58
67
  });
59
68
  return c.json(saved);
60
69
  })
61
- // The project's own links, such as its "Previous video"; they win over Settings' list.
70
+ // The project's own links, such as its "Previous video"; they win over its channel's list.
62
71
  .put("/:id/youtube-edits/links", zValidator("param", idParam, onInvalid), zValidator("json", channelLinksBody, onInvalid), (c) => {
63
72
  const { id } = c.req.valid("param");
64
73
  if (!exists(id))
@@ -0,0 +1,83 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function nodeGeminiLoginHost() {
5
+ return {
6
+ home: homedir(),
7
+ env: process.env,
8
+ read: (path) => readFile(path, "utf8").catch(() => undefined),
9
+ };
10
+ }
11
+ export async function readGeminiLogin(host) {
12
+ const folder = join(host.home, ".gemini");
13
+ const raw = await host.read(join(folder, "settings.json"));
14
+ const settings = parse(raw);
15
+ const method = selectedAuth(settings);
16
+ const hasKey = async (name) => nonEmpty(host.env[name]) || dotenvHas(await host.read(join(folder, ".env")), name);
17
+ const oauth = async () => {
18
+ const creds = parse(await host.read(join(folder, "oauth_creds.json")));
19
+ return typeof creds?.refresh_token === "string" && creds.refresh_token !== "";
20
+ };
21
+ switch (method) {
22
+ case "oauth-personal":
23
+ case "login-with-google":
24
+ return (await oauth()) ? "signed-in" : "signed-out";
25
+ case "gemini-api-key":
26
+ return (await hasKey("GEMINI_API_KEY")) ? "signed-in" : "signed-out";
27
+ case "vertex-ai":
28
+ return (await hasKey("GOOGLE_API_KEY")) || (await hasKey("GOOGLE_CLOUD_PROJECT"))
29
+ ? "signed-in"
30
+ : "signed-out";
31
+ case "cloud-shell":
32
+ return "signed-in";
33
+ case undefined:
34
+ // No choice saved yet: the CLI still starts without asking when a key or an earlier
35
+ // Google sign-in is there; otherwise it opens its sign-in menu.
36
+ if ((await hasKey("GEMINI_API_KEY")) || (await oauth()))
37
+ return "signed-in";
38
+ // Nothing saved says nothing either: the CLI can be signed in some other way (Vertex or
39
+ // a key in its own environment), so only a sign-in the files prove missing blocks a run.
40
+ return "unknown";
41
+ default:
42
+ // A method this version of Slopify doesn't know.
43
+ return "unknown";
44
+ }
45
+ }
46
+ function parse(text) {
47
+ if (text === undefined)
48
+ return undefined;
49
+ try {
50
+ const value = JSON.parse(text);
51
+ return typeof value === "object" && value !== null && !Array.isArray(value)
52
+ ? value
53
+ : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function selectedAuth(settings) {
60
+ const security = settings?.security;
61
+ const auth = typeof security === "object" && security !== null
62
+ ? security.auth
63
+ : undefined;
64
+ const nested = typeof auth === "object" && auth !== null
65
+ ? auth.selectedType
66
+ : undefined;
67
+ if (typeof nested === "string" && nested !== "")
68
+ return nested;
69
+ const flat = settings?.selectedAuthType;
70
+ return typeof flat === "string" && flat !== "" ? flat : undefined;
71
+ }
72
+ function nonEmpty(value) {
73
+ return value !== undefined && value.trim() !== "";
74
+ }
75
+ // `NAME=value` in a .env file, optionally quoted or exported.
76
+ function dotenvHas(text, name) {
77
+ if (text === undefined)
78
+ return false;
79
+ return text.split(/\r?\n/).some((line) => {
80
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
81
+ return match?.[1] === name && nonEmpty((match[2] ?? "").replace(/^["']|["']$/g, ""));
82
+ });
83
+ }
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { cliLoginError } from "../adapters/llm/cli-login-error.js";
7
7
  import { cliCommand } from "../kernel/cli-command.js";
8
8
  import { cliProbeTimeoutMs, readinessFromProbe, } from "../slices/settings/cli-status.js";
9
+ import { nodeGeminiLoginHost, readGeminiLogin } from "./gemini-login.js";
9
10
  export function hostCommandName(id) {
10
11
  return id === "claude-code" ? "claude" : id === "codex-image" ? "codex" : id;
11
12
  }
@@ -48,9 +49,10 @@ export function parseHostLogin(id, stdout, stderr) {
48
49
  return "unknown";
49
50
  }
50
51
  }
51
- export async function readHostLogin(binary, id, signal) {
52
+ export async function readHostLogin(binary, id, signal, gemini = nodeGeminiLoginHost()) {
53
+ // The Gemini CLI has no status command; its settings and credential files answer instead.
52
54
  if (id === "gemini")
53
- return "unknown";
55
+ return readGeminiLogin(gemini);
54
56
  const command = cliCommand(binary);
55
57
  return new Promise((resolve) => {
56
58
  execFile(command.file, [...command.args, ...(id === "claude-code" ? ["auth", "status"] : ["login", "status"])], { timeout: cliProbeTimeoutMs, maxBuffer: 64 * 1024, signal, windowsHide: true }, (error, stdout, stderr) => {
@@ -0,0 +1,3 @@
1
+ -- A channel's hosts: cast members marked as the channel's recurring voices. A new podcast or
2
+ -- interview draft starts with them as its speakers. Every member made before this is not a host.
3
+ ALTER TABLE cast_members ADD COLUMN host INTEGER NOT NULL DEFAULT 0 CHECK (host IN (0, 1));
@@ -2,9 +2,11 @@
2
2
  // shape never leaves its adapter.
3
3
  export const providerFamilies = ["llm", "tts", "image"];
4
4
  export function readinessIsUsable(readiness) {
5
- return readiness.kind === "keyed"
6
- ? readiness.hasKey
7
- : readiness.installed && readiness.issue === undefined;
5
+ if (readiness.kind === "keyed")
6
+ return readiness.hasKey;
7
+ if (readiness.kind === "local")
8
+ return readiness.available;
9
+ return readiness.installed && readiness.issue === undefined;
8
10
  }
9
11
  export const providerErrorKinds = [
10
12
  // A key the provider rejected. Like an absent one it fails at once: no wait fixes it.
@@ -0,0 +1,203 @@
1
+ // The speech a computer already has, found when asked: macOS's `say`, Windows' built-in
2
+ // System.Speech through PowerShell, and on Linux Piper, SVOX Pico, eSpeak NG or eSpeak, best
3
+ // first. No key and no charge. It lives in the kernel because the settings slice (readiness)
4
+ // and the TTS adapter both read it, and neither may import the other.
5
+ export const speechEngineIds = [
6
+ "say",
7
+ "sapi",
8
+ "piper",
9
+ "pico2wave",
10
+ "espeak-ng",
11
+ "espeak",
12
+ ];
13
+ export const speechEngineNames = {
14
+ say: "macOS voices",
15
+ sapi: "Windows voices",
16
+ piper: "Piper",
17
+ pico2wave: "SVOX Pico",
18
+ "espeak-ng": "eSpeak NG",
19
+ espeak: "eSpeak",
20
+ };
21
+ const probeTimeoutMs = 15_000;
22
+ // Windows lists the enabled voices as "name<TAB>culture" lines.
23
+ export const windowsVoiceListScript = "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.Enabled) { Write-Output ($v.VoiceInfo.Name + [char]9 + $v.VoiceInfo.Culture.Name) } }; $s.Dispose()";
24
+ // SVOX Pico ships exactly these six.
25
+ const picoVoices = [
26
+ { id: "en-US", name: "English (US)", language: "en-US" },
27
+ { id: "en-GB", name: "English (UK)", language: "en-GB" },
28
+ { id: "de-DE", name: "German", language: "de-DE" },
29
+ { id: "es-ES", name: "Spanish", language: "es-ES" },
30
+ { id: "fr-FR", name: "French", language: "fr-FR" },
31
+ { id: "it-IT", name: "Italian", language: "it-IT" },
32
+ ];
33
+ export const noSpeechFix = "or add an ElevenLabs or OpenAI key in Settings → Providers for a better voice.";
34
+ export async function detectSpeech(given, host) {
35
+ // A probe that throws (a host bridge that cannot run local programs) found nothing.
36
+ const probe = (binary, args, timeoutMs) => Promise.resolve()
37
+ .then(() => given(binary, args, timeoutMs))
38
+ .catch(() => ({ ran: false, stdout: "" }));
39
+ const engines = [];
40
+ const add = (id, voices) => {
41
+ if (voices.length > 0)
42
+ engines.push({ id, name: speechEngineNames[id], voices });
43
+ };
44
+ if (host.platform === "darwin") {
45
+ const listed = await probe("say", ["-v", "?"], probeTimeoutMs);
46
+ if (listed.ran)
47
+ add("say", parseSayVoices(listed.stdout));
48
+ return engines.length > 0
49
+ ? { engines }
50
+ : {
51
+ engines,
52
+ issue: `macOS's speech command (say) didn't answer, so the system voice can't narrate. Check that /usr/bin/say runs in Terminal, ${noSpeechFix}`,
53
+ };
54
+ }
55
+ if (host.platform === "win32") {
56
+ const listed = await probe("powershell", ["-NoProfile", "-NonInteractive", "-Command", windowsVoiceListScript], probeTimeoutMs);
57
+ if (listed.ran)
58
+ add("sapi", parseWindowsVoices(listed.stdout));
59
+ return engines.length > 0
60
+ ? { engines }
61
+ : {
62
+ engines,
63
+ issue: `Windows' built-in speech didn't answer through PowerShell, or no voice is installed. Add a voice in Windows Settings → Time & language → Speech, ${noSpeechFix}`,
64
+ };
65
+ }
66
+ const piperModels = piperVoices(host.env);
67
+ const [piper, pico, espeakNg, espeak] = await Promise.all([
68
+ // Without a voice model Piper can't speak, so it isn't even asked.
69
+ piperModels.length > 0 ? isPiperTts(probe) : Promise.resolve(false),
70
+ onPath(probe, "pico2wave"),
71
+ probe("espeak-ng", ["--voices"], probeTimeoutMs),
72
+ probe("espeak", ["--voices"], probeTimeoutMs),
73
+ ]);
74
+ if (piper)
75
+ add("piper", piperModels);
76
+ if (pico)
77
+ add("pico2wave", picoVoices);
78
+ if (espeakNg.ran)
79
+ add("espeak-ng", parseEspeakVoices(espeakNg.stdout));
80
+ if (espeak.ran)
81
+ add("espeak", parseEspeakVoices(espeak.stdout));
82
+ if (engines.length > 0)
83
+ return { engines };
84
+ return {
85
+ engines,
86
+ issue: host.env.SLOPIFY_CONTAINER === "1"
87
+ ? `Slopify runs in Docker and this image has no speech program, so the system voice can't narrate. Update the image (docker compose pull, then docker compose up -d), ${noSpeechFix}`
88
+ : `No speech program was found on this computer, so the system voice can't narrate. Install espeak-ng (for example sudo apt install espeak-ng, sudo pacman -S espeak-ng or sudo dnf install espeak-ng) and reload Settings → Providers, ${noSpeechFix}`,
89
+ };
90
+ }
91
+ async function onPath(probe, binary) {
92
+ // `command -v` answers for a program on PATH without running it: Pico and Piper have no
93
+ // harmless flag that exits cleanly.
94
+ return (await probe("sh", ["-c", 'command -v "$1"', "sh", binary], probeTimeoutMs)).ran;
95
+ }
96
+ // `piper` on PATH is not always Piper the voice engine: libratbag's mouse settings app has the
97
+ // same name (/usr/bin/piper on many desktops). The engine is told apart by its help, which names
98
+ // --model and an output option (the C++ build prints it on stderr, hence 2>&1), or failing that
99
+ // by a version line that names piper-tts. Anything else, or no answer, is not Piper.
100
+ async function isPiperTts(probe) {
101
+ const ask = (flag) => probe("sh", ["-c", '"$1" "$2" 2>&1; exit 0', "sh", "piper", flag], probeTimeoutMs);
102
+ const help = await ask("--help");
103
+ if (help.ran && isPiperTtsHelp(help.stdout))
104
+ return true;
105
+ const version = await ask("--version");
106
+ return version.ran && isPiperTtsVersion(version.stdout);
107
+ }
108
+ export function isPiperTtsHelp(text) {
109
+ return /--model\b/.test(text) && /--output[-_](?:file|dir|raw)\b/.test(text);
110
+ }
111
+ export function isPiperTtsVersion(text) {
112
+ return /\bpiper[-_ ]tts\b/i.test(text);
113
+ }
114
+ // Piper needs a voice model file; SLOPIFY_PIPER_VOICES lists them (.onnx paths, separated like
115
+ // PATH). Without one, Piper can't speak and is left out.
116
+ export function piperVoices(env) {
117
+ return (env.SLOPIFY_PIPER_VOICES ?? "")
118
+ .split(":")
119
+ .map((path) => path.trim())
120
+ .filter((path) => path.endsWith(".onnx"))
121
+ .map((path) => {
122
+ const name = (path.split("/").pop() ?? path).replace(/\.onnx$/, "");
123
+ const language = /^([a-z]{2,3}_[A-Z]{2})/.exec(name)?.[1];
124
+ return { id: path, name, ...(language === undefined ? {} : { language }) };
125
+ });
126
+ }
127
+ // `say -v ?`: "Samantha en_US # Hello! My name is Samantha." A name may carry
128
+ // single spaces and brackets ("Eddy (English (US))"), so the language is the anchor.
129
+ export function parseSayVoices(stdout) {
130
+ const voices = [];
131
+ for (const line of stdout.split(/\r?\n/)) {
132
+ const match = /^(.+?)\s+([a-z]{2,3}[_-][A-Za-z0-9_-]+)\s+#/.exec(line);
133
+ if (match?.[1] === undefined || match[2] === undefined)
134
+ continue;
135
+ const name = match[1].trim();
136
+ if (!voices.some((voice) => voice.id === name))
137
+ voices.push({ id: name, name, language: match[2] });
138
+ }
139
+ return voices;
140
+ }
141
+ export function parseWindowsVoices(stdout) {
142
+ const voices = [];
143
+ for (const line of stdout.split(/\r?\n/)) {
144
+ const [name, culture] = line.split("\t").map((part) => part.trim());
145
+ if (name === undefined || name === "" || voices.some((voice) => voice.id === name))
146
+ continue;
147
+ voices.push({
148
+ id: name,
149
+ name: name.replace(/^Microsoft\s+/, "").replace(/\s+Desktop$/, ""),
150
+ ...(culture === undefined || culture === "" ? {} : { language: culture }),
151
+ });
152
+ }
153
+ return voices;
154
+ }
155
+ // `espeak-ng --voices`: a header, then " 5 en-us --/M English_(America) gmw/en-US ...".
156
+ export function parseEspeakVoices(stdout) {
157
+ const voices = [];
158
+ for (const line of stdout.split(/\r?\n/)) {
159
+ const parts = line.trim().split(/\s+/);
160
+ if (parts.length < 4 || !/^\d+$/.test(parts[0] ?? ""))
161
+ continue;
162
+ const [, language, , name] = parts;
163
+ if (language === undefined || name === undefined)
164
+ continue;
165
+ if (voices.some((voice) => voice.id === language))
166
+ continue;
167
+ voices.push({ id: language, name: name.replace(/_/g, " "), language });
168
+ }
169
+ return voices;
170
+ }
171
+ // The voice a first short uses when none is saved: an English one, American first.
172
+ export function defaultSpeechVoice(engine) {
173
+ const english = (voice, tag) => tag.test(voice.language ?? voice.id);
174
+ return (engine.voices.find((voice) => voice.name === "Samantha") ??
175
+ engine.voices.find((voice) => english(voice, /^en[-_]us$/i)) ??
176
+ engine.voices.find((voice) => english(voice, /^en\b|^en[-_]/i)) ??
177
+ engine.voices[0]);
178
+ }
179
+ // The primary language subtag ("en" from "en_US"), as Settings → Voices records it.
180
+ export function primaryLanguage(voice) {
181
+ const tag = /^([a-z]{2,3})(?:[-_]|$)/i.exec(voice.language ?? "")?.[1];
182
+ return tag?.toLowerCase();
183
+ }
184
+ // One look per minute: readiness is asked on every Settings and Play load, and starting four
185
+ // processes each time is wasted work for something that changes when a program is installed.
186
+ const cacheMs = 60_000;
187
+ const cached = new WeakMap();
188
+ export function detectSpeechCached(probe, host) {
189
+ const now = performance.now();
190
+ const key = [
191
+ host.platform,
192
+ host.env.SLOPIFY_CONTAINER ?? "",
193
+ host.env.SLOPIFY_PIPER_VOICES ?? "",
194
+ ].join("\n");
195
+ const byHost = cached.get(probe) ?? new Map();
196
+ cached.set(probe, byHost);
197
+ const hit = byHost.get(key);
198
+ if (hit !== undefined && now - hit.at < cacheMs)
199
+ return hit.found;
200
+ const found = detectSpeech(probe, host);
201
+ byHost.set(key, { at: now, found });
202
+ return found;
203
+ }
@@ -2,7 +2,8 @@
2
2
  // audio-only export narrow these dependencies through dependenciesOf(). The document is
3
3
  // laid out from the article alone, with the thumbnail as its cover when there is one, so it
4
4
  // runs beside narration and images rather than after them. The video never reads the
5
- // thumbnail, so a thumbnail that fails does not hold the video back.
5
+ // thumbnail, so a thumbnail that fails does not hold the video back; neither does it hold
6
+ // the document back (see `optionalDeps`).
6
7
  export const deps = {
7
8
  research: [],
8
9
  article: ["research"],
@@ -12,10 +13,25 @@ export const deps = {
12
13
  video: ["article", "audio", "images"],
13
14
  document: ["article", "thumbnail"],
14
15
  };
16
+ // Dependencies a stage waits for but can do without: the document takes the thumbnail as its
17
+ // cover when there is one. A thumbnail that failed or was canceled releases the document,
18
+ // which is then laid out with no cover, instead of holding it back with it. Making the
19
+ // thumbnail again still redoes the document (reruns/cascade.ts follows `deps`), so it gets
20
+ // its cover then.
21
+ export const optionalDeps = {
22
+ document: ["thumbnail"],
23
+ };
15
24
  // `provided` and `skipped` release a dependency exactly as `done` does.
16
25
  export function satisfied(state) {
17
26
  return state === "done" || state === "provided" || state === "skipped";
18
27
  }
28
+ // Whether `dependency`, in `state`, lets `kind` start.
29
+ export function releases(kind, dependency, state) {
30
+ if (satisfied(state))
31
+ return true;
32
+ return ((state === "failed" || state === "canceled") &&
33
+ (optionalDeps[kind]?.includes(dependency) ?? false));
34
+ }
19
35
  // What a run is for, in order: the first of these the run asks for is the one that has to be
20
36
  // there for the run to count as finished. Anything else failing leaves it done with
21
37
  // problems rather than failed.
@@ -1,5 +1,5 @@
1
1
  import { causedBy } from "../errors.js";
2
- import { derive, deps as graph, satisfied } from "./graph.js";
2
+ import { derive, deps as graph, releases } from "./graph.js";
3
3
  import { progressGate } from "./progress.js";
4
4
  import { faultOf } from "./retry-policy.js";
5
5
  const pauseReason = new Error("paused by user");
@@ -190,9 +190,9 @@ export function createRunner(deps) {
190
190
  }
191
191
  function startEligible(projectId) {
192
192
  const stages = deps.stages.stagesOf(projectId);
193
- const satisfiedKind = (kind) => {
193
+ const releasesKind = (dependent) => (kind) => {
194
194
  const matches = stages.filter((stage) => stage.kind === kind);
195
- return matches.length > 0 && matches.every((stage) => satisfied(stage.state));
195
+ return (matches.length > 0 && matches.every((stage) => releases(dependent, kind, stage.state)));
196
196
  };
197
197
  for (const stage of stages) {
198
198
  if (stage.state !== "pending" || inflight.has(stage.work.workId)) {
@@ -200,7 +200,7 @@ export function createRunner(deps) {
200
200
  }
201
201
  const dependencies = deps.stages.dependenciesOf?.(projectId, stage.kind) ?? graph[stage.kind];
202
202
  if (deps.stages.ready === undefined
203
- ? !dependencies.every(satisfiedKind)
203
+ ? !dependencies.every(releasesKind(stage.kind))
204
204
  : !deps.stages.ready(stage.work)) {
205
205
  continue;
206
206
  }
@@ -178,7 +178,7 @@ export function stageProviders(deps, context, pieceId) {
178
178
  if (call.dialogue !== undefined && port.capabilities.dialogue !== true)
179
179
  throw providerError({
180
180
  kind: "unsupported",
181
- message: `${call.provider} can't speak several voices in one request. Turn off Native multi-speaker under Speakers (Play → Audio, or Edit project → Providers), then Try again.`,
181
+ message: `${call.provider} can't speak several voices in one request. Turn off Native multi-speaker under Speakers (Play → Narration, or Edit project → Providers), then Try again.`,
182
182
  });
183
183
  const spoken = await port.synthesize({
184
184
  model: call.model,
package/dist/main.js CHANGED
@@ -50,6 +50,7 @@ import { createEpisodeMemoryWatcher, } from "./slices/episodes/summarize.js";
50
50
  import { resolveFont } from "./slices/fonts/index.js";
51
51
  import { decodePeaks } from "./slices/narration/peaks.js";
52
52
  import { createRunNotifier } from "./slices/notifications/notifier.js";
53
+ import { projectLink } from "./slices/notifications/rules.js";
53
54
  import { createNotificationSender } from "./slices/notifications/send.js";
54
55
  import { readNotificationUrl } from "./slices/notifications/settings.js";
55
56
  import { seedSamples } from "./slices/onboarding/sample.js";
@@ -205,6 +206,8 @@ export async function boot(config, options = {}) {
205
206
  ? undefined
206
207
  : { title: project.title, makesVideo: project.config.sources.video !== "off" };
207
208
  },
209
+ // Read when a notification goes out, by which time the server is listening.
210
+ link: (projectId) => projectLink(config.host, listeningPort, projectId),
208
211
  send: sendNotification,
209
212
  log,
210
213
  });
@@ -238,6 +241,7 @@ export async function boot(config, options = {}) {
238
241
  clock,
239
242
  probe: nodeCliProbe,
240
243
  hostCli,
244
+ ffmpeg,
241
245
  }), catalogue);
242
246
  // Calls that belong to no project (topic generation, episode summaries, cast pictures)
243
247
  // still go through the attempt wrapper, and are metered against their schedule or channel.
@@ -530,6 +534,7 @@ export async function boot(config, options = {}) {
530
534
  log,
531
535
  version,
532
536
  webDist: fileURLToPath(new URL("../dist/web", import.meta.url)),
537
+ extensionDist: fileURLToPath(new URL("../dist/extension", import.meta.url)),
533
538
  flushSoon: flusher.soon,
534
539
  probe: nodeCliProbe,
535
540
  hostCliStatus: hostCli?.status,
@@ -4,6 +4,7 @@ import { openAiImageModels } from "./adapters/image/openai.js";
4
4
  import { replicateModels } from "./adapters/image/replicate.js";
5
5
  import { cartesiaModels } from "./adapters/tts/cartesia.js";
6
6
  import { elevenLabsModels } from "./adapters/tts/elevenlabs.js";
7
+ import { geminiTtsModels } from "./adapters/tts/gemini.js";
7
8
  import { inworldModels } from "./adapters/tts/inworld.js";
8
9
  import { openAiTtsModels } from "./adapters/tts/openai.js";
9
10
  // Composition keeps provider-specific fallback data out of the HTTP and settings layers.
@@ -13,6 +14,7 @@ export function modelSources(registry) {
13
14
  "openai-tts": openAiTtsModels,
14
15
  cartesia: cartesiaModels,
15
16
  inworld: inworldModels,
17
+ "google-tts": geminiTtsModels,
16
18
  fal: falModels,
17
19
  replicate: replicateModels,
18
20
  "openai-image": openAiImageModels,