@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.
- package/README.md +36 -1
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +18 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/image/google.js +12 -15
- package/dist/adapters/image/models.js +72 -0
- package/dist/adapters/image/openai.js +3 -5
- package/dist/adapters/llm/catalogue-files.js +23 -0
- package/dist/adapters/llm/claude-code.js +9 -7
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -12
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +138 -0
- package/dist/adapters/llm/openrouter.js +11 -2
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/adapters/tts/cartesia.js +9 -1
- package/dist/adapters/tts/elevenlabs.js +33 -2
- package/dist/adapters/tts/inworld-async.js +150 -0
- package/dist/adapters/tts/inworld-text.js +32 -0
- package/dist/adapters/tts/inworld.js +170 -0
- package/dist/adapters/tts/openai.js +31 -4
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/cli.js +5 -0
- package/dist/edge/events/hub.js +9 -2
- package/dist/edge/events/preview-cache.js +40 -0
- package/dist/edge/http/actions.js +2 -0
- package/dist/edge/http/app.js +30 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +29 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/edge/http/update.js +57 -0
- package/dist/edge/update-worker.js +44 -0
- package/dist/kernel/audio-preview.js +188 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +75 -24
- package/dist/main.js +106 -25
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/control/index.js +1 -0
- package/dist/slices/control/providers.js +2 -0
- package/dist/slices/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -0
- package/dist/slices/narration/live.js +21 -0
- package/dist/slices/narration/run.js +9 -9
- package/dist/slices/research/run.js +3 -0
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +11 -0
- package/dist/slices/settings/models.js +89 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +91 -0
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +27 -0
- package/dist/slices/subtitles/prepare.js +167 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +3 -0
- package/dist/slices/video/ffmpeg.js +14 -7
- package/dist/slices/video/plan.js +4 -4
- package/dist/slices/video/run.js +5 -2
- package/dist/slices/video/write-export.js +85 -20
- package/dist/updater/candidate.js +28 -0
- package/dist/updater/forward.js +35 -0
- package/dist/updater/install-flow.js +26 -0
- package/dist/updater/install.js +50 -0
- package/dist/updater/model.js +24 -0
- package/dist/updater/plan.js +135 -0
- package/dist/updater/readiness.js +15 -0
- package/dist/updater/registry.js +19 -0
- package/dist/updater/service.js +128 -0
- package/dist/updater/worker.js +136 -0
- package/dist/web/assets/index-6QIQ-TzO.css +1 -0
- package/dist/web/assets/index-CNgWL-ct.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-C1fsrIP5.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|