@gentbajko/slopify 0.6.1 → 0.8.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 +3 -3
- package/dist/adapter-registry.js +12 -2
- 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 +3 -4
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -10
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +24 -1
- package/dist/adapters/llm/gemini.js +4 -7
- package/dist/adapters/llm/openrouter.js +12 -2
- 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/models.yaml +470 -0
- package/dist/catalog/registry.js +147 -0
- package/dist/catalog/schema.js +91 -0
- package/dist/catalog/store.js +90 -0
- package/dist/catalog/validate.js +43 -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 +28 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/planning.js +101 -0
- package/dist/edge/http/projects.js +4 -2
- package/dist/edge/http/providers.js +44 -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/db/migrations/0003-batch-queue.sql +11 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/text.js +26 -0
- package/dist/kernel/runner/providers.js +80 -28
- package/dist/kernel/runner/queue.js +65 -0
- package/dist/main.js +142 -27
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +6 -1
- package/dist/slices/admission/start.js +9 -8
- package/dist/slices/article/continuation.js +8 -1
- package/dist/slices/article/segments.js +1 -0
- package/dist/slices/batch/index.js +82 -0
- package/dist/slices/control/index.js +6 -1
- package/dist/slices/control/providers.js +16 -1
- package/dist/slices/estimate/index.js +81 -0
- package/dist/slices/narration/live.js +21 -0
- package/dist/slices/narration/plan.js +58 -0
- package/dist/slices/narration/run.js +15 -36
- package/dist/slices/research/run.js +7 -0
- package/dist/slices/settings/model.js +2 -0
- package/dist/slices/settings/models.js +89 -0
- package/dist/slices/storage/staging.js +2 -1
- package/dist/slices/subtitles/captions.js +4 -1
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +9 -0
- package/dist/slices/subtitles/prepare.js +6 -1
- package/dist/slices/thumbnail/run.js +3 -0
- 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-6zz8telY.css +1 -0
- package/dist/web/assets/index-CbYEcBOa.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CNYs0noB.js +0 -130
- package/dist/web/assets/index-Mk0bvBg-.css +0 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { splitText } from "../../kernel/ports/text.js";
|
|
4
|
+
import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
|
|
5
|
+
const payload = z.object({ text: z.string(), file: z.string().optional() });
|
|
6
|
+
// Old whole-text plans can exceed a newly known account limit. Preserve finished
|
|
7
|
+
// pieces and their paths, and split only unfinished work before any request starts.
|
|
8
|
+
export function planNarration(deps, stageId, texts, maxCharacters, finished) {
|
|
9
|
+
const old = piecesOf(deps.db, stageId, "chunk");
|
|
10
|
+
const source = old.length
|
|
11
|
+
? old
|
|
12
|
+
: texts.map((text, idx) => ({
|
|
13
|
+
id: deps.ids.next(),
|
|
14
|
+
stageId,
|
|
15
|
+
kind: "chunk",
|
|
16
|
+
idx: idx + 1,
|
|
17
|
+
state: "pending",
|
|
18
|
+
payload: JSON.stringify({ text }),
|
|
19
|
+
}));
|
|
20
|
+
const planned = [];
|
|
21
|
+
for (const piece of source) {
|
|
22
|
+
const data = payload.parse(JSON.parse(piece.payload ?? "null"));
|
|
23
|
+
if (finished(piece)) {
|
|
24
|
+
planned.push({ ...piece, idx: planned.length + 1 });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
for (const [part, text] of splitText(data.text, maxCharacters).entries())
|
|
28
|
+
planned.push({
|
|
29
|
+
...piece,
|
|
30
|
+
id: part === 0 ? piece.id : deps.ids.next(),
|
|
31
|
+
idx: planned.length + 1,
|
|
32
|
+
state: "pending",
|
|
33
|
+
payload: JSON.stringify({ text }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (old.length &&
|
|
37
|
+
planned.length === old.length &&
|
|
38
|
+
planned.every((p, i) => p.id === old[i]?.id &&
|
|
39
|
+
payload.parse(JSON.parse(p.payload ?? "null")).text ===
|
|
40
|
+
payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
|
|
41
|
+
return old;
|
|
42
|
+
transact(deps.db, () => {
|
|
43
|
+
// Negative temporary indices avoid colliding with a retained completed piece.
|
|
44
|
+
deps.db
|
|
45
|
+
.prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
|
|
46
|
+
.run(stageId);
|
|
47
|
+
const known = new Set(old.map((p) => p.id));
|
|
48
|
+
for (const piece of planned) {
|
|
49
|
+
if (known.has(piece.id))
|
|
50
|
+
deps.db
|
|
51
|
+
.prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
|
|
52
|
+
.run(piece.idx, piece.state, piece.payload, piece.id);
|
|
53
|
+
else
|
|
54
|
+
insertPiece(deps.db, piece);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return planned;
|
|
58
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
6
5
|
import { entryModes } from "../admission/model.js";
|
|
7
6
|
import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
|
|
8
7
|
import { entryCategories } from "../library/model.js";
|
|
@@ -11,6 +10,8 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
|
11
10
|
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
12
11
|
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
13
12
|
import { joinNarration } from "./concat.js";
|
|
13
|
+
import { observeNarration } from "./live.js";
|
|
14
|
+
import { planNarration } from "./plan.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`);
|
|
@@ -51,7 +53,8 @@ export async function runNarration(deps, context, providers) {
|
|
|
51
53
|
if (texts.length === 0) {
|
|
52
54
|
throw new Error(nothingToNarrate);
|
|
53
55
|
}
|
|
54
|
-
const
|
|
56
|
+
const planned = planNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
|
|
57
|
+
const files = await speakChunks(deps, context, providers, choice, planned);
|
|
55
58
|
await storeBody(deps, context, choice, files, outputs);
|
|
56
59
|
await speakSegments(deps, context, providers, choice, outputs);
|
|
57
60
|
deps.log.write("info", "narration.done", {
|
|
@@ -69,29 +72,7 @@ function narrationSource(deps, outputs, projectId) {
|
|
|
69
72
|
}
|
|
70
73
|
return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
|
|
71
74
|
}
|
|
72
|
-
//
|
|
73
|
-
// run wrote and narrates only the ones that did not finish.
|
|
74
|
-
function plan(deps, context, texts) {
|
|
75
|
-
const existing = piecesOf(deps.db, context.stage.id, "chunk");
|
|
76
|
-
if (existing.length > 0) {
|
|
77
|
-
return existing;
|
|
78
|
-
}
|
|
79
|
-
const planned = texts.map((text, index) => ({
|
|
80
|
-
id: deps.ids.next(),
|
|
81
|
-
stageId: context.stage.id,
|
|
82
|
-
kind: "chunk",
|
|
83
|
-
idx: index + 1,
|
|
84
|
-
state: "pending",
|
|
85
|
-
payload: JSON.stringify({ text }),
|
|
86
|
-
}));
|
|
87
|
-
transact(deps.db, () => {
|
|
88
|
-
for (const piece of planned) {
|
|
89
|
-
insertPiece(deps.db, piece);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
return planned;
|
|
93
|
-
}
|
|
94
|
-
// Every chunk is synthesized in parallel. A chunk a previous run finished is not spoken
|
|
75
|
+
// Chunks are scheduled through the shared provider queue. A chunk a previous run finished is not spoken
|
|
95
76
|
// again, and one that fails takes down the whole stage while its siblings finish and keep their
|
|
96
77
|
// audio for the next resume.
|
|
97
78
|
async function speakChunks(deps, context, providers, choice, pieces) {
|
|
@@ -105,14 +86,15 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
105
86
|
if (kept !== undefined) {
|
|
106
87
|
return { ok: true, file: kept };
|
|
107
88
|
}
|
|
108
|
-
const file = `${chunkDir}/${
|
|
89
|
+
const file = `${chunkDir}/${piece.id}.mp3`;
|
|
109
90
|
setPiece(deps.db, piece.id, "running", piece.payload);
|
|
110
91
|
try {
|
|
111
92
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
112
93
|
provider: choice.provider,
|
|
94
|
+
model: choice.model,
|
|
113
95
|
voiceId: choice.voice,
|
|
114
96
|
text: carried.text,
|
|
115
|
-
});
|
|
97
|
+
}, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
|
|
116
98
|
write(deps, projectId, file, spoken.bytes);
|
|
117
99
|
// The file is on disk before the row says so: a crash between the two leaves a
|
|
118
100
|
// file the reconcile collects, where the other order would leave a `done` chunk
|
|
@@ -180,9 +162,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
|
|
|
180
162
|
}
|
|
181
163
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
182
164
|
provider: choice.provider,
|
|
165
|
+
model: choice.model,
|
|
183
166
|
voiceId: choice.voice,
|
|
184
167
|
text,
|
|
185
|
-
});
|
|
168
|
+
}, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
|
|
186
169
|
const name = outputFileName(role, 1, ".mp3", "audio");
|
|
187
170
|
write(deps, projectId, name, spoken.bytes);
|
|
188
171
|
// Measured the same way the body is, because the render adds all three and the gaps to
|
|
@@ -208,14 +191,13 @@ function segmentPieces(deps, projectId) {
|
|
|
208
191
|
}
|
|
209
192
|
return piecesOf(deps.db, article.id, "segment");
|
|
210
193
|
}
|
|
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.
|
|
194
|
+
// Audio seconds come from the measured duration per segment, never from the text's length.
|
|
214
195
|
function counted(deps, segment, durationMs, choice) {
|
|
215
196
|
deps.count("stage.completed", {
|
|
216
197
|
stage: "audio",
|
|
217
198
|
segment,
|
|
218
199
|
provider: choice.provider,
|
|
200
|
+
model: choice.model,
|
|
219
201
|
audioSeconds: durationMs / 1000,
|
|
220
202
|
});
|
|
221
203
|
}
|
|
@@ -246,9 +228,6 @@ function write(deps, projectId, path, bytes) {
|
|
|
246
228
|
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
247
229
|
writeFileSync(target, bytes, { mode: 0o600 });
|
|
248
230
|
}
|
|
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
231
|
function store(deps, projectId, role, path, durationMs, choice) {
|
|
253
232
|
insertOutput(deps.db, {
|
|
254
233
|
id: deps.ids.next(),
|
|
@@ -259,7 +238,7 @@ function store(deps, projectId, role, path, durationMs, choice) {
|
|
|
259
238
|
originalFilename: null,
|
|
260
239
|
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
261
240
|
durationMs,
|
|
262
|
-
meta: { provider: choice.provider, voice: choice.voice },
|
|
241
|
+
meta: { provider: choice.provider, model: choice.model, voice: choice.voice },
|
|
263
242
|
createdAt: deps.clock.now().toISOString(),
|
|
264
243
|
});
|
|
265
244
|
}
|
|
@@ -52,7 +52,9 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
52
52
|
const answer = await providers.llm({
|
|
53
53
|
provider: choice.provider,
|
|
54
54
|
model: choice.model,
|
|
55
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
55
56
|
messages: synthesisMessages(brief, findings),
|
|
57
|
+
previewLabel: "Writing research notes",
|
|
56
58
|
check: (given) => sourcedAnswer("the synthesis", given.text),
|
|
57
59
|
});
|
|
58
60
|
add(answer.usage);
|
|
@@ -70,6 +72,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
70
72
|
stage: "research",
|
|
71
73
|
provider: choice.provider,
|
|
72
74
|
model: choice.model,
|
|
75
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
73
76
|
...tokens,
|
|
74
77
|
});
|
|
75
78
|
deps.log.write("info", "research.done", {
|
|
@@ -88,7 +91,9 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
88
91
|
const answer = await providers.llm({
|
|
89
92
|
provider: choice.provider,
|
|
90
93
|
model: choice.model,
|
|
94
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
91
95
|
messages: plannerMessages(brief),
|
|
96
|
+
previewLabel: "Planning research",
|
|
92
97
|
// An empty answer, or one with no chapter in it, is a failed attempt.
|
|
93
98
|
check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
|
|
94
99
|
});
|
|
@@ -128,7 +133,9 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
128
133
|
const answer = await providers.forPiece(piece.id).llm({
|
|
129
134
|
provider: choice.provider,
|
|
130
135
|
model: choice.model,
|
|
136
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
131
137
|
messages: subAgentMessages(brief, kept.title, outline),
|
|
138
|
+
previewLabel: kept.title,
|
|
132
139
|
// Grounding is asked for explicitly, so a model without it says so
|
|
133
140
|
// instead of answering from what it already knows.
|
|
134
141
|
webSearch: true,
|
|
@@ -12,6 +12,7 @@ export const providerIds = [
|
|
|
12
12
|
"elevenlabs",
|
|
13
13
|
"openai-tts",
|
|
14
14
|
"cartesia",
|
|
15
|
+
"inworld",
|
|
15
16
|
"fal",
|
|
16
17
|
"replicate",
|
|
17
18
|
"openai-image",
|
|
@@ -46,6 +47,7 @@ export const providers = [
|
|
|
46
47
|
{ id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
|
|
47
48
|
{ id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
|
|
48
49
|
{ id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
|
|
50
|
+
{ id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
|
|
49
51
|
{ id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
|
|
50
52
|
{ id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
|
|
51
53
|
{ id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// These marketplaces host models with different input schemas. Discovery alone cannot
|
|
2
|
+
// make an arbitrary endpoint compatible with our image request.
|
|
3
|
+
export function allowsCustomModel(provider) {
|
|
4
|
+
return provider !== "fal" && provider !== "replicate";
|
|
5
|
+
}
|
|
6
|
+
const ttlMs = 5 * 60_000;
|
|
7
|
+
const retryMs = 30_000;
|
|
8
|
+
export function createModelCatalog(deps) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
const pending = new Map();
|
|
11
|
+
const generations = new Map();
|
|
12
|
+
async function get(provider, family, refresh = false) {
|
|
13
|
+
const active = pending.get(provider);
|
|
14
|
+
if (active !== undefined)
|
|
15
|
+
return active;
|
|
16
|
+
const previous = cache.get(provider);
|
|
17
|
+
if (!refresh && previous !== undefined && previous.expires > deps.now())
|
|
18
|
+
return previous.value;
|
|
19
|
+
const generation = generations.get(provider) ?? 0;
|
|
20
|
+
const task = Promise.resolve()
|
|
21
|
+
.then(async () => {
|
|
22
|
+
let value;
|
|
23
|
+
let lifetime = ttlMs;
|
|
24
|
+
try {
|
|
25
|
+
const models = await deps.load(provider, family);
|
|
26
|
+
value = {
|
|
27
|
+
models: uniqueModels(models),
|
|
28
|
+
allowsCustom: allowsCustomModel(provider),
|
|
29
|
+
...catalogNotice(provider),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A catalogue is metadata, not generation. Never expose an upstream response
|
|
34
|
+
// here: it can quote credentials that belong only inside its adapter.
|
|
35
|
+
deps.report(provider);
|
|
36
|
+
const saved = previous?.value.models;
|
|
37
|
+
value = {
|
|
38
|
+
models: saved ?? deps.fallback(provider),
|
|
39
|
+
...catalogNotice(provider),
|
|
40
|
+
allowsCustom: allowsCustomModel(provider),
|
|
41
|
+
warning: saved === undefined
|
|
42
|
+
? "Could not load the model list. Showing bundled choices; refresh to try again."
|
|
43
|
+
: "Could not refresh the model list. Showing the last loaded choices.",
|
|
44
|
+
};
|
|
45
|
+
lifetime = retryMs;
|
|
46
|
+
}
|
|
47
|
+
if ((generations.get(provider) ?? 0) === generation) {
|
|
48
|
+
cache.set(provider, { value, expires: deps.now() + lifetime });
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
if (pending.get(provider) === task)
|
|
54
|
+
pending.delete(provider);
|
|
55
|
+
});
|
|
56
|
+
pending.set(provider, task);
|
|
57
|
+
return task;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
get,
|
|
61
|
+
invalidate: (provider) => {
|
|
62
|
+
generations.set(provider, (generations.get(provider) ?? 0) + 1);
|
|
63
|
+
cache.delete(provider);
|
|
64
|
+
pending.delete(provider);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function uniqueModels(models) {
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
return models.filter((model) => {
|
|
71
|
+
if (model.id.trim() === "" || seen.has(model.id))
|
|
72
|
+
return false;
|
|
73
|
+
seen.add(model.id);
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function catalogNotice(provider) {
|
|
78
|
+
const notices = {
|
|
79
|
+
codex: "Models come from Codex's local catalogue. Open Codex to refresh that catalogue, then refresh this list.",
|
|
80
|
+
gemini: "Models and aliases come from the installed Gemini CLI. Update that CLI to discover newer models.",
|
|
81
|
+
"claude-code": "These aliases follow Claude Code's model families. You can also enter an exact model ID.",
|
|
82
|
+
inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
|
|
83
|
+
cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
|
|
84
|
+
fal: "These models have input formats supported by Slopify's fal adapter.",
|
|
85
|
+
replicate: "These models have input formats supported by Slopify's Replicate adapter.",
|
|
86
|
+
};
|
|
87
|
+
const notice = Object.hasOwn(notices, provider) ? notices[provider] : undefined;
|
|
88
|
+
return notice === undefined ? {} : { notice };
|
|
89
|
+
}
|
|
@@ -140,7 +140,8 @@ export function attachStagedFile(deps, input) {
|
|
|
140
140
|
try {
|
|
141
141
|
transact(deps.db, () => {
|
|
142
142
|
insertOutput(deps.db, output);
|
|
143
|
-
|
|
143
|
+
if (!input.retainStaged)
|
|
144
|
+
deleteStagedFile(deps.db, staged.id);
|
|
144
145
|
});
|
|
145
146
|
}
|
|
146
147
|
catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subtitlePlacement } from "./layout.js";
|
|
1
2
|
// Keep spoken spelling and punctuation. Word times come only from acoustic alignment.
|
|
2
3
|
export function captionCues(words) {
|
|
3
4
|
const cues = [];
|
|
@@ -61,10 +62,12 @@ export function serializeVtt(cues) {
|
|
|
61
62
|
}
|
|
62
63
|
export function serializeAss(cues, style) {
|
|
63
64
|
const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
|
|
65
|
+
const placement = subtitlePlacement(style.position ?? "bottom", style.height);
|
|
66
|
+
const position = `{\\an${placement.alignment}\\pos(${style.width / 2},${placement.y})}`;
|
|
64
67
|
const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${style.width}\nPlayResY: ${style.height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${name},${style.fontSize},&H00FFFFFF,&H00FFFFFF,&H00101010,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,60,60,60,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
|
|
65
68
|
return (header +
|
|
66
69
|
cues
|
|
67
|
-
.map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${assText(cue.text)}\n`)
|
|
70
|
+
.map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${position}${assText(cue.text)}\n`)
|
|
68
71
|
.join(""));
|
|
69
72
|
}
|
|
70
73
|
function markup(text) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function subtitleFrame(format) {
|
|
2
|
+
return format === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
|
|
3
|
+
}
|
|
4
|
+
// ASS and the browser preview share anchors. Edge positions keep the existing
|
|
5
|
+
// 60-pixel safe margin; interior positions center both single and multiline cues.
|
|
6
|
+
export function subtitlePlacement(position, height) {
|
|
7
|
+
switch (position) {
|
|
8
|
+
case "top":
|
|
9
|
+
return { y: 60, alignment: 8, translateY: 0 };
|
|
10
|
+
case "upper-middle":
|
|
11
|
+
return { y: height / 4, alignment: 5, translateY: -50 };
|
|
12
|
+
case "center":
|
|
13
|
+
return { y: height / 2, alignment: 5, translateY: -50 };
|
|
14
|
+
case "lower-middle":
|
|
15
|
+
return { y: (height * 3) / 4, alignment: 5, translateY: -50 };
|
|
16
|
+
case "bottom":
|
|
17
|
+
return { y: height - 60, alignment: 2, translateY: -100 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const subtitleModes = ["off", "files", "burn-in"];
|
|
3
|
+
export const subtitlePositions = [
|
|
4
|
+
"top",
|
|
5
|
+
"upper-middle",
|
|
6
|
+
"center",
|
|
7
|
+
"lower-middle",
|
|
8
|
+
"bottom",
|
|
9
|
+
];
|
|
3
10
|
export const subtitleConfigSchema = z.object({
|
|
4
11
|
mode: z.enum(subtitleModes),
|
|
5
12
|
language: z.literal("en").default("en"),
|
|
@@ -8,6 +15,7 @@ export const subtitleConfigSchema = z.object({
|
|
|
8
15
|
.max(160)
|
|
9
16
|
.regex(/^[A-Za-z0-9_-]+$/)
|
|
10
17
|
.default("default"),
|
|
18
|
+
position: z.enum(subtitlePositions).default("bottom"),
|
|
11
19
|
fontSize: z.number().int().min(16).max(120).default(48),
|
|
12
20
|
});
|
|
13
21
|
export const defaultSubtitles = {
|
|
@@ -15,4 +23,5 @@ export const defaultSubtitles = {
|
|
|
15
23
|
language: "en",
|
|
16
24
|
fontId: "default",
|
|
17
25
|
fontSize: 48,
|
|
26
|
+
position: "bottom",
|
|
18
27
|
};
|
|
@@ -59,7 +59,12 @@ export async function prepareSubtitles(deps, context, audio, frame) {
|
|
|
59
59
|
throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
|
|
60
60
|
writeFileSync(join(directory, "subtitles.srt"), serializeSrt(cues), { mode: 0o600 });
|
|
61
61
|
writeFileSync(join(directory, "subtitles.vtt"), serializeVtt(cues), { mode: 0o600 });
|
|
62
|
-
writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, {
|
|
62
|
+
writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, {
|
|
63
|
+
...frame,
|
|
64
|
+
fontSize: config.fontSize,
|
|
65
|
+
fontName: font.assName,
|
|
66
|
+
position: config.position,
|
|
67
|
+
}), { mode: 0o600 });
|
|
63
68
|
writeFileSync(join(directory, "subtitles.json"), JSON.stringify({ key, words, font }), {
|
|
64
69
|
mode: 0o600,
|
|
65
70
|
});
|
|
@@ -81,6 +81,7 @@ async function byLlm(deps, context, providers, project) {
|
|
|
81
81
|
const answer = await providers.llm({
|
|
82
82
|
provider: llm.provider,
|
|
83
83
|
model: llm.model,
|
|
84
|
+
...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
|
|
84
85
|
messages,
|
|
85
86
|
// An empty answer is a failed attempt, and the wrapper is what retries it.
|
|
86
87
|
check: (given) => writtenPrompt(given.text),
|
|
@@ -151,6 +152,7 @@ async function make(deps, context, providers, project, choice, prompt) {
|
|
|
151
152
|
const made = await providers.image({
|
|
152
153
|
provider: choice.provider,
|
|
153
154
|
model: choice.model,
|
|
155
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
154
156
|
prompt,
|
|
155
157
|
aspect: project.format,
|
|
156
158
|
});
|
|
@@ -175,6 +177,7 @@ async function make(deps, context, providers, project, choice, prompt) {
|
|
|
175
177
|
prompt,
|
|
176
178
|
provider: choice.provider,
|
|
177
179
|
model: choice.model,
|
|
180
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
178
181
|
},
|
|
179
182
|
createdAt: deps.clock.now().toISOString(),
|
|
180
183
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function watchActivation(updater, token, expired, report) {
|
|
2
|
+
let checking = false;
|
|
3
|
+
const until = Date.now() + 120_000;
|
|
4
|
+
const timer = setInterval(() => {
|
|
5
|
+
if (checking)
|
|
6
|
+
return;
|
|
7
|
+
checking = true;
|
|
8
|
+
void updater
|
|
9
|
+
.activate(token)
|
|
10
|
+
.then(async (activated) => {
|
|
11
|
+
if (activated)
|
|
12
|
+
clearInterval(timer);
|
|
13
|
+
else if (Date.now() >= until) {
|
|
14
|
+
clearInterval(timer);
|
|
15
|
+
report("The update was not committed; stopping the candidate server.");
|
|
16
|
+
await expired();
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
.catch(() => {
|
|
20
|
+
report("The update activation marker could not be checked.");
|
|
21
|
+
})
|
|
22
|
+
.finally(() => {
|
|
23
|
+
checking = false;
|
|
24
|
+
});
|
|
25
|
+
}, 250);
|
|
26
|
+
timer.unref();
|
|
27
|
+
return () => clearInterval(timer);
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { activeUpdateEntry } from "./plan.js";
|
|
3
|
+
export async function forwardManagedUpdate(dataDir, version, args) {
|
|
4
|
+
if (process.env.SLOPIFY_SKIP_MANAGED_UPDATE === "1")
|
|
5
|
+
return undefined;
|
|
6
|
+
const entry = await activeUpdateEntry(dataDir, version);
|
|
7
|
+
if (entry === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(process.execPath, [entry, ...args], {
|
|
11
|
+
stdio: "inherit",
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
});
|
|
14
|
+
const interrupt = () => {
|
|
15
|
+
child.kill("SIGINT");
|
|
16
|
+
};
|
|
17
|
+
const terminate = () => {
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
};
|
|
20
|
+
process.once("SIGINT", interrupt);
|
|
21
|
+
process.once("SIGTERM", terminate);
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
process.off("SIGINT", interrupt);
|
|
24
|
+
process.off("SIGTERM", terminate);
|
|
25
|
+
};
|
|
26
|
+
child.once("error", () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
resolve(undefined);
|
|
29
|
+
});
|
|
30
|
+
child.once("close", (code) => {
|
|
31
|
+
cleanup();
|
|
32
|
+
resolve(code ?? 1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function runUpdateFlow(plan, deps) {
|
|
2
|
+
const entry = await deps.install();
|
|
3
|
+
// npm or package verification failures leave the currently serving app entirely alone.
|
|
4
|
+
await deps.handoff();
|
|
5
|
+
let backedUp = false;
|
|
6
|
+
try {
|
|
7
|
+
await deps.backup();
|
|
8
|
+
backedUp = true;
|
|
9
|
+
await deps.start(entry);
|
|
10
|
+
await deps.healthy(plan.version);
|
|
11
|
+
await deps.activate();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
await deps.stopCandidate();
|
|
15
|
+
if (backedUp)
|
|
16
|
+
await deps.restore();
|
|
17
|
+
await deps.start(plan.oldEntry);
|
|
18
|
+
await deps.healthy(plan.previousVersion);
|
|
19
|
+
deps.report("The update did not start; the previous version and database were restored.");
|
|
20
|
+
throw new Error("The previous Slopify version was restored.");
|
|
21
|
+
}
|
|
22
|
+
// The pointer rename commits the new database. The candidate may already observe
|
|
23
|
+
// it and accept writes, so a lost unlock acknowledgement can never trigger rollback.
|
|
24
|
+
await deps.release();
|
|
25
|
+
deps.report(`Slopify ${plan.version} started successfully.`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { updatePlanSchema } from "./plan.js";
|
|
6
|
+
export async function launchUpdate(worker, plan, handoff) {
|
|
7
|
+
updatePlanSchema.parse(plan);
|
|
8
|
+
const directory = join(plan.dataDir, "updates");
|
|
9
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
10
|
+
const planPath = join(directory, `plan-${randomUUID()}.json`);
|
|
11
|
+
await writeFile(planPath, JSON.stringify(plan), { mode: 0o600 });
|
|
12
|
+
const child = spawn(process.execPath, [worker, planPath], {
|
|
13
|
+
cwd: plan.cwd,
|
|
14
|
+
detached: true,
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
let restarting = false;
|
|
21
|
+
const send = (type) => {
|
|
22
|
+
if (!child.connected) {
|
|
23
|
+
reject(new Error("The update worker disconnected."));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
child.send({ type }, (error) => {
|
|
27
|
+
if (error !== null)
|
|
28
|
+
reject(new Error("The update handoff failed."));
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
child.once("error", () => reject(new Error("The update worker could not start.")));
|
|
32
|
+
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("The update worker could not finish.")));
|
|
33
|
+
child.on("message", (message) => {
|
|
34
|
+
if (typeof message !== "object" || message === null || !("type" in message))
|
|
35
|
+
return;
|
|
36
|
+
if (message.type === "installed" && !restarting) {
|
|
37
|
+
restarting = true;
|
|
38
|
+
void handoff().then(() => send("handoff"), () => {
|
|
39
|
+
send("abort");
|
|
40
|
+
reject(new Error("Slopify could not release its server for the update."));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
child.unref();
|
|
48
|
+
await rm(planPath, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const updatePackage = "@gentbajko/slopify";
|
|
2
|
+
export const npmRegistry = "https://registry.npmjs.org/";
|
|
3
|
+
const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
4
|
+
export function isStableVersion(value) {
|
|
5
|
+
return value.length <= 50 && stableVersion.test(value);
|
|
6
|
+
}
|
|
7
|
+
export function newerVersion(candidate, current) {
|
|
8
|
+
if (!isStableVersion(candidate) || !isStableVersion(current))
|
|
9
|
+
return false;
|
|
10
|
+
const left = candidate.split(".").map(BigInt);
|
|
11
|
+
const right = current.split(".").map(BigInt);
|
|
12
|
+
for (let index = 0; index < 3; index++) {
|
|
13
|
+
const a = left[index];
|
|
14
|
+
const b = right[index];
|
|
15
|
+
if (a === undefined || b === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
if (a !== b)
|
|
18
|
+
return a > b;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export function isUpdateToken(value) {
|
|
23
|
+
return /^[a-f0-9]{64}$/.test(value);
|
|
24
|
+
}
|