@gentbajko/slopify 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +3 -3
  3. package/dist/adapter-registry.js +12 -2
  4. package/dist/adapters/image/google.js +12 -15
  5. package/dist/adapters/image/models.js +72 -0
  6. package/dist/adapters/image/openai.js +3 -5
  7. package/dist/adapters/llm/catalogue-files.js +23 -0
  8. package/dist/adapters/llm/claude-code.js +3 -4
  9. package/dist/adapters/llm/codex-models.js +46 -0
  10. package/dist/adapters/llm/codex.js +4 -10
  11. package/dist/adapters/llm/gemini-models.js +85 -0
  12. package/dist/adapters/llm/gemini.js +3 -6
  13. package/dist/adapters/llm/openrouter.js +11 -2
  14. package/dist/adapters/tts/cartesia.js +9 -1
  15. package/dist/adapters/tts/elevenlabs.js +33 -2
  16. package/dist/adapters/tts/inworld-async.js +150 -0
  17. package/dist/adapters/tts/inworld-text.js +32 -0
  18. package/dist/adapters/tts/inworld.js +170 -0
  19. package/dist/adapters/tts/openai.js +31 -4
  20. package/dist/edge/cli.js +5 -0
  21. package/dist/edge/events/hub.js +9 -2
  22. package/dist/edge/events/preview-cache.js +40 -0
  23. package/dist/edge/http/actions.js +2 -0
  24. package/dist/edge/http/app.js +26 -1
  25. package/dist/edge/http/audio-preview.js +46 -0
  26. package/dist/edge/http/providers.js +16 -0
  27. package/dist/edge/http/update.js +57 -0
  28. package/dist/edge/update-worker.js +44 -0
  29. package/dist/kernel/audio-preview.js +188 -0
  30. package/dist/kernel/runner/providers.js +72 -22
  31. package/dist/main.js +104 -24
  32. package/dist/model-catalog.js +37 -0
  33. package/dist/slices/control/index.js +1 -0
  34. package/dist/slices/control/providers.js +2 -0
  35. package/dist/slices/narration/live.js +21 -0
  36. package/dist/slices/narration/run.js +9 -9
  37. package/dist/slices/research/run.js +3 -0
  38. package/dist/slices/settings/model.js +2 -0
  39. package/dist/slices/settings/models.js +89 -0
  40. package/dist/slices/subtitles/captions.js +4 -1
  41. package/dist/slices/subtitles/layout.js +19 -0
  42. package/dist/slices/subtitles/model.js +9 -0
  43. package/dist/slices/subtitles/prepare.js +6 -1
  44. package/dist/updater/candidate.js +28 -0
  45. package/dist/updater/forward.js +35 -0
  46. package/dist/updater/install-flow.js +26 -0
  47. package/dist/updater/install.js +50 -0
  48. package/dist/updater/model.js +24 -0
  49. package/dist/updater/plan.js +135 -0
  50. package/dist/updater/readiness.js +15 -0
  51. package/dist/updater/registry.js +19 -0
  52. package/dist/updater/service.js +128 -0
  53. package/dist/updater/worker.js +136 -0
  54. package/dist/web/assets/index-6QIQ-TzO.css +1 -0
  55. package/dist/web/assets/{index-CNYs0noB.js → index-CNgWL-ct.js} +24 -24
  56. package/dist/web/index.html +2 -2
  57. package/package.json +1 -1
  58. package/dist/web/assets/index-Mk0bvBg-.css +0 -1
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
5
5
  import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
6
6
  import { providerById, providerIds } from "../../slices/settings/model.js";
7
+ import { createModelCatalog } from "../../slices/settings/models.js";
7
8
  import { providerStatuses } from "../../slices/settings/readiness.js";
8
9
  import { onInvalid, problem, titleOf } from "./problem.js";
9
10
  const providerParam = z.object({ id: z.enum(providerIds) });
@@ -17,10 +18,22 @@ const keyBody = z.object({ key: z.string().min(1).max(4096) });
17
18
  export function providerRoutes(deps) {
18
19
  const keys = { db: deps.db, clock: deps.clock };
19
20
  const readiness = { db: deps.db, probe: deps.probe };
21
+ const catalog = createModelCatalog({
22
+ load: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
23
+ fallback: deps.fallbackModelsFor ?? (() => []),
24
+ now: () => deps.clock.now().getTime(),
25
+ report: (provider) => deps.log.write("warn", "model-catalog", { detail: `Could not load models for ${provider}` }),
26
+ });
20
27
  return (new Hono()
21
28
  // What Settings draws its rails from and Play its dropdowns: every provider, with
22
29
  // the one fact that decides whether it is selectable.
23
30
  .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
31
+ .get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
32
+ const { id } = c.req.valid("param");
33
+ const result = await catalog.get(id, providerById(id).family, c.req.query("refresh") === "1");
34
+ c.header("Cache-Control", "no-store");
35
+ return c.json(result);
36
+ })
24
37
  .put("/:id/path", zValidator("param", providerParam, onInvalid), zValidator("json", pathBody, onInvalid), async (c) => {
25
38
  const result = await saveCliPath(readiness, c.req.valid("param").id, c.req.valid("json").path);
26
39
  if (!result.ok)
@@ -30,6 +43,7 @@ export function providerRoutes(deps) {
30
43
  detail: result.message,
31
44
  extensions: { fields: [{ field: "path", message: result.message }] },
32
45
  });
46
+ catalog.invalidate(c.req.valid("param").id);
33
47
  return c.json(result.status);
34
48
  })
35
49
  .put("/:id/key", zValidator("param", providerParam, onInvalid), zValidator("json", keyBody, onInvalid), (c) => {
@@ -38,6 +52,7 @@ export function providerRoutes(deps) {
38
52
  if (!result.ok) {
39
53
  return refusal(c, id, result.reason);
40
54
  }
55
+ catalog.invalidate(id);
41
56
  // The response says a key is stored and shows the mask, never the value.
42
57
  return c.json(keyStatus(keys, id));
43
58
  })
@@ -47,6 +62,7 @@ export function providerRoutes(deps) {
47
62
  if (!result.ok) {
48
63
  return refusal(c, id, result.reason);
49
64
  }
65
+ catalog.invalidate(id);
50
66
  return c.body(null, 204);
51
67
  }));
52
68
  }
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import { problem, titleOf } from "./problem.js";
3
+ export function updateRoutes(deps) {
4
+ const unavailable = {
5
+ currentVersion: deps.version,
6
+ latestVersion: null,
7
+ available: false,
8
+ busy: false,
9
+ canUpdate: false,
10
+ status: "idle",
11
+ blockedReason: "In-app updates are unavailable for this server.",
12
+ };
13
+ return new Hono()
14
+ .get("/ready", (c) => {
15
+ c.header("Cache-Control", "no-store");
16
+ if (!deps.updater?.ready(c.req.header("X-Slopify-Update-Token") ?? ""))
17
+ return problem(c, { status: 403, title: titleOf(403) });
18
+ return c.json({ status: "ok", version: deps.version });
19
+ })
20
+ .post("/activate", async (c) => {
21
+ c.header("Cache-Control", "no-store");
22
+ const token = c.req.header("X-Slopify-Update-Token") ?? "";
23
+ if (!deps.updater?.ready(token))
24
+ return problem(c, { status: 403, title: titleOf(403) });
25
+ if (!(await deps.updater.activate(token)))
26
+ return problem(c, { status: 409, title: titleOf(409) });
27
+ return c.json({ status: "ok", version: deps.version });
28
+ })
29
+ .get("/", async (c) => {
30
+ c.header("Cache-Control", "no-store");
31
+ return c.json(deps.updater === undefined
32
+ ? unavailable
33
+ : await deps.updater.check(c.req.query("refresh") === "1"));
34
+ })
35
+ .post("/", async (c) => {
36
+ const origin = c.req.header("Origin");
37
+ if (origin !== undefined && origin !== new URL(c.req.url).origin)
38
+ return problem(c, {
39
+ status: 403,
40
+ title: titleOf(403),
41
+ detail: "Start updates from the Slopify page on this server.",
42
+ });
43
+ if (deps.updater === undefined)
44
+ return problem(c, { status: 409, title: titleOf(409), detail: unavailable.blockedReason });
45
+ const result = await deps.updater.start();
46
+ if (result.ok)
47
+ return c.json(result.info, 202);
48
+ const status = result.code ?? 409;
49
+ return problem(c, {
50
+ status,
51
+ title: titleOf(status),
52
+ detail: result.info.blockedReason ??
53
+ result.info.error ??
54
+ "No newer published update is available.",
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { updatePlanSchema } from "../updater/plan.js";
3
+ import { performUpdate } from "../updater/worker.js";
4
+ const planPath = process.argv[2];
5
+ try {
6
+ if (planPath === undefined || !process.connected)
7
+ throw new Error("An update plan and parent process are required.");
8
+ const plan = updatePlanSchema.parse(JSON.parse(await readFile(planPath, "utf8")));
9
+ await rm(planPath, { force: true });
10
+ await performUpdate(plan, () => new Promise((resolve, reject) => {
11
+ if (!process.connected) {
12
+ reject(new Error("The update parent disconnected."));
13
+ return;
14
+ }
15
+ const fail = () => {
16
+ clearTimeout(timer);
17
+ reject(new Error("The update parent disconnected."));
18
+ };
19
+ const timer = setTimeout(() => reject(new Error("The update handoff timed out.")), 60_000);
20
+ process.once("disconnect", fail);
21
+ process.once("message", (message) => {
22
+ clearTimeout(timer);
23
+ process.off("disconnect", fail);
24
+ if (typeof message === "object" &&
25
+ message !== null &&
26
+ "type" in message &&
27
+ message.type === "handoff")
28
+ resolve();
29
+ else
30
+ reject(new Error("The update handoff was refused."));
31
+ });
32
+ process.send?.({ type: "installed" }, (error) => {
33
+ if (error !== null)
34
+ fail();
35
+ });
36
+ }));
37
+ if (process.connected)
38
+ process.disconnect?.();
39
+ }
40
+ catch {
41
+ if (process.connected)
42
+ process.disconnect?.();
43
+ process.exitCode = 1;
44
+ }
@@ -0,0 +1,188 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const inactive = { append: () => { }, complete: () => { }, interrupt: () => { } };
3
+ // A preview is a disposable copy of bytes from the paid call already in flight.
4
+ // It never initiates synthesis, persists audio, or backpressures the provider.
5
+ // Readers pull from shared bounded storage, so a slow browser cannot grow a queue.
6
+ export function createAudioPreviewStore(limits = {}) {
7
+ const maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
8
+ const maxEntryBytes = limits.maxEntryBytes ?? 16 * 1024 * 1024;
9
+ const maxEntries = limits.maxEntries ?? 128;
10
+ const keepMs = limits.keepMs ?? 5 * 60_000;
11
+ const now = limits.now ?? Date.now;
12
+ const entries = new Map();
13
+ let retained = 0;
14
+ let closed = false;
15
+ const notify = (entry) => {
16
+ const listeners = [...entry.changed];
17
+ entry.changed.clear();
18
+ for (const listener of listeners)
19
+ listener();
20
+ };
21
+ const stop = (entry, state) => {
22
+ retained -= entry.bytes;
23
+ entry.bytes = 0;
24
+ entry.chunks.length = 0;
25
+ entry.state = state;
26
+ entry.endedAt = now();
27
+ notify(entry);
28
+ };
29
+ const remove = (entry) => {
30
+ stop(entry, "interrupted");
31
+ entries.delete(entry.id);
32
+ };
33
+ const prune = () => {
34
+ for (const entry of entries.values()) {
35
+ if (entry.endedAt !== undefined && now() - entry.endedAt >= keepMs)
36
+ remove(entry);
37
+ }
38
+ };
39
+ const expiry = setInterval(prune, Math.min(keepMs, 30_000));
40
+ expiry.unref();
41
+ const store = {
42
+ begin(projectId, key, label) {
43
+ if (closed)
44
+ return inactive;
45
+ prune();
46
+ for (const entry of entries.values()) {
47
+ if (entry.projectId === projectId && entry.key === key)
48
+ remove(entry);
49
+ }
50
+ // At capacity, completed previews yield to current generation. Otherwise
51
+ // this request has no preview; the normal durable narration still succeeds.
52
+ if (entries.size >= maxEntries) {
53
+ const oldest = [...entries.values()].find((entry) => entry.state !== "streaming");
54
+ if (oldest !== undefined)
55
+ remove(oldest);
56
+ }
57
+ if (entries.size >= maxEntries)
58
+ return inactive;
59
+ const entry = {
60
+ id: randomUUID(),
61
+ projectId,
62
+ key,
63
+ label,
64
+ chunks: [],
65
+ changed: new Set(),
66
+ state: "streaming",
67
+ bytes: 0,
68
+ endedAt: undefined,
69
+ };
70
+ entries.set(entry.id, entry);
71
+ const active = () => entries.get(entry.id) === entry && entry.state === "streaming";
72
+ return {
73
+ append(bytes) {
74
+ if (!active() || bytes.byteLength === 0)
75
+ return;
76
+ if (retained + bytes.byteLength > maxBytes ||
77
+ entry.bytes + bytes.byteLength > maxEntryBytes) {
78
+ stop(entry, "unavailable");
79
+ return;
80
+ }
81
+ // Own these bounded buffers. A provider may reuse its read buffer.
82
+ for (let at = 0; at < bytes.byteLength; at += 64 * 1024) {
83
+ entry.chunks.push(bytes.slice(at, at + 64 * 1024));
84
+ }
85
+ retained += bytes.byteLength;
86
+ entry.bytes += bytes.byteLength;
87
+ notify(entry);
88
+ },
89
+ complete() {
90
+ if (!active())
91
+ return;
92
+ entry.state = "ready";
93
+ entry.endedAt = now();
94
+ notify(entry);
95
+ },
96
+ interrupt() {
97
+ if (active())
98
+ stop(entry, "interrupted");
99
+ },
100
+ };
101
+ },
102
+ list(projectId) {
103
+ prune();
104
+ return [...entries.values()]
105
+ .filter((entry) => entry.projectId === projectId)
106
+ .map(({ id, label, state, bytes }) => ({ id, label, state, bytes }));
107
+ },
108
+ stream(projectId, id, signal) {
109
+ prune();
110
+ const entry = entries.get(id);
111
+ if (entry === undefined ||
112
+ entry.projectId !== projectId ||
113
+ entry.state === "interrupted" ||
114
+ entry.state === "unavailable")
115
+ return undefined;
116
+ return follow(entry, signal);
117
+ },
118
+ clear(projectId) {
119
+ for (const entry of entries.values())
120
+ if (entry.projectId === projectId)
121
+ remove(entry);
122
+ },
123
+ close() {
124
+ closed = true;
125
+ clearInterval(expiry);
126
+ for (const entry of entries.values())
127
+ remove(entry);
128
+ },
129
+ };
130
+ return store;
131
+ }
132
+ function follow(entry, signal) {
133
+ let index = 0;
134
+ let ended = false;
135
+ let wake;
136
+ let control;
137
+ const detach = () => {
138
+ ended = true;
139
+ signal.removeEventListener("abort", abort);
140
+ if (wake !== undefined) {
141
+ entry.changed.delete(wake);
142
+ wake();
143
+ wake = undefined;
144
+ }
145
+ };
146
+ const abort = () => {
147
+ if (ended)
148
+ return;
149
+ detach();
150
+ control?.error(new Error("Audio preview disconnected"));
151
+ };
152
+ return new ReadableStream({
153
+ start(controller) {
154
+ control = controller;
155
+ if (signal.aborted)
156
+ abort();
157
+ else
158
+ signal.addEventListener("abort", abort, { once: true });
159
+ },
160
+ async pull(controller) {
161
+ while (!ended) {
162
+ if (entry.state === "interrupted" || entry.state === "unavailable") {
163
+ detach();
164
+ controller.error(new Error("Audio preview was interrupted"));
165
+ return;
166
+ }
167
+ const bytes = entry.chunks[index];
168
+ if (bytes !== undefined) {
169
+ index += 1;
170
+ controller.enqueue(bytes);
171
+ return;
172
+ }
173
+ if (entry.state === "ready") {
174
+ detach();
175
+ controller.close();
176
+ return;
177
+ }
178
+ await new Promise((resolve) => {
179
+ wake = resolve;
180
+ entry.changed.add(resolve);
181
+ });
182
+ }
183
+ },
184
+ cancel() {
185
+ detach();
186
+ },
187
+ }, { highWaterMark: 0 });
188
+ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { attempt } from "./attempt.js";
2
3
  export function stageProviders(deps, context, pieceId) {
3
4
  const ctx = {
@@ -15,7 +16,18 @@ export function stageProviders(deps, context, pieceId) {
15
16
  // Resolved once; the adapter reads the stored key per request, so a key replaced
16
17
  // mid-run still reaches the next attempt.
17
18
  const port = deps.registry.llm(call.provider);
19
+ const callId = randomUUID();
20
+ const preview = (text, reset) => context.emit({
21
+ type: "llm.preview",
22
+ projectId: context.stage.projectId,
23
+ stage: context.stage.kind,
24
+ callId,
25
+ label: call.previewLabel ?? context.stage.kind,
26
+ text,
27
+ ...(reset === undefined ? {} : { reset }),
28
+ });
18
29
  return attempt(ctx, async (signal, progress) => {
30
+ preview("", true);
19
31
  let text = "";
20
32
  let usage = null;
21
33
  let finishReason = null;
@@ -25,10 +37,12 @@ export function stageProviders(deps, context, pieceId) {
25
37
  ...(call.webSearch === undefined ? {} : { webSearch: call.webSearch }),
26
38
  signal,
27
39
  })) {
40
+ signal.throwIfAborted();
28
41
  // Every event is a sign of life, so the idle clock restarts here.
29
42
  progress();
30
43
  if (event.type === "delta") {
31
44
  text += event.text;
45
+ preview(event.text);
32
46
  }
33
47
  else if (event.type === "done") {
34
48
  usage = event.usage;
@@ -46,33 +60,69 @@ export function stageProviders(deps, context, pieceId) {
46
60
  return answer;
47
61
  }, { kind: "llm", streaming: port.capabilities.streams });
48
62
  },
49
- tts: (call) => {
63
+ tts: (call, observe) => {
50
64
  const port = deps.registry.tts(call.provider);
65
+ let continuation;
66
+ const notify = (event) => {
67
+ try {
68
+ observe?.(event);
69
+ }
70
+ catch {
71
+ // A preview is optional. A broken observer must never repeat a paid call.
72
+ deps.log.write("warn", "audio.preview", {
73
+ projectId: context.stage.projectId,
74
+ stage: "audio",
75
+ detail: "Could not update the live audio preview",
76
+ });
77
+ }
78
+ };
51
79
  return attempt(ctx, async (signal, progress) => {
52
- const spoken = await port.synthesize({
53
- voiceId: call.voiceId,
54
- text: call.text,
55
- signal,
56
- });
57
- const reader = spoken.audio.getReader();
58
- const chunks = [];
59
- let total = 0;
60
- for (;;) {
61
- const { done, value } = await reader.read();
62
- if (done || value === undefined) {
63
- break;
80
+ notify({ type: "start" });
81
+ try {
82
+ const spoken = await port.synthesize({
83
+ model: call.model,
84
+ voiceId: call.voiceId,
85
+ text: call.text,
86
+ signal,
87
+ onActivity: progress,
88
+ continuation: {
89
+ read: () => continuation,
90
+ write: (token) => {
91
+ continuation = token;
92
+ },
93
+ },
94
+ });
95
+ const reader = spoken.audio.getReader();
96
+ const chunks = [];
97
+ let total = 0;
98
+ try {
99
+ for (;;) {
100
+ const { done, value } = await reader.read();
101
+ signal.throwIfAborted();
102
+ if (done || value === undefined)
103
+ break;
104
+ progress();
105
+ chunks.push(value);
106
+ total += value.length;
107
+ notify({ type: "chunk", bytes: value });
108
+ }
64
109
  }
65
- progress();
66
- chunks.push(value);
67
- total += value.length;
110
+ finally {
111
+ reader.releaseLock();
112
+ }
113
+ const bytes = new Uint8Array(total);
114
+ let at = 0;
115
+ for (const chunk of chunks) {
116
+ bytes.set(chunk, at);
117
+ at += chunk.length;
118
+ }
119
+ notify({ type: "complete" });
120
+ return { bytes, container: spoken.container };
68
121
  }
69
- const bytes = new Uint8Array(total);
70
- let at = 0;
71
- for (const chunk of chunks) {
72
- bytes.set(chunk, at);
73
- at += chunk.length;
122
+ catch (error) {
123
+ notify({ type: "interrupted" });
124
+ throw error;
74
125
  }
75
- return { bytes, container: spoken.container };
76
126
  }, { kind: "tts", streaming: port.capabilities.streams });
77
127
  },
78
128
  image: (call) => {
package/dist/main.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
1
3
  import { fileURLToPath } from "node:url";
2
4
  import { serve } from "@hono/node-server";
3
5
  import ffmpegStatic from "ffmpeg-static";
@@ -5,11 +7,9 @@ import { buildRegistry } from "./adapter-registry.js";
5
7
  import { alignSubtitles } from "./adapters/alignment/index.js";
6
8
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
7
9
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
8
- import { cartesiaModel } from "./adapters/tts/cartesia.js";
9
- import { elevenLabsModel } from "./adapters/tts/elevenlabs.js";
10
- import { openAiTtsModel } from "./adapters/tts/openai.js";
11
10
  import { createHub } from "./edge/events/hub.js";
12
11
  import { createApp } from "./edge/http/app.js";
12
+ import { createAudioPreviewStore } from "./kernel/audio-preview.js";
13
13
  import { systemClock } from "./kernel/clock.js";
14
14
  import { openDb } from "./kernel/db/index.js";
15
15
  import { migrate } from "./kernel/db/migrate.js";
@@ -22,6 +22,7 @@ import { dependenciesOf } from "./kernel/runner/graph.js";
22
22
  import { createRunner } from "./kernel/runner/index.js";
23
23
  import { stageProviders } from "./kernel/runner/providers.js";
24
24
  import { readVersion } from "./kernel/version.js";
25
+ import { modelSources } from "./model-catalog.js";
25
26
  import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
26
27
  import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
27
28
  import { runArticle } from "./slices/article/run.js";
@@ -35,6 +36,12 @@ import { createFlusher } from "./slices/telemetry/flush.js";
35
36
  import { record } from "./slices/telemetry/record.js";
36
37
  import { runThumbnail } from "./slices/thumbnail/run.js";
37
38
  import { renderVideo } from "./slices/video/run.js";
39
+ import { watchActivation } from "./updater/candidate.js";
40
+ import { launchUpdate } from "./updater/install.js";
41
+ import { isUpdateToken } from "./updater/model.js";
42
+ import { npmCommand, updateCommitted } from "./updater/plan.js";
43
+ import { publishedVersion } from "./updater/registry.js";
44
+ import { createUpdater } from "./updater/service.js";
38
45
  // ceiling: a burst of finished stages coalesces into one delivery a second later, and the
39
46
  // collector gets ten seconds to answer before the attempt is abandoned and the events
40
47
  // stay queued.
@@ -78,25 +85,91 @@ export async function boot(config) {
78
85
  clock,
79
86
  probe: nodeCliProbe,
80
87
  });
81
- const runner = wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg });
88
+ const audioPreviews = createAudioPreviewStore();
89
+ const runner = wire({
90
+ db,
91
+ paths,
92
+ clock,
93
+ ids,
94
+ log,
95
+ hub,
96
+ telemetry,
97
+ flusher,
98
+ registry,
99
+ ffmpeg,
100
+ audioPreviews,
101
+ });
102
+ const updateDb = db;
103
+ const oldEntry = fileURLToPath(new URL("./edge/cli.js", import.meta.url));
104
+ const workerEntry = fileURLToPath(new URL("./edge/update-worker.js", import.meta.url));
105
+ let npm;
106
+ try {
107
+ npm = await npmCommand();
108
+ }
109
+ catch {
110
+ log.write("warn", "update", {
111
+ detail: "npm is unavailable; in-app installation is disabled.",
112
+ });
113
+ }
114
+ let shutdown = async () => {
115
+ throw new Error("The server is not ready.");
116
+ };
117
+ let listeningPort = config.port;
118
+ const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
119
+ const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
120
+ const updater = createUpdater({
121
+ ...(isUpdateToken(candidateToken)
122
+ ? {
123
+ candidate: {
124
+ token: candidateToken,
125
+ pending: pendingActivation,
126
+ committed: () => updateCommitted(paths.dataDir, version, candidateToken),
127
+ },
128
+ }
129
+ : {}),
130
+ currentVersion: version,
131
+ previousUpdateFailed: process.env.SLOPIFY_UPDATE_FAILED === "1",
132
+ now: () => Date.now(),
133
+ latest: () => publishedVersion(globalThis.fetch),
134
+ unsupported: () => !existsSync(oldEntry) || !existsSync(workerEntry)
135
+ ? "Run Slopify from its installed package to use in-app updates."
136
+ : npm === undefined
137
+ ? "npm is unavailable. Install Node.js with npm to use in-app updates."
138
+ : undefined,
139
+ busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
140
+ undefined ||
141
+ updateDb
142
+ .prepare("SELECT id FROM projects")
143
+ .all()
144
+ .some((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true),
145
+ report: (message) => log.write("warn", "update", { detail: message }),
146
+ install: async (next, restarting) => {
147
+ if (npm === undefined)
148
+ throw new Error("npm is unavailable.");
149
+ await launchUpdate(workerEntry, {
150
+ version: next,
151
+ token: randomBytes(32).toString("hex"),
152
+ previousVersion: version,
153
+ oldEntry,
154
+ dataDir: paths.dataDir,
155
+ cwd: process.cwd(),
156
+ host: config.host,
157
+ port: listeningPort,
158
+ npm: { file: npm.file, args: [...npm.args] },
159
+ }, async () => {
160
+ restarting();
161
+ await shutdown();
162
+ });
163
+ },
164
+ });
82
165
  const app = createApp({
83
166
  db,
84
167
  paths,
85
168
  hub,
86
169
  runner,
87
- modelsFor: (provider, family) => {
88
- if (family === "llm")
89
- return registry.llm(provider).models();
90
- if (family === "image")
91
- return registry.image(provider).models();
92
- const models = {
93
- elevenlabs: elevenLabsModel,
94
- "openai-tts": openAiTtsModel,
95
- cartesia: cartesiaModel,
96
- };
97
- const id = models[provider];
98
- return Promise.resolve(id === undefined ? [] : [{ id, name: id }]);
99
- },
170
+ updater,
171
+ audioPreviews,
172
+ ...modelSources(registry),
100
173
  clock,
101
174
  ids,
102
175
  log,
@@ -106,14 +179,17 @@ export async function boot(config) {
106
179
  probe: nodeCliProbe,
107
180
  });
108
181
  const server = await listen(app, config, log);
182
+ listeningPort = portOf(server) ?? config.port;
109
183
  // Whatever last run left queued goes out at start. Nothing waits for
110
184
  // it, and an unreachable collector costs one refused socket.
111
185
  flusher.soon();
112
186
  const open = db;
113
- return {
114
- paths,
115
- url: urlOf(config.host, portOf(server) ?? config.port),
116
- stop: async () => {
187
+ let stopping;
188
+ let stopActivation = () => { };
189
+ shutdown = () => {
190
+ stopping ??= (async () => {
191
+ stopActivation();
192
+ audioPreviews.close();
117
193
  try {
118
194
  // The listener goes first. Aborting the runner while the socket still accepted
119
195
  // requests let a Play arriving during the await start a stage under a fresh
@@ -132,8 +208,12 @@ export async function boot(config) {
132
208
  open.close();
133
209
  lock.release();
134
210
  }
135
- },
211
+ })();
212
+ return stopping;
136
213
  };
214
+ if (pendingActivation)
215
+ stopActivation = watchActivation(updater, candidateToken, shutdown, (message) => log.write("warn", "update", { detail: message }));
216
+ return { paths, url: urlOf(config.host, listeningPort), stop: shutdown };
137
217
  }
138
218
  catch (error) {
139
219
  db?.close();
@@ -141,7 +221,7 @@ export async function boot(config) {
141
221
  throw error;
142
222
  }
143
223
  }
144
- function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg, }) {
224
+ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg, }) {
145
225
  // A stage counts what it did and the queue is flushed after each new event. `record`
146
226
  // swallows its own failures, so this can neither fail a stage nor widen what leaves the
147
227
  // machine - the payload allow-list is checked inside it.
@@ -177,7 +257,7 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
177
257
  audio: async (context) => {
178
258
  const wrapped = stageProviders(providers, context);
179
259
  await prepareProvidedArticleSegments(writing, context, wrapped);
180
- await runNarration(video, context, wrapped);
260
+ await runNarration({ ...video, audioPreviews }, context, wrapped);
181
261
  },
182
262
  images: (context) => runImages(writing, context, stageProviders(providers, context)),
183
263
  thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),