@gentbajko/slopify 0.5.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 (104) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +21 -0
  3. package/dist/adapter-registry.js +18 -3
  4. package/dist/adapters/alignment/audio.js +45 -0
  5. package/dist/adapters/alignment/cache.js +91 -0
  6. package/dist/adapters/alignment/ctc.js +128 -0
  7. package/dist/adapters/alignment/index.js +34 -0
  8. package/dist/adapters/alignment/lock.js +58 -0
  9. package/dist/adapters/alignment/numbers.js +91 -0
  10. package/dist/adapters/alignment/protocol.js +23 -0
  11. package/dist/adapters/alignment/quality.js +17 -0
  12. package/dist/adapters/alignment/runner.js +76 -0
  13. package/dist/adapters/alignment/text.js +61 -0
  14. package/dist/adapters/alignment/vocabulary.js +69 -0
  15. package/dist/adapters/alignment/worker.js +163 -0
  16. package/dist/adapters/image/google.js +12 -15
  17. package/dist/adapters/image/models.js +72 -0
  18. package/dist/adapters/image/openai.js +3 -5
  19. package/dist/adapters/llm/catalogue-files.js +23 -0
  20. package/dist/adapters/llm/claude-code.js +9 -7
  21. package/dist/adapters/llm/codex-models.js +46 -0
  22. package/dist/adapters/llm/codex.js +7 -12
  23. package/dist/adapters/llm/gemini-models.js +85 -0
  24. package/dist/adapters/llm/gemini-workspace.js +70 -0
  25. package/dist/adapters/llm/gemini.js +138 -0
  26. package/dist/adapters/llm/openrouter.js +11 -2
  27. package/dist/adapters/llm/run-cli.js +6 -9
  28. package/dist/adapters/tts/cartesia.js +9 -1
  29. package/dist/adapters/tts/elevenlabs.js +33 -2
  30. package/dist/adapters/tts/inworld-async.js +150 -0
  31. package/dist/adapters/tts/inworld-text.js +32 -0
  32. package/dist/adapters/tts/inworld.js +170 -0
  33. package/dist/adapters/tts/openai.js +31 -4
  34. package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
  35. package/dist/assets/fonts/OFL.txt +93 -0
  36. package/dist/assets/fonts/SOURCE.txt +7 -0
  37. package/dist/edge/cli.js +5 -0
  38. package/dist/edge/events/hub.js +9 -2
  39. package/dist/edge/events/preview-cache.js +40 -0
  40. package/dist/edge/http/actions.js +2 -0
  41. package/dist/edge/http/app.js +30 -1
  42. package/dist/edge/http/audio-preview.js +46 -0
  43. package/dist/edge/http/fonts.js +147 -0
  44. package/dist/edge/http/projects.js +23 -1
  45. package/dist/edge/http/providers.js +29 -0
  46. package/dist/edge/http/subtitles.js +92 -0
  47. package/dist/edge/http/update.js +57 -0
  48. package/dist/edge/update-worker.js +44 -0
  49. package/dist/kernel/audio-preview.js +188 -0
  50. package/dist/kernel/cli-command.js +45 -0
  51. package/dist/kernel/ports/subtitles.js +1 -0
  52. package/dist/kernel/runner/providers.js +75 -24
  53. package/dist/main.js +106 -25
  54. package/dist/model-catalog.js +37 -0
  55. package/dist/slices/admission/repo.js +2 -0
  56. package/dist/slices/admission/rules.js +6 -0
  57. package/dist/slices/control/index.js +1 -0
  58. package/dist/slices/control/providers.js +2 -0
  59. package/dist/slices/fonts/catalog.js +95 -0
  60. package/dist/slices/fonts/discovery.js +81 -0
  61. package/dist/slices/fonts/files.js +37 -0
  62. package/dist/slices/fonts/index.js +5 -0
  63. package/dist/slices/fonts/model.js +4 -0
  64. package/dist/slices/fonts/preview.js +14 -0
  65. package/dist/slices/fonts/sfnt.js +262 -0
  66. package/dist/slices/fonts/upload.js +40 -0
  67. package/dist/slices/narration/live.js +21 -0
  68. package/dist/slices/narration/run.js +9 -9
  69. package/dist/slices/research/run.js +3 -0
  70. package/dist/slices/settings/cli-paths.js +98 -0
  71. package/dist/slices/settings/cli-status.js +21 -12
  72. package/dist/slices/settings/model.js +11 -0
  73. package/dist/slices/settings/models.js +89 -0
  74. package/dist/slices/settings/readiness.js +13 -9
  75. package/dist/slices/storage/downloads.js +2 -0
  76. package/dist/slices/storage/layout.js +10 -0
  77. package/dist/slices/storage/model.js +5 -0
  78. package/dist/slices/storage/repo.js +1 -0
  79. package/dist/slices/subtitles/captions.js +91 -0
  80. package/dist/slices/subtitles/layout.js +19 -0
  81. package/dist/slices/subtitles/model.js +27 -0
  82. package/dist/slices/subtitles/prepare.js +167 -0
  83. package/dist/slices/subtitles/transcript.js +34 -0
  84. package/dist/slices/video/audio-export.js +3 -0
  85. package/dist/slices/video/ffmpeg.js +14 -7
  86. package/dist/slices/video/plan.js +4 -4
  87. package/dist/slices/video/run.js +5 -2
  88. package/dist/slices/video/write-export.js +85 -20
  89. package/dist/updater/candidate.js +28 -0
  90. package/dist/updater/forward.js +35 -0
  91. package/dist/updater/install-flow.js +26 -0
  92. package/dist/updater/install.js +50 -0
  93. package/dist/updater/model.js +24 -0
  94. package/dist/updater/plan.js +135 -0
  95. package/dist/updater/readiness.js +15 -0
  96. package/dist/updater/registry.js +19 -0
  97. package/dist/updater/service.js +128 -0
  98. package/dist/updater/worker.js +136 -0
  99. package/dist/web/assets/index-6QIQ-TzO.css +1 -0
  100. package/dist/web/assets/index-CNgWL-ct.js +130 -0
  101. package/dist/web/index.html +2 -2
  102. package/package.json +4 -2
  103. package/dist/web/assets/index-C1fsrIP5.css +0 -1
  104. package/dist/web/assets/index-DwJ_XTl3.js +0 -81
@@ -0,0 +1,14 @@
1
+ import { missingFont, resolveFont } from "./catalog.js";
2
+ import { readFontFile } from "./files.js";
3
+ import { standaloneFont } from "./sfnt.js";
4
+ export async function previewFont(paths, id) {
5
+ const font = await resolveFont(paths, id);
6
+ const bytes = await readFontFile(font.path);
7
+ const content = bytes === undefined ? undefined : standaloneFont(bytes, font.faceIndex);
8
+ if (content === undefined)
9
+ throw missingFont();
10
+ return {
11
+ content,
12
+ contentType: Buffer.from(content).readUInt32BE(0) === 0x4f54544f ? "font/otf" : "font/ttf",
13
+ };
14
+ }
@@ -0,0 +1,262 @@
1
+ import { fontMaxBytes } from "./model.js";
2
+ const trueType = 0x00010000;
3
+ const openType = 0x4f54544f;
4
+ const collection = 0x74746366;
5
+ // Metadata only: outlines remain FreeType's responsibility. Every read and allocation
6
+ // is bounded before using file-controlled offsets, lengths or counts.
7
+ export function readFontMetadata(content) {
8
+ const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
9
+ const faces = directories(bytes);
10
+ if (faces === undefined)
11
+ return undefined;
12
+ const result = [];
13
+ for (const [index, face] of faces.entries()) {
14
+ const names = fontNames(bytes, face.tables.get("name"));
15
+ if (names === undefined)
16
+ return undefined;
17
+ const family = names.get(1) ?? names.get(16);
18
+ const name = names.get(4) ?? family;
19
+ const cff = face.tables.has("CFF ") || face.tables.has("CFF2");
20
+ const assName = cff ? (names.get(6) ?? family) : (names.get(4) ?? names.get(1));
21
+ if (!usable(family) || !usable(name) || !usable(assName))
22
+ return undefined;
23
+ result.push({
24
+ family,
25
+ name,
26
+ assName,
27
+ index,
28
+ extension: bytes.readUInt32BE(0) === collection ? ".ttc" : cff ? ".otf" : ".ttf",
29
+ });
30
+ }
31
+ return result;
32
+ }
33
+ // Browsers do not consistently address individual TTC faces. Repackage just the
34
+ // selected face, retaining its original tables and names, with fresh SFNT checksums.
35
+ export function standaloneFont(content, index) {
36
+ const bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
37
+ const face = directories(bytes)?.[index];
38
+ if (face === undefined || readFontMetadata(content) === undefined)
39
+ return undefined;
40
+ if (bytes.readUInt32BE(0) !== collection)
41
+ return Buffer.from(bytes);
42
+ const tables = [...face.tables.entries()].filter(([tag]) => tag !== "DSIG");
43
+ const header = 12 + tables.length * 16;
44
+ const size = tables.reduce((sum, [, table]) => sum + padded(table.length), header);
45
+ if (size > fontMaxBytes)
46
+ return undefined;
47
+ const result = Buffer.alloc(size);
48
+ result.writeUInt32BE(bytes.readUInt32BE(face.offset), 0);
49
+ result.writeUInt16BE(tables.length, 4);
50
+ const power = Math.floor(Math.log2(tables.length));
51
+ result.writeUInt16BE(2 ** power * 16, 6);
52
+ result.writeUInt16BE(power, 8);
53
+ result.writeUInt16BE(tables.length * 16 - 2 ** power * 16, 10);
54
+ let offset = header;
55
+ let head = 0;
56
+ for (const [index, [tag, table]] of tables.entries()) {
57
+ const record = 12 + index * 16;
58
+ result.write(tag, record, 4, "ascii");
59
+ bytes.copy(result, offset, table.offset, table.offset + table.length);
60
+ if (tag === "head") {
61
+ head = offset;
62
+ result.writeUInt32BE(0, head + 8);
63
+ }
64
+ result.writeUInt32BE(checksum(result.subarray(offset, offset + padded(table.length))), record + 4);
65
+ result.writeUInt32BE(offset, record + 8);
66
+ result.writeUInt32BE(table.length, record + 12);
67
+ offset += padded(table.length);
68
+ }
69
+ result.writeUInt32BE((0xb1b0afba - checksum(result)) >>> 0, head + 8);
70
+ return result;
71
+ }
72
+ function directories(bytes) {
73
+ if (bytes.length < 12 || bytes.length > fontMaxBytes)
74
+ return undefined;
75
+ const offsets = [0];
76
+ if (bytes.readUInt32BE(0) === collection) {
77
+ const version = bytes.readUInt32BE(4);
78
+ const count = bytes.readUInt32BE(8);
79
+ if (![0x00010000, 0x00020000].includes(version) ||
80
+ count < 1 ||
81
+ count > 256 ||
82
+ 12 + count * 4 > bytes.length)
83
+ return undefined;
84
+ offsets.splice(0);
85
+ for (let at = 0; at < count; at += 1)
86
+ offsets.push(bytes.readUInt32BE(12 + at * 4));
87
+ if (new Set(offsets).size !== offsets.length)
88
+ return undefined;
89
+ }
90
+ const faces = [];
91
+ for (const offset of offsets) {
92
+ if (!within(bytes, offset, 12) || ![trueType, openType].includes(bytes.readUInt32BE(offset)))
93
+ return undefined;
94
+ const count = bytes.readUInt16BE(offset + 4);
95
+ if (count < 1 || count > 256 || !within(bytes, offset + 12, count * 16))
96
+ return undefined;
97
+ const tables = new Map();
98
+ for (let at = 0; at < count; at += 1) {
99
+ const record = offset + 12 + at * 16;
100
+ const tag = bytes.toString("ascii", record, record + 4);
101
+ const table = {
102
+ offset: bytes.readUInt32BE(record + 8),
103
+ length: bytes.readUInt32BE(record + 12),
104
+ };
105
+ if (tables.has(tag) || table.offset % 4 !== 0 || !within(bytes, table.offset, table.length))
106
+ return undefined;
107
+ tables.set(tag, table);
108
+ }
109
+ const spans = [...tables.values()]
110
+ .filter((table) => table.length > 0)
111
+ .toSorted((a, b) => a.offset - b.offset);
112
+ if (spans.some((table, at) => at > 0 && (spans[at - 1]?.offset ?? 0) + (spans[at - 1]?.length ?? 0) > table.offset))
113
+ return undefined;
114
+ const cff = tables.has("CFF ") || tables.has("CFF2");
115
+ if ((bytes.readUInt32BE(offset) === openType) !== cff || !validTables(bytes, tables))
116
+ return undefined;
117
+ faces.push({ offset, tables });
118
+ }
119
+ return faces;
120
+ }
121
+ function validTables(bytes, tables) {
122
+ const minimum = {
123
+ head: 54,
124
+ maxp: 6,
125
+ cmap: 4,
126
+ hhea: 36,
127
+ hmtx: 4,
128
+ name: 6,
129
+ };
130
+ for (const [tag, size] of Object.entries(minimum))
131
+ if ((tables.get(tag)?.length ?? 0) < size)
132
+ return false;
133
+ const head = tables.get("head");
134
+ const maxp = tables.get("maxp");
135
+ const hhea = tables.get("hhea");
136
+ const hmtx = tables.get("hmtx");
137
+ if (!head || !maxp || !hhea || !hmtx)
138
+ return false;
139
+ const glyphs = bytes.readUInt16BE(maxp.offset + 4);
140
+ const metrics = bytes.readUInt16BE(hhea.offset + 34);
141
+ const units = bytes.readUInt16BE(head.offset + 18);
142
+ if (bytes.readUInt32BE(head.offset + 12) !== 0x5f0f3cf5 ||
143
+ units < 16 ||
144
+ units > 16384 ||
145
+ glyphs === 0 ||
146
+ metrics === 0 ||
147
+ metrics > glyphs ||
148
+ hmtx.length < metrics * 4 + (glyphs - metrics) * 2)
149
+ return false;
150
+ const cff = tables.get("CFF ") ?? tables.get("CFF2");
151
+ if (!validCmap(bytes, tables.get("cmap")))
152
+ return false;
153
+ if (cff)
154
+ return cff.length >= 4 && [1, 2].includes(bytes[cff.offset] ?? 0);
155
+ const glyf = tables.get("glyf");
156
+ const loca = tables.get("loca");
157
+ const format = bytes.readInt16BE(head.offset + 50);
158
+ if (!glyf ||
159
+ !loca ||
160
+ (format !== 0 && format !== 1) ||
161
+ loca.length < (glyphs + 1) * (format === 0 ? 2 : 4))
162
+ return false;
163
+ let previous = 0;
164
+ for (let at = 0; at <= glyphs; at += 1) {
165
+ const next = format === 0
166
+ ? bytes.readUInt16BE(loca.offset + at * 2) * 2
167
+ : bytes.readUInt32BE(loca.offset + at * 4);
168
+ if (next < previous || next > glyf.length)
169
+ return false;
170
+ previous = next;
171
+ }
172
+ return true;
173
+ }
174
+ function validCmap(bytes, table) {
175
+ if (!table || table.length < 4 || bytes.readUInt16BE(table.offset) !== 0)
176
+ return false;
177
+ const count = bytes.readUInt16BE(table.offset + 2);
178
+ if (count < 1 || count > 512 || table.length < 4 + count * 8)
179
+ return false;
180
+ let supported = false;
181
+ for (let at = 0; at < count; at += 1) {
182
+ const offset = bytes.readUInt32BE(table.offset + 4 + at * 8 + 4);
183
+ if (offset < 4 + count * 8 || offset + 4 > table.length)
184
+ return false;
185
+ const format = bytes.readUInt16BE(table.offset + offset);
186
+ const long = [8, 10, 12, 13].includes(format);
187
+ if (long && offset + 8 > table.length)
188
+ return false;
189
+ const length = format === 14
190
+ ? offset + 6 <= table.length
191
+ ? bytes.readUInt32BE(table.offset + offset + 2)
192
+ : 0
193
+ : long
194
+ ? bytes.readUInt32BE(table.offset + offset + 4)
195
+ : bytes.readUInt16BE(table.offset + offset + 2);
196
+ if (length < 4 || offset + length > table.length)
197
+ return false;
198
+ if ([0, 4, 6, 10, 12, 13].includes(format))
199
+ supported = true;
200
+ }
201
+ return supported;
202
+ }
203
+ function fontNames(bytes, table) {
204
+ if (!table || table.length < 6)
205
+ return undefined;
206
+ const format = bytes.readUInt16BE(table.offset);
207
+ const count = bytes.readUInt16BE(table.offset + 2);
208
+ const storage = bytes.readUInt16BE(table.offset + 4);
209
+ if (format > 1 || count > 4096 || storage < 6 + count * 12 || storage > table.length)
210
+ return undefined;
211
+ const names = new Map();
212
+ for (let at = 0; at < count; at += 1) {
213
+ const record = table.offset + 6 + at * 12;
214
+ const platform = bytes.readUInt16BE(record);
215
+ const encoding = bytes.readUInt16BE(record + 2);
216
+ const language = bytes.readUInt16BE(record + 4);
217
+ const id = bytes.readUInt16BE(record + 6);
218
+ const length = bytes.readUInt16BE(record + 8);
219
+ const offset = bytes.readUInt16BE(record + 10);
220
+ if (storage + offset + length > table.length)
221
+ return undefined;
222
+ if (![1, 4, 6, 16].includes(id) ||
223
+ (platform !== 0 && !(platform === 3 && [0, 1, 10].includes(encoding))))
224
+ continue;
225
+ if (length === 0 || length > 1024 || length % 2 !== 0)
226
+ return undefined;
227
+ const score = (platform === 3 ? 2 : 0) + (language === 0x409 ? 1 : 0);
228
+ if ((names.get(id)?.score ?? -1) > score)
229
+ continue;
230
+ let value;
231
+ try {
232
+ value = new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(table.offset + storage + offset, table.offset + storage + offset + length));
233
+ }
234
+ catch {
235
+ return undefined;
236
+ }
237
+ names.set(id, { value, score });
238
+ }
239
+ return new Map([...names].map(([id, entry]) => [id, entry.value]));
240
+ }
241
+ function usable(value) {
242
+ return (value !== undefined &&
243
+ value.length > 0 &&
244
+ value.length <= 256 &&
245
+ !value.includes(",") &&
246
+ ![...value].some((character) => {
247
+ const point = character.codePointAt(0) ?? 0;
248
+ return point < 0x20 || (point >= 0x7f && point <= 0x9f);
249
+ }));
250
+ }
251
+ function within(bytes, offset, length) {
252
+ return offset >= 0 && length >= 0 && offset <= bytes.length && length <= bytes.length - offset;
253
+ }
254
+ function padded(value) {
255
+ return Math.ceil(value / 4) * 4;
256
+ }
257
+ function checksum(bytes) {
258
+ let sum = 0;
259
+ for (let at = 0; at < bytes.length; at += 4)
260
+ sum = (sum + bytes.readUInt32BE(at)) >>> 0;
261
+ return sum;
262
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { extname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import { fontMaxBytes } from "./model.js";
6
+ import { readFontMetadata } from "./sfnt.js";
7
+ const filename = z
8
+ .string()
9
+ .min(1)
10
+ .max(255)
11
+ .refine((value) => !value.includes("/") &&
12
+ !value.includes("\\") &&
13
+ !value.includes(":") &&
14
+ ![...value].some((character) => (character.codePointAt(0) ?? 0) < 32));
15
+ export async function uploadFont(paths, input) {
16
+ if (!filename.safeParse(input.filename).success)
17
+ return { ok: false, reason: "unsafe-filename" };
18
+ if (![".ttf", ".otf"].includes(extname(input.filename).toLowerCase()))
19
+ return { ok: false, reason: "unsupported-format" };
20
+ if (input.content.byteLength > fontMaxBytes)
21
+ return { ok: false, reason: "too-large" };
22
+ const metadata = readFontMetadata(input.content);
23
+ const face = metadata?.[0];
24
+ if (face === undefined || metadata?.length !== 1 || face.extension === ".ttc")
25
+ return { ok: false, reason: "invalid-font" };
26
+ const id = `uploaded-${createHash("sha256").update(input.content).digest("hex")}`;
27
+ const root = join(paths.dataDir, "fonts");
28
+ await mkdir(root, { recursive: true, mode: 0o700 });
29
+ if (!(await lstat(root)).isDirectory())
30
+ throw new Error("The font storage directory is unavailable.");
31
+ const temporary = join(root, `.upload-${randomUUID()}`);
32
+ try {
33
+ await writeFile(temporary, input.content, { mode: 0o600, flag: "wx" });
34
+ await rename(temporary, join(root, `${id}${face.extension}`));
35
+ }
36
+ finally {
37
+ await rm(temporary, { force: true });
38
+ }
39
+ return { ok: true, font: { id, name: face.name, family: face.family, source: "uploaded" } };
40
+ }
@@ -0,0 +1,21 @@
1
+ export function observeNarration(store, projectId, key, label) {
2
+ if (store === undefined)
3
+ return undefined;
4
+ let sink;
5
+ return (event) => {
6
+ switch (event.type) {
7
+ case "start":
8
+ sink = store.begin(projectId, key, label);
9
+ break;
10
+ case "chunk":
11
+ sink?.append(event.bytes);
12
+ break;
13
+ case "complete":
14
+ sink?.complete();
15
+ break;
16
+ case "interrupted":
17
+ sink?.interrupt();
18
+ break;
19
+ }
20
+ };
21
+ }
@@ -11,6 +11,7 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
11
11
  import { probeDurationMs } from "../video/ffmpeg.js";
12
12
  import { chunkNarration, defaultChunking } from "./chunk.js";
13
13
  import { joinNarration } from "./concat.js";
14
+ import { observeNarration } from "./live.js";
14
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
15
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
16
17
  export const nothingToNarrate = "nothing to narrate";
@@ -31,6 +32,7 @@ const segmentPayload = z.object({
31
32
  const chunkDir = "audio-chunks";
32
33
  export async function runNarration(deps, context, providers) {
33
34
  const { projectId } = context.stage;
35
+ deps.audioPreviews?.clear(projectId);
34
36
  const project = projectById(deps.db, projectId);
35
37
  if (project === undefined) {
36
38
  throw new Error(`project ${projectId} has no row`);
@@ -110,9 +112,10 @@ async function speakChunks(deps, context, providers, choice, pieces) {
110
112
  try {
111
113
  const spoken = await providers.forPiece(piece.id).tts({
112
114
  provider: choice.provider,
115
+ model: choice.model,
113
116
  voiceId: choice.voice,
114
117
  text: carried.text,
115
- });
118
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
116
119
  write(deps, projectId, file, spoken.bytes);
117
120
  // The file is on disk before the row says so: a crash between the two leaves a
118
121
  // file the reconcile collects, where the other order would leave a `done` chunk
@@ -180,9 +183,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
180
183
  }
181
184
  const spoken = await providers.forPiece(piece.id).tts({
182
185
  provider: choice.provider,
186
+ model: choice.model,
183
187
  voiceId: choice.voice,
184
188
  text,
185
- });
189
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
186
190
  const name = outputFileName(role, 1, ".mp3", "audio");
187
191
  write(deps, projectId, name, spoken.bytes);
188
192
  // Measured the same way the body is, because the render adds all three and the gaps to
@@ -208,14 +212,13 @@ function segmentPieces(deps, projectId) {
208
212
  }
209
213
  return piecesOf(deps.db, article.id, "segment");
210
214
  }
211
- // Audio seconds come from the measured duration per segment, never from the text's length:
212
- // the real duration goes on the row and the video timeline is built from it. No model is
213
- // named because the TTS port carries none - the same reason `store` above leaves it off.
215
+ // Audio seconds come from the measured duration per segment, never from the text's length.
214
216
  function counted(deps, segment, durationMs, choice) {
215
217
  deps.count("stage.completed", {
216
218
  stage: "audio",
217
219
  segment,
218
220
  provider: choice.provider,
221
+ model: choice.model,
219
222
  audioSeconds: durationMs / 1000,
220
223
  });
221
224
  }
@@ -246,9 +249,6 @@ function write(deps, projectId, path, bytes) {
246
249
  mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
247
250
  writeFileSync(target, bytes, { mode: 0o600 });
248
251
  }
249
- // The provider and the voice are stored with the audio they made. The model is not:
250
- // the TTS port carries no model, so every request went to the adapter's own, and writing
251
- // the run's dropdown value here would record something that was never sent.
252
252
  function store(deps, projectId, role, path, durationMs, choice) {
253
253
  insertOutput(deps.db, {
254
254
  id: deps.ids.next(),
@@ -259,7 +259,7 @@ function store(deps, projectId, role, path, durationMs, choice) {
259
259
  originalFilename: null,
260
260
  bytes: statSync(outputPath(deps.paths, projectId, path)).size,
261
261
  durationMs,
262
- meta: { provider: choice.provider, voice: choice.voice },
262
+ meta: { provider: choice.provider, model: choice.model, voice: choice.voice },
263
263
  createdAt: deps.clock.now().toISOString(),
264
264
  });
265
265
  }
@@ -53,6 +53,7 @@ async function research(deps, context, providers, choice, brief) {
53
53
  provider: choice.provider,
54
54
  model: choice.model,
55
55
  messages: synthesisMessages(brief, findings),
56
+ previewLabel: "Writing research notes",
56
57
  check: (given) => sourcedAnswer("the synthesis", given.text),
57
58
  });
58
59
  add(answer.usage);
@@ -89,6 +90,7 @@ async function plan(deps, context, providers, choice, brief, add) {
89
90
  provider: choice.provider,
90
91
  model: choice.model,
91
92
  messages: plannerMessages(brief),
93
+ previewLabel: "Planning research",
92
94
  // An empty answer, or one with no chapter in it, is a failed attempt.
93
95
  check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
94
96
  });
@@ -129,6 +131,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
129
131
  provider: choice.provider,
130
132
  model: choice.model,
131
133
  messages: subAgentMessages(brief, kept.title, outline),
134
+ previewLabel: kept.title,
132
135
  // Grounding is asked for explicitly, so a model without it says so
133
136
  // instead of answering from what it already knows.
134
137
  webSearch: true,
@@ -0,0 +1,98 @@
1
+ import { constants } from "node:fs";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { extname, isAbsolute } from "node:path";
4
+ import { z } from "zod";
5
+ import { cliProbeTimeoutMs, readinessFromProbe } from "./cli-status.js";
6
+ import { providerById } from "./model.js";
7
+ import { readSetting, writeSetting } from "./repo.js";
8
+ export const cliPathMaxLength = 4096;
9
+ const pathSchema = z
10
+ .string()
11
+ .trim()
12
+ .max(cliPathMaxLength)
13
+ .refine((value) => value === "" || (isAbsolute(value) && !/[\p{Cc}]/u.test(value)));
14
+ const storedPathSchema = z.string().max(cliPathMaxLength).nullable();
15
+ const saves = new WeakMap();
16
+ export function cliPathStatus(db, id) {
17
+ const provider = providerById(id);
18
+ if (provider.auth !== "cli")
19
+ throw new Error(`${id} is not a CLI provider`);
20
+ const stored = readSetting(db, `cli.path.${id}`);
21
+ const configured = stored === undefined ? null : storedPathSchema.parse(JSON.parse(stored));
22
+ return { configured, command: configured ?? provider.binary };
23
+ }
24
+ // Read on each attempt, so a saved path reaches the next invocation without a restart.
25
+ export function cliBinary(db, id) {
26
+ return cliPathStatus(db, id).command;
27
+ }
28
+ export async function saveCliPath(deps, id, raw) {
29
+ // A slow version probe must not overwrite a later Save or Reset from another tab.
30
+ let queue = saves.get(deps.db);
31
+ if (queue === undefined) {
32
+ queue = new Map();
33
+ saves.set(deps.db, queue);
34
+ }
35
+ const previous = queue.get(id) ?? Promise.resolve();
36
+ const next = previous.then(() => save(deps, id, raw));
37
+ const settled = next.then(() => { }, () => { });
38
+ queue.set(id, settled);
39
+ try {
40
+ return await next;
41
+ }
42
+ finally {
43
+ if (queue.get(id) === settled)
44
+ queue.delete(id);
45
+ }
46
+ }
47
+ async function save(deps, id, raw) {
48
+ const provider = providerById(id);
49
+ if (provider.auth !== "cli")
50
+ return { ok: false, message: `${provider.displayName} uses an API key, not a CLI executable.` };
51
+ const parsed = pathSchema.safeParse(raw);
52
+ if (!parsed.success)
53
+ return {
54
+ ok: false,
55
+ message: "Enter an absolute executable file path without quotes or arguments, or leave it blank to use PATH.",
56
+ };
57
+ const configured = parsed.data === "" ? null : parsed.data;
58
+ if (configured !== null) {
59
+ try {
60
+ if (!(await stat(configured)).isFile())
61
+ return { ok: false, message: "Choose an executable file, not a directory." };
62
+ await access(configured, process.platform === "win32" ||
63
+ [".js", ".mjs", ".cjs"].includes(extname(configured).toLowerCase())
64
+ ? constants.R_OK
65
+ : constants.X_OK);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Error &&
69
+ "code" in error &&
70
+ ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)))
71
+ return {
72
+ ok: false,
73
+ message: "That executable file cannot be found or run. Check its path and permissions.",
74
+ };
75
+ throw error;
76
+ }
77
+ }
78
+ const command = configured ?? provider.binary;
79
+ const probed = await deps.probe(command, provider.versionArgs, cliProbeTimeoutMs);
80
+ const readiness = readinessFromProbe(probed);
81
+ if (configured !== null && readiness.kind === "cli" && !readiness.installed)
82
+ return {
83
+ ok: false,
84
+ message: probed.error ??
85
+ `${provider.displayName} did not answer --version successfully within ${cliProbeTimeoutMs / 1000} seconds. Choose its executable file without arguments.`,
86
+ };
87
+ writeSetting(deps.db, `cli.path.${id}`, JSON.stringify(configured));
88
+ return {
89
+ ok: true,
90
+ status: {
91
+ id,
92
+ family: provider.family,
93
+ displayName: provider.displayName,
94
+ readiness,
95
+ cliPath: { configured, command },
96
+ },
97
+ };
98
+ }
@@ -1,24 +1,33 @@
1
1
  import { execFile } from "node:child_process";
2
- // ceiling: readiness is computed at request time, so nothing is cached and a hung CLI costs the
3
- // settings page this long. Both probes run in parallel, so the page waits 2 s in the worst
4
- // case. Caching the answer for a few seconds is the upgrade if a slow machine makes the status
5
- // line flap.
6
- export const cliProbeTimeoutMs = 2000;
2
+ import { cliCommand } from "../../kernel/cli-command.js";
3
+ // Gemini's Node startup can take several seconds on Windows. Providers are probed in
4
+ // parallel; a hung process is bounded without treating ordinary startup as missing.
5
+ export const cliProbeTimeoutMs = 15_000;
7
6
  const probeOutputMax = 64 * 1024;
8
- // The real probe: an argument array, never a shell string, so nothing in a binary name or an
9
- // argument can be interpreted as a command. It resolves for every outcome; a missing CLI is an
10
- // answer, not a failure. ceiling: POSIX only. A Windows install puts `claude` on PATH as a
11
- // `.cmd` shim, which execFile cannot run without a shell; a shim-aware lookup is the upgrade
12
- // when Windows is supported.
7
+ // Known Windows package-manager shims are resolved to an executable plus fixed args;
8
+ // user input never becomes a shell command. Missing/unsupported commands mean not ready.
13
9
  export function nodeCliProbe(binary, args, timeoutMs) {
10
+ let command;
11
+ try {
12
+ command = cliCommand(binary);
13
+ }
14
+ catch (error) {
15
+ return Promise.resolve({
16
+ ran: false,
17
+ stdout: "",
18
+ error: error instanceof Error ? error.message : "The CLI launcher could not be resolved.",
19
+ });
20
+ }
14
21
  return new Promise((resolve) => {
15
- execFile(binary, [...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
22
+ execFile(command.file, [...command.args, ...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
16
23
  resolve(error === null ? { ran: true, stdout } : { ran: false, stdout: "" });
17
24
  });
18
25
  });
19
26
  }
20
27
  export async function cliReadiness(probe, provider) {
21
- const result = await probe(provider.binary, provider.versionArgs, cliProbeTimeoutMs);
28
+ return readinessFromProbe(await probe(provider.binary, provider.versionArgs, cliProbeTimeoutMs));
29
+ }
30
+ export function readinessFromProbe(result) {
22
31
  if (!result.ran) {
23
32
  return { kind: "cli", installed: false };
24
33
  }
@@ -8,9 +8,11 @@ export const providerIds = [
8
8
  "openrouter",
9
9
  "claude-code",
10
10
  "codex",
11
+ "gemini",
11
12
  "elevenlabs",
12
13
  "openai-tts",
13
14
  "cartesia",
15
+ "inworld",
14
16
  "fal",
15
17
  "replicate",
16
18
  "openai-image",
@@ -34,9 +36,18 @@ export const providers = [
34
36
  binary: "codex",
35
37
  versionArgs: ["--version"],
36
38
  },
39
+ {
40
+ id: "gemini",
41
+ family: "llm",
42
+ displayName: "Gemini CLI",
43
+ auth: "cli",
44
+ binary: "gemini",
45
+ versionArgs: ["--version"],
46
+ },
37
47
  { id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
38
48
  { id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
39
49
  { id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
50
+ { id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
40
51
  { id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
41
52
  { id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
42
53
  { id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },