@gentbajko/slopify 3.0.0 → 3.0.2
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.
- package/README.md +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/alignment/text.js +3 -1
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/3.0.2.md +20 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-save.js +1 -1
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-C4McGNke.js +307 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- 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
|
|
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))
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|
package/dist/host-cli/status.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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,
|
|
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
|
|
193
|
+
const releasesKind = (dependent) => (kind) => {
|
|
194
194
|
const matches = stages.filter((stage) => stage.kind === kind);
|
|
195
|
-
return matches.length > 0 && matches.every((stage) =>
|
|
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(
|
|
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 →
|
|
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,
|
package/dist/model-catalog.js
CHANGED
|
@@ -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,
|