@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 @@
1
+ export {};
@@ -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,16 +37,19 @@ 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
- else {
47
+ else if (event.type === "done") {
34
48
  usage = event.usage;
35
49
  finishReason = event.finishReason;
36
50
  }
37
- onEvent?.(event);
51
+ if (event.type !== "activity")
52
+ onEvent?.(event);
38
53
  }
39
54
  const answer = { text, usage, finishReason };
40
55
  const unusable = call.check?.(answer);
@@ -45,33 +60,69 @@ export function stageProviders(deps, context, pieceId) {
45
60
  return answer;
46
61
  }, { kind: "llm", streaming: port.capabilities.streams });
47
62
  },
48
- tts: (call) => {
63
+ tts: (call, observe) => {
49
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
+ };
50
79
  return attempt(ctx, async (signal, progress) => {
51
- const spoken = await port.synthesize({
52
- voiceId: call.voiceId,
53
- text: call.text,
54
- signal,
55
- });
56
- const reader = spoken.audio.getReader();
57
- const chunks = [];
58
- let total = 0;
59
- for (;;) {
60
- const { done, value } = await reader.read();
61
- if (done || value === undefined) {
62
- 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
+ }
63
109
  }
64
- progress();
65
- chunks.push(value);
66
- 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 };
67
121
  }
68
- const bytes = new Uint8Array(total);
69
- let at = 0;
70
- for (const chunk of chunks) {
71
- bytes.set(chunk, at);
72
- at += chunk.length;
122
+ catch (error) {
123
+ notify({ type: "interrupted" });
124
+ throw error;
73
125
  }
74
- return { bytes, container: spoken.container };
75
126
  }, { kind: "tts", streaming: port.capabilities.streams });
76
127
  },
77
128
  image: (call) => {
package/dist/main.js CHANGED
@@ -1,14 +1,15 @@
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";
4
6
  import { buildRegistry } from "./adapter-registry.js";
7
+ import { alignSubtitles } from "./adapters/alignment/index.js";
5
8
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
6
9
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
7
- import { cartesiaModel } from "./adapters/tts/cartesia.js";
8
- import { elevenLabsModel } from "./adapters/tts/elevenlabs.js";
9
- import { openAiTtsModel } from "./adapters/tts/openai.js";
10
10
  import { createHub } from "./edge/events/hub.js";
11
11
  import { createApp } from "./edge/http/app.js";
12
+ import { createAudioPreviewStore } from "./kernel/audio-preview.js";
12
13
  import { systemClock } from "./kernel/clock.js";
13
14
  import { openDb } from "./kernel/db/index.js";
14
15
  import { migrate } from "./kernel/db/migrate.js";
@@ -21,6 +22,7 @@ import { dependenciesOf } from "./kernel/runner/graph.js";
21
22
  import { createRunner } from "./kernel/runner/index.js";
22
23
  import { stageProviders } from "./kernel/runner/providers.js";
23
24
  import { readVersion } from "./kernel/version.js";
25
+ import { modelSources } from "./model-catalog.js";
24
26
  import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
25
27
  import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
26
28
  import { runArticle } from "./slices/article/run.js";
@@ -34,6 +36,12 @@ import { createFlusher } from "./slices/telemetry/flush.js";
34
36
  import { record } from "./slices/telemetry/record.js";
35
37
  import { runThumbnail } from "./slices/thumbnail/run.js";
36
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";
37
45
  // ceiling: a burst of finished stages coalesces into one delivery a second later, and the
38
46
  // collector gets ten seconds to answer before the attempt is abandoned and the events
39
47
  // stay queued.
@@ -77,25 +85,91 @@ export async function boot(config) {
77
85
  clock,
78
86
  probe: nodeCliProbe,
79
87
  });
80
- 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
+ });
81
165
  const app = createApp({
82
166
  db,
83
167
  paths,
84
168
  hub,
85
169
  runner,
86
- modelsFor: (provider, family) => {
87
- if (family === "llm")
88
- return registry.llm(provider).models();
89
- if (family === "image")
90
- return registry.image(provider).models();
91
- const models = {
92
- elevenlabs: elevenLabsModel,
93
- "openai-tts": openAiTtsModel,
94
- cartesia: cartesiaModel,
95
- };
96
- const id = models[provider];
97
- return Promise.resolve(id === undefined ? [] : [{ id, name: id }]);
98
- },
170
+ updater,
171
+ audioPreviews,
172
+ ...modelSources(registry),
99
173
  clock,
100
174
  ids,
101
175
  log,
@@ -105,14 +179,17 @@ export async function boot(config) {
105
179
  probe: nodeCliProbe,
106
180
  });
107
181
  const server = await listen(app, config, log);
182
+ listeningPort = portOf(server) ?? config.port;
108
183
  // Whatever last run left queued goes out at start. Nothing waits for
109
184
  // it, and an unreachable collector costs one refused socket.
110
185
  flusher.soon();
111
186
  const open = db;
112
- return {
113
- paths,
114
- url: urlOf(config.host, portOf(server) ?? config.port),
115
- stop: async () => {
187
+ let stopping;
188
+ let stopActivation = () => { };
189
+ shutdown = () => {
190
+ stopping ??= (async () => {
191
+ stopActivation();
192
+ audioPreviews.close();
116
193
  try {
117
194
  // The listener goes first. Aborting the runner while the socket still accepted
118
195
  // requests let a Play arriving during the await start a stage under a fresh
@@ -131,8 +208,12 @@ export async function boot(config) {
131
208
  open.close();
132
209
  lock.release();
133
210
  }
134
- },
211
+ })();
212
+ return stopping;
135
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 };
136
217
  }
137
218
  catch (error) {
138
219
  db?.close();
@@ -140,7 +221,7 @@ export async function boot(config) {
140
221
  throw error;
141
222
  }
142
223
  }
143
- 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, }) {
144
225
  // A stage counts what it did and the queue is flushed after each new event. `record`
145
226
  // swallows its own failures, so this can neither fail a stage nor widen what leaves the
146
227
  // machine - the payload allow-list is checked inside it.
@@ -150,7 +231,7 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
150
231
  };
151
232
  // The audio stage joins its chunks with the same binary the render uses, so it takes
152
233
  // the same seven dependencies.
153
- const video = { db, paths, ids, clock, log, ffmpeg, count };
234
+ const video = { db, paths, ids, clock, log, ffmpeg, count, alignSubtitles };
154
235
  // Research, the article, the images and the thumbnail all write into the same project
155
236
  // folder from the same database handle, so they take the same six dependencies.
156
237
  const writing = { db, paths, ids, clock, log, count };
@@ -176,7 +257,7 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
176
257
  audio: async (context) => {
177
258
  const wrapped = stageProviders(providers, context);
178
259
  await prepareProvidedArticleSegments(writing, context, wrapped);
179
- await runNarration(video, context, wrapped);
260
+ await runNarration({ ...video, audioPreviews }, context, wrapped);
180
261
  },
181
262
  images: (context) => runImages(writing, context, stageProviders(providers, context)),
182
263
  thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
@@ -0,0 +1,37 @@
1
+ import { falModels } from "./adapters/image/fal.js";
2
+ import { googleImageModels } from "./adapters/image/google.js";
3
+ import { openAiImageModels } from "./adapters/image/openai.js";
4
+ import { replicateModels } from "./adapters/image/replicate.js";
5
+ import { claudeCodeModels } from "./adapters/llm/claude-code.js";
6
+ import { codexModels } from "./adapters/llm/codex.js";
7
+ import { geminiModels } from "./adapters/llm/gemini.js";
8
+ import { cartesiaModels } from "./adapters/tts/cartesia.js";
9
+ import { elevenLabsModels } from "./adapters/tts/elevenlabs.js";
10
+ import { inworldModels } from "./adapters/tts/inworld.js";
11
+ import { openAiTtsModels } from "./adapters/tts/openai.js";
12
+ // Composition keeps provider-specific fallback data out of the HTTP and settings layers.
13
+ export function modelSources(registry) {
14
+ const fallbacks = {
15
+ "claude-code": claudeCodeModels,
16
+ codex: codexModels,
17
+ gemini: geminiModels,
18
+ elevenlabs: elevenLabsModels,
19
+ "openai-tts": openAiTtsModels,
20
+ cartesia: cartesiaModels,
21
+ inworld: inworldModels,
22
+ fal: falModels,
23
+ replicate: replicateModels,
24
+ "openai-image": openAiImageModels,
25
+ "google-image": googleImageModels,
26
+ };
27
+ return {
28
+ modelsFor: (provider, family) => {
29
+ if (family === "llm")
30
+ return registry.llm(provider).models();
31
+ if (family === "image")
32
+ return registry.image(provider).models();
33
+ return registry.tts(provider).models();
34
+ },
35
+ fallbackModelsFor: (provider) => Object.hasOwn(fallbacks, provider) ? (fallbacks[provider] ?? []) : [],
36
+ };
37
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds, stageStates } from "../../kernel/pipeline.js";
3
3
  import { chunkModes } from "../narration/chunk.js";
4
+ import { subtitleConfigSchema } from "../subtitles/model.js";
4
5
  import { entryModes, formats, stageSources } from "./model.js";
5
6
  const providerChoice = z.object({ provider: z.string(), model: z.string() });
6
7
  const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
@@ -39,6 +40,7 @@ export const runDraftSchema = z.object({
39
40
  // a mode not listed here would never reach the audio stage.
40
41
  chunking: z.object({ mode: z.enum(chunkModes), words: z.number().optional() }).optional(),
41
42
  silenceGapSeconds: z.number(),
43
+ subtitles: subtitleConfigSchema.optional(),
42
44
  });
43
45
  export const runConfigSchema = runDraftSchema.extend({
44
46
  rendered: z.record(z.string(), z.string()),
@@ -71,6 +71,9 @@ export function admit(input) {
71
71
  blank(draft.thumbnailPrompt)) {
72
72
  fields.push({ field: "thumbnailPrompt", message: "Pick a thumbnail prompt." });
73
73
  }
74
+ if (sources.audio === "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off") {
75
+ fields.push({ field: "subtitles.mode", message: "Subtitles need narration audio." });
76
+ }
74
77
  checkProvided(draft, input.staged, fields);
75
78
  checkValues(draft, input.requiredSlots, fields);
76
79
  if (!Number.isFinite(draft.silenceGapSeconds) ||
@@ -104,6 +107,9 @@ export function normaliseDraft(draft) {
104
107
  return {
105
108
  ...draft,
106
109
  title: draft.title.trim(),
110
+ subtitles: sources.video === "off" && draft.subtitles?.mode === "burn-in"
111
+ ? { ...draft.subtitles, mode: "files" }
112
+ : draft.subtitles,
107
113
  sources,
108
114
  values,
109
115
  intro: sources.audio === "generate" ? draft.intro : undefined,
@@ -15,6 +15,7 @@ async function providerFields(deps, changes) {
15
15
  providers: await deps.providers(),
16
16
  voices: listVoices(deps.db),
17
17
  modelsFor: deps.modelsFor,
18
+ allowsCustomModels: deps.allowsCustomModels,
18
19
  }))(),
19
20
  new Promise((_resolve, reject) => {
20
21
  timeout = setTimeout(() => reject(new Error("Provider catalog timed out")), providerCheckTimeoutMs);
@@ -50,6 +50,8 @@ export async function validateProviderChanges(changes, current) {
50
50
  const picked = changes[key];
51
51
  if (picked === undefined || fields.some((field) => field.field === key))
52
52
  continue;
53
+ if (current.allowsCustomModels?.(picked.provider) === true)
54
+ continue;
53
55
  const models = await current.modelsFor(picked.provider, families[key]);
54
56
  if (!models.some((model) => model.id === picked.model)) {
55
57
  fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { discoverSystemFonts } from "./discovery.js";
6
+ import { readFontFile, unavailable } from "./files.js";
7
+ import { fontSummary } from "./model.js";
8
+ import { readFontMetadata } from "./sfnt.js";
9
+ export const fontIdPattern = /^(?:default|(?:system|uploaded)-[a-f0-9]{64})$/;
10
+ const uploadName = /^(uploaded-[a-f0-9]{64})\.(ttf|otf)$/;
11
+ const bundled = fileURLToPath(new URL("../../assets/fonts/Barlow-Regular.ttf", import.meta.url));
12
+ export async function listFonts(paths) {
13
+ const [defaultFont, uploaded, system] = await Promise.all([
14
+ bundledFont(),
15
+ uploadedFonts(paths),
16
+ discoverSystemFonts(),
17
+ ]);
18
+ return [defaultFont, ...uploaded, ...system].map(fontSummary);
19
+ }
20
+ export async function resolveFont(paths, id) {
21
+ if (!fontIdPattern.test(id))
22
+ throw missingFont();
23
+ if (id === "default")
24
+ return bundledFont();
25
+ const fonts = id.startsWith("uploaded-")
26
+ ? await uploadedFonts(paths, id)
27
+ : await discoverSystemFonts();
28
+ const font = fonts.find((font) => font.id === id);
29
+ if (font === undefined)
30
+ throw missingFont();
31
+ return font;
32
+ }
33
+ export function missingFont() {
34
+ return new Error("The selected font is no longer available. Choose another font.", {
35
+ cause: "font-not-found",
36
+ });
37
+ }
38
+ export function isMissingFont(error) {
39
+ return error instanceof Error && error.cause === "font-not-found";
40
+ }
41
+ async function bundledFont() {
42
+ const bytes = await readFontFile(bundled);
43
+ const face = bytes === undefined ? undefined : readFontMetadata(bytes)?.[0];
44
+ if (face === undefined)
45
+ throw new Error("Slopify's bundled Barlow font is missing or invalid. Reinstall Slopify.");
46
+ return {
47
+ id: "default",
48
+ name: face.name,
49
+ family: face.family,
50
+ source: "bundled",
51
+ path: bundled,
52
+ extension: ".ttf",
53
+ assName: face.assName,
54
+ faceIndex: 0,
55
+ };
56
+ }
57
+ async function uploadedFonts(paths, wanted) {
58
+ const root = join(paths.dataDir, "fonts");
59
+ let names;
60
+ try {
61
+ if (!(await lstat(root)).isDirectory())
62
+ return [];
63
+ names = wanted === undefined ? await readdir(root) : [`${wanted}.ttf`, `${wanted}.otf`];
64
+ }
65
+ catch (error) {
66
+ if (unavailable(error))
67
+ return [];
68
+ throw error;
69
+ }
70
+ const result = [];
71
+ for (const name of names.toSorted()) {
72
+ const id = uploadName.exec(name)?.[1];
73
+ if (id === undefined)
74
+ continue;
75
+ const path = join(root, name);
76
+ const bytes = await readFontFile(path);
77
+ if (bytes === undefined ||
78
+ `uploaded-${createHash("sha256").update(bytes).digest("hex")}` !== id)
79
+ continue;
80
+ const face = readFontMetadata(bytes)?.[0];
81
+ if (face === undefined || face.extension === ".ttc")
82
+ continue;
83
+ result.push({
84
+ id,
85
+ name: face.name,
86
+ family: face.family,
87
+ source: "uploaded",
88
+ path,
89
+ extension: face.extension,
90
+ assName: face.assName,
91
+ faceIndex: 0,
92
+ });
93
+ }
94
+ return result;
95
+ }
@@ -0,0 +1,81 @@
1
+ import { createHash } from "node:crypto";
2
+ import { opendir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { extname, join, posix, win32 } from "node:path";
5
+ import { readFontFile, unavailable } from "./files.js";
6
+ import { readFontMetadata } from "./sfnt.js";
7
+ export function systemFontDirectories(platform, env, home) {
8
+ if (platform === "win32")
9
+ return [
10
+ win32.join(env.WINDIR ?? "C:\\Windows", "Fonts"),
11
+ ...(env.LOCALAPPDATA ? [win32.join(env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts")] : []),
12
+ ];
13
+ if (platform === "darwin")
14
+ return ["/System/Library/Fonts", "/Library/Fonts", posix.join(home, "Library/Fonts")];
15
+ return [
16
+ "/usr/share/fonts",
17
+ "/usr/local/share/fonts",
18
+ posix.join(env.XDG_DATA_HOME ?? posix.join(home, ".local/share"), "fonts"),
19
+ posix.join(home, ".fonts"),
20
+ ];
21
+ }
22
+ export async function discoverSystemFonts() {
23
+ return scanFontDirectories(systemFontDirectories(process.platform, process.env, homedir()));
24
+ }
25
+ // Fixed roots only, no symlink traversal. Bounds also cover nested font-manager
26
+ // folders and damaged font files, without making the Settings request unbounded.
27
+ export async function scanFontDirectories(roots) {
28
+ const pending = [...new Set(roots)].map((path) => ({ path, depth: 0 }));
29
+ const files = new Set();
30
+ let entries = 0;
31
+ for (let at = 0; at < pending.length && entries < 10000 && files.size < 2048; at += 1) {
32
+ const dir = pending[at];
33
+ if (dir === undefined)
34
+ continue;
35
+ try {
36
+ for await (const entry of await opendir(dir.path)) {
37
+ entries += 1;
38
+ const path = join(dir.path, entry.name);
39
+ if (entry.isDirectory() && dir.depth < 8)
40
+ pending.push({ path, depth: dir.depth + 1 });
41
+ else if (entry.isFile() &&
42
+ [".ttf", ".otf", ".ttc"].includes(extname(entry.name).toLowerCase()))
43
+ files.add(path);
44
+ if (entries >= 10000 || files.size >= 2048)
45
+ break;
46
+ }
47
+ }
48
+ catch (error) {
49
+ if (!unavailable(error))
50
+ throw error;
51
+ }
52
+ }
53
+ const fonts = [];
54
+ let bytesRead = 0;
55
+ for (const path of [...files].sort()) {
56
+ if (bytesRead >= 256 * 1024 * 1024)
57
+ break;
58
+ const bytes = await readFontFile(path);
59
+ if (bytes === undefined)
60
+ continue;
61
+ bytesRead += bytes.length;
62
+ for (const face of readFontMetadata(bytes) ?? []) {
63
+ const id = createHash("sha256")
64
+ .update(path)
65
+ .update("\0")
66
+ .update(String(face.index))
67
+ .digest("hex");
68
+ fonts.push({
69
+ id: `system-${id}`,
70
+ name: face.name,
71
+ family: face.family,
72
+ source: "system",
73
+ path,
74
+ extension: face.extension,
75
+ assName: face.assName,
76
+ faceIndex: face.index,
77
+ });
78
+ }
79
+ }
80
+ return fonts;
81
+ }
@@ -0,0 +1,37 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { fontMaxBytes } from "./model.js";
4
+ export function unavailable(error) {
5
+ return (error instanceof Error &&
6
+ "code" in error &&
7
+ ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)));
8
+ }
9
+ export async function readFontFile(path) {
10
+ try {
11
+ if (!(await lstat(path)).isFile())
12
+ return undefined;
13
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
14
+ try {
15
+ const stat = await file.stat();
16
+ if (!stat.isFile() || stat.size < 12 || stat.size > fontMaxBytes)
17
+ return undefined;
18
+ const bytes = Buffer.alloc(stat.size);
19
+ let offset = 0;
20
+ while (offset < bytes.length) {
21
+ const result = await file.read(bytes, offset, bytes.length - offset, offset);
22
+ if (result.bytesRead === 0)
23
+ return undefined;
24
+ offset += result.bytesRead;
25
+ }
26
+ return bytes;
27
+ }
28
+ finally {
29
+ await file.close();
30
+ }
31
+ }
32
+ catch (error) {
33
+ if (unavailable(error))
34
+ return undefined;
35
+ throw error;
36
+ }
37
+ }
@@ -0,0 +1,5 @@
1
+ export { fontIdPattern, isMissingFont, listFonts, resolveFont } from "./catalog.js";
2
+ export { fontMaxBytes } from "./model.js";
3
+ export { previewFont } from "./preview.js";
4
+ export { standaloneFont } from "./sfnt.js";
5
+ export { uploadFont } from "./upload.js";
@@ -0,0 +1,4 @@
1
+ export const fontMaxBytes = 32 * 1024 * 1024;
2
+ export function fontSummary(font) {
3
+ return { id: font.id, name: font.name, family: font.family, source: font.source };
4
+ }