@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,90 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { catalogueSchema } from "./schema.js";
|
|
5
|
+
export const catalogueSource = "https://raw.githubusercontent.com/GentBajko/slopify/main/packages/app/src/assets/models.yaml";
|
|
6
|
+
export function parseCatalogue(text) {
|
|
7
|
+
if (Buffer.byteLength(text) > 1024 * 1024)
|
|
8
|
+
throw new Error("Model catalogue exceeds 1 MB");
|
|
9
|
+
return catalogueSchema.parse(parse(text, { maxAliasCount: 20, uniqueKeys: true }));
|
|
10
|
+
}
|
|
11
|
+
export function createCatalogueStore(deps) {
|
|
12
|
+
const path = join(deps.dataDir, "models.yaml");
|
|
13
|
+
const bundled = deps.bundled ?? readFileSync(new URL("../assets/models.yaml", import.meta.url), "utf8");
|
|
14
|
+
let current = parseCatalogue(bundled);
|
|
15
|
+
mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
writeFileSync(path, bundled, { mode: 0o600, flag: "wx" });
|
|
18
|
+
let stamp = "";
|
|
19
|
+
let warning = null;
|
|
20
|
+
let refreshing;
|
|
21
|
+
function read() {
|
|
22
|
+
try {
|
|
23
|
+
const file = statSync(path);
|
|
24
|
+
const next = `${file.mtimeMs}:${file.size}`;
|
|
25
|
+
if (next !== stamp) {
|
|
26
|
+
stamp = next;
|
|
27
|
+
if (file.size > 1024 * 1024)
|
|
28
|
+
throw new Error("Oversized catalogue");
|
|
29
|
+
current = parseCatalogue(readFileSync(path, "utf8"));
|
|
30
|
+
warning = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
warning =
|
|
35
|
+
"Your models.yaml is unavailable or invalid. The last valid catalogue is still in use.";
|
|
36
|
+
}
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
async function refresh() {
|
|
40
|
+
const response = await deps.fetch(catalogueSource, {
|
|
41
|
+
signal: AbortSignal.timeout(15000),
|
|
42
|
+
redirect: "error",
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new Error(`Catalogue update answered ${response.status}`);
|
|
46
|
+
if (!response.body)
|
|
47
|
+
throw new Error("Empty catalogue response");
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
const reader = response.body.getReader();
|
|
51
|
+
try {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const next = await reader.read();
|
|
54
|
+
if (next.done)
|
|
55
|
+
break;
|
|
56
|
+
size += next.value.byteLength;
|
|
57
|
+
if (size > 1024 * 1024)
|
|
58
|
+
throw new Error("Catalogue exceeds 1 MB");
|
|
59
|
+
chunks.push(next.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await reader.cancel().catch(() => { });
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
66
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
67
|
+
const next = parseCatalogue(text);
|
|
68
|
+
mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
|
|
69
|
+
// A manual refresh replaces the local catalogue; retain the previous text for recovery.
|
|
70
|
+
if (existsSync(path))
|
|
71
|
+
writeFileSync(`${path}.previous`, readFileSync(path), { mode: 0o600 });
|
|
72
|
+
writeFileSync(`${path}.next`, text, { mode: 0o600 });
|
|
73
|
+
renameSync(`${path}.next`, path);
|
|
74
|
+
current = next;
|
|
75
|
+
stamp = "";
|
|
76
|
+
warning = null;
|
|
77
|
+
read();
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
read,
|
|
81
|
+
models: (provider, family) => read()[family].filter((m) => m.provider === provider && m.enabled && !m.deprecated),
|
|
82
|
+
status: () => ({ updatedAt: read().updatedAt, path, warning, source: catalogueSource }),
|
|
83
|
+
refresh: () => {
|
|
84
|
+
refreshing ??= refresh().finally(() => {
|
|
85
|
+
refreshing = undefined;
|
|
86
|
+
});
|
|
87
|
+
return refreshing;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function modelFields(draft, catalogue) {
|
|
2
|
+
if (!catalogue)
|
|
3
|
+
return [];
|
|
4
|
+
const fields = [];
|
|
5
|
+
const needLlm = draft.sources.research === "generate" ||
|
|
6
|
+
draft.sources.article === "generate" ||
|
|
7
|
+
draft.sources.thumbnail === "prompt_by_llm" ||
|
|
8
|
+
draft.intro?.mode === "llm" ||
|
|
9
|
+
draft.outro?.mode === "llm";
|
|
10
|
+
const checks = [
|
|
11
|
+
{ field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
|
|
12
|
+
{
|
|
13
|
+
field: "audio",
|
|
14
|
+
family: "tts",
|
|
15
|
+
choice: draft.audio,
|
|
16
|
+
needed: draft.sources.audio === "generate",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
field: "images",
|
|
20
|
+
family: "image",
|
|
21
|
+
choice: draft.images,
|
|
22
|
+
needed: draft.sources.images === "generate" ||
|
|
23
|
+
["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail),
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
for (const { field, family, choice, needed } of checks) {
|
|
27
|
+
if (!needed || !choice)
|
|
28
|
+
continue;
|
|
29
|
+
const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
|
|
30
|
+
if (!model)
|
|
31
|
+
fields.push({ field, message: "Choose an enabled model from the current catalogue." });
|
|
32
|
+
else if ("llm" in model && choice.thinking && !model.llm.thinking?.[choice.thinking])
|
|
33
|
+
fields.push({ field, message: "Choose a supported thinking setting for this model." });
|
|
34
|
+
else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
|
|
35
|
+
fields.push({
|
|
36
|
+
field,
|
|
37
|
+
message: "This model cannot perform web research. Choose another or turn Research off.",
|
|
38
|
+
});
|
|
39
|
+
else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
|
|
40
|
+
fields.push({ field, message: "This image model does not support the selected shape." });
|
|
41
|
+
}
|
|
42
|
+
return fields;
|
|
43
|
+
}
|
package/dist/edge/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { configFrom } from "../kernel/config/index.js";
|
|
4
|
+
import { readVersion } from "../kernel/version.js";
|
|
4
5
|
import { boot } from "../main.js";
|
|
6
|
+
import { forwardManagedUpdate } from "../updater/forward.js";
|
|
5
7
|
import { openBrowser } from "./open-browser.js";
|
|
6
8
|
const { values } = parseArgs({
|
|
7
9
|
options: {
|
|
@@ -13,6 +15,9 @@ const { values } = parseArgs({
|
|
|
13
15
|
});
|
|
14
16
|
try {
|
|
15
17
|
const config = configFrom(values, process.env);
|
|
18
|
+
const forwarded = await forwardManagedUpdate(config.dataDir, readVersion(), process.argv.slice(2));
|
|
19
|
+
if (forwarded !== undefined)
|
|
20
|
+
process.exit(forwarded);
|
|
16
21
|
const { paths, url, stop } = await boot(config);
|
|
17
22
|
console.log(`Slopify is running at ${url}`);
|
|
18
23
|
console.log(`Slopify data directory: ${paths.dataDir}`);
|
package/dist/edge/events/hub.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createPreviewCache } from "./preview-cache.js";
|
|
1
2
|
export function createHub(deps) {
|
|
3
|
+
const previews = createPreviewCache();
|
|
2
4
|
const projects = new Map();
|
|
3
5
|
const globals = new Set();
|
|
4
6
|
// The tally a page needs before anything else happens. The runner emits it when it
|
|
@@ -43,11 +45,15 @@ export function createHub(deps) {
|
|
|
43
45
|
subscribe: (projectId, stream, signal) => {
|
|
44
46
|
const set = projects.get(projectId) ?? new Set();
|
|
45
47
|
projects.set(projectId, set);
|
|
46
|
-
|
|
48
|
+
const subscriber = join(set, stream, signal, () => {
|
|
47
49
|
if (set.size === 0) {
|
|
48
50
|
projects.delete(projectId);
|
|
49
51
|
}
|
|
50
|
-
})
|
|
52
|
+
});
|
|
53
|
+
if (set.has(subscriber))
|
|
54
|
+
for (const event of previews.snapshot(projectId))
|
|
55
|
+
send(subscriber, event);
|
|
56
|
+
return subscriber.done;
|
|
51
57
|
},
|
|
52
58
|
subscribeGlobal: (stream, signal) => {
|
|
53
59
|
const subscriber = join(globals, stream, signal, () => { });
|
|
@@ -57,6 +63,7 @@ export function createHub(deps) {
|
|
|
57
63
|
return subscriber.done;
|
|
58
64
|
},
|
|
59
65
|
emit: (projectId, event) => {
|
|
66
|
+
previews.observe(event);
|
|
60
67
|
for (const subscriber of projects.get(projectId) ?? []) {
|
|
61
68
|
send(subscriber, event);
|
|
62
69
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Preview memory is bounded independently from durable outputs. A subscriber joining
|
|
2
|
+
// mid-call receives only visible response text, never prompts or provider reasoning.
|
|
3
|
+
export function createPreviewCache() {
|
|
4
|
+
const projects = new Map();
|
|
5
|
+
return {
|
|
6
|
+
observe: (event) => {
|
|
7
|
+
if (event.type === "stage.state") {
|
|
8
|
+
const calls = projects.get(event.projectId);
|
|
9
|
+
if (calls) {
|
|
10
|
+
for (const [id, call] of calls)
|
|
11
|
+
if (call.stage === event.stage)
|
|
12
|
+
calls.delete(id);
|
|
13
|
+
if (calls.size === 0)
|
|
14
|
+
projects.delete(event.projectId);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (event.type !== "llm.preview")
|
|
19
|
+
return;
|
|
20
|
+
const calls = projects.get(event.projectId) ?? new Map();
|
|
21
|
+
const prior = calls.get(event.callId);
|
|
22
|
+
const text = (event.reset ? event.text : `${prior?.text ?? ""}${event.text}`).slice(-64 * 1024);
|
|
23
|
+
calls.delete(event.callId);
|
|
24
|
+
calls.set(event.callId, { ...event, text, reset: true });
|
|
25
|
+
while (calls.size > 8) {
|
|
26
|
+
const id = calls.keys().next().value;
|
|
27
|
+
if (id !== undefined)
|
|
28
|
+
calls.delete(id);
|
|
29
|
+
}
|
|
30
|
+
projects.delete(event.projectId);
|
|
31
|
+
projects.set(event.projectId, calls);
|
|
32
|
+
while (projects.size > 16) {
|
|
33
|
+
const id = projects.keys().next().value;
|
|
34
|
+
if (id !== undefined)
|
|
35
|
+
projects.delete(id);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
snapshot: (projectId) => [...(projects.get(projectId)?.values() ?? [])],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -9,6 +9,7 @@ import { changeProviders, pauseProject, resumeProject } from "../../slices/contr
|
|
|
9
9
|
import { withProjectControl } from "../../slices/control/lock.js";
|
|
10
10
|
import { providerChangesSchema } from "../../slices/control/providers.js";
|
|
11
11
|
import { deleteImage, editArticle, regenerateImage, rerunStage, retryStage, } from "../../slices/reruns/index.js";
|
|
12
|
+
import { allowsCustomModel } from "../../slices/settings/models.js";
|
|
12
13
|
import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
13
14
|
import { outputsOf } from "../../slices/storage/repo.js";
|
|
14
15
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
@@ -79,6 +80,7 @@ export function actionRoutes(deps) {
|
|
|
79
80
|
runner: deps.runner,
|
|
80
81
|
emit: (projectId, event) => deps.hub.emit(projectId, event),
|
|
81
82
|
providers: () => providerStatuses({ db: deps.db, probe: deps.probe }),
|
|
83
|
+
allowsCustomModels: deps.catalogue ? () => false : allowsCustomModel,
|
|
82
84
|
modelsFor: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
83
85
|
};
|
|
84
86
|
const view = (projectId) => {
|
package/dist/edge/http/app.js
CHANGED
|
@@ -3,9 +3,11 @@ import { serveStatic } from "@hono/node-server/serve-static";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { streamSSE } from "hono/streaming";
|
|
5
5
|
import { actionRoutes } from "./actions.js";
|
|
6
|
+
import { audioPreviewRoutes } from "./audio-preview.js";
|
|
6
7
|
import { entryRoutes } from "./entries.js";
|
|
7
8
|
import { fileRoutes } from "./files.js";
|
|
8
9
|
import { fontsRoutes } from "./fonts.js";
|
|
10
|
+
import { planningRoutes } from "./planning.js";
|
|
9
11
|
import { problem, problemFromError, titleOf } from "./problem.js";
|
|
10
12
|
import { projectRoutes } from "./projects.js";
|
|
11
13
|
import { promptRoutes } from "./prompts.js";
|
|
@@ -14,6 +16,7 @@ import { settingsRoutes } from "./settings.js";
|
|
|
14
16
|
import { stagingRoutes } from "./staging.js";
|
|
15
17
|
import { subtitleRoutes } from "./subtitles.js";
|
|
16
18
|
import { telemetryRoutes } from "./telemetry.js";
|
|
19
|
+
import { updateRoutes } from "./update.js";
|
|
17
20
|
import { usageRoutes } from "./usage.js";
|
|
18
21
|
const notBuilt = "The Slopify UI is not built yet. The API is running: try /api/health.\n" +
|
|
19
22
|
"Build the SPA with `npm run build` at the repository root.\n";
|
|
@@ -25,7 +28,10 @@ function apiRoutes(deps, startedAt) {
|
|
|
25
28
|
uptimeMs: deps.clock.now().getTime() - startedAt,
|
|
26
29
|
}))
|
|
27
30
|
.route("/staging", stagingRoutes(deps))
|
|
31
|
+
.route("/projects", planningRoutes(deps))
|
|
28
32
|
.route("/projects", projectRoutes(deps))
|
|
33
|
+
.route("/projects", audioPreviewRoutes(deps))
|
|
34
|
+
.route("/update", updateRoutes(deps))
|
|
29
35
|
// The re-run and cancel actions sit on the same prefix as the project itself; they
|
|
30
36
|
// are their own router because they are their own concern.
|
|
31
37
|
.route("/projects", actionRoutes(deps))
|
|
@@ -42,8 +48,29 @@ export function createApp(deps) {
|
|
|
42
48
|
const startedAt = deps.clock.now().getTime();
|
|
43
49
|
const app = new Hono()
|
|
44
50
|
.use("*", async (c, next) => {
|
|
45
|
-
|
|
51
|
+
// A provisional candidate must not trigger the stale-tab Reload dialog.
|
|
52
|
+
if (!deps.updater?.locked())
|
|
53
|
+
c.header("X-Slopify-Version", deps.version);
|
|
46
54
|
await next();
|
|
55
|
+
})
|
|
56
|
+
.use("/api/*", async (c, next) => {
|
|
57
|
+
if (deps.updater === undefined ||
|
|
58
|
+
["GET", "HEAD", "OPTIONS"].includes(c.req.method) ||
|
|
59
|
+
["/api/update", "/api/update/", "/api/update/activate", "/api/update/activate/"].includes(c.req.path))
|
|
60
|
+
return next();
|
|
61
|
+
const release = deps.updater.beginMutation();
|
|
62
|
+
if (release === undefined)
|
|
63
|
+
return problem(c, {
|
|
64
|
+
status: 409,
|
|
65
|
+
title: titleOf(409),
|
|
66
|
+
detail: "Slopify is updating. Wait for it to restart before making changes.",
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await next();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
release();
|
|
73
|
+
}
|
|
47
74
|
})
|
|
48
75
|
.onError((error, c) => problemFromError(c, error, deps))
|
|
49
76
|
.notFound(missing)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { projectTitle } from "../../slices/storage/repo.js";
|
|
5
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
6
|
+
const projectParam = z.object({
|
|
7
|
+
projectId: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(64)
|
|
11
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
12
|
+
});
|
|
13
|
+
const previewParam = projectParam.extend({ previewId: z.uuid() });
|
|
14
|
+
export function audioPreviewRoutes(deps) {
|
|
15
|
+
return new Hono()
|
|
16
|
+
.get("/:projectId/audio-preview", zValidator("param", projectParam, onInvalid), (c) => {
|
|
17
|
+
const { projectId } = c.req.valid("param");
|
|
18
|
+
if (projectTitle(deps.db, projectId) === undefined)
|
|
19
|
+
return missing(c);
|
|
20
|
+
c.header("Cache-Control", "no-store");
|
|
21
|
+
return c.json({ previews: deps.audioPreviews?.list(projectId) ?? [] });
|
|
22
|
+
})
|
|
23
|
+
.get("/:projectId/audio-preview/:previewId", zValidator("param", previewParam, onInvalid), (c) => {
|
|
24
|
+
const { projectId, previewId } = c.req.valid("param");
|
|
25
|
+
if (projectTitle(deps.db, projectId) === undefined)
|
|
26
|
+
return missing(c);
|
|
27
|
+
const stream = deps.audioPreviews?.stream(projectId, previewId, c.req.raw.signal);
|
|
28
|
+
if (stream === undefined)
|
|
29
|
+
return missing(c);
|
|
30
|
+
// A growing MP3 has no final length or seek range. A native audio element
|
|
31
|
+
// can consume it progressively; late listeners receive the retained prefix.
|
|
32
|
+
return c.body(stream, 200, {
|
|
33
|
+
"Content-Type": "audio/mpeg",
|
|
34
|
+
"Cache-Control": "no-store",
|
|
35
|
+
"Accept-Ranges": "none",
|
|
36
|
+
"X-Content-Type-Options": "nosniff",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function missing(c) {
|
|
41
|
+
return problem(c, {
|
|
42
|
+
status: 404,
|
|
43
|
+
title: titleOf(404),
|
|
44
|
+
detail: "This live audio preview is no longer available. Finished narration is available in the audio outputs.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
5
|
+
import { runDraftSchema } from "../../slices/admission/repo.js";
|
|
6
|
+
import { admit } from "../../slices/admission/rules.js";
|
|
7
|
+
import { batchExists, enqueueBatch, pumpQueue, queueEntries } from "../../slices/batch/index.js";
|
|
8
|
+
import { estimateRun } from "../../slices/estimate/index.js";
|
|
9
|
+
import { resolveFont } from "../../slices/fonts/index.js";
|
|
10
|
+
import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
|
|
11
|
+
import { stagedFiles } from "../../slices/storage/repo.js";
|
|
12
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
13
|
+
const planningBody = z.object({
|
|
14
|
+
draft: runDraftSchema,
|
|
15
|
+
expectedWords: z.number().int().min(1).max(100000).default(1500),
|
|
16
|
+
items: z
|
|
17
|
+
.array(z
|
|
18
|
+
.object({
|
|
19
|
+
title: z.string().trim().min(1).max(200),
|
|
20
|
+
values: z.record(z.string().max(200), z.string().max(10000)),
|
|
21
|
+
})
|
|
22
|
+
.strict())
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(50)
|
|
25
|
+
.optional(),
|
|
26
|
+
});
|
|
27
|
+
const batchBody = planningBody.extend({ requestId: z.uuid() });
|
|
28
|
+
export function planningRoutes(deps) {
|
|
29
|
+
const prepare = (input) => {
|
|
30
|
+
const drafts = input.items?.map((item) => ({
|
|
31
|
+
...input.draft,
|
|
32
|
+
title: item.title,
|
|
33
|
+
values: { ...input.draft.values, ...item.values },
|
|
34
|
+
})) ?? [input.draft];
|
|
35
|
+
return drafts.map((draft, index) => {
|
|
36
|
+
const picked = pickTemplates(deps.db, draft);
|
|
37
|
+
const admitted = admit({
|
|
38
|
+
draft: picked.draft,
|
|
39
|
+
staged: stagedFiles(deps.db),
|
|
40
|
+
requiredSlots: picked.requiredSlots,
|
|
41
|
+
});
|
|
42
|
+
const accepted = admitted.ok ? admitted.draft : picked.draft;
|
|
43
|
+
const fields = [
|
|
44
|
+
...picked.missing,
|
|
45
|
+
...(admitted.ok ? [] : admitted.fields),
|
|
46
|
+
...modelFields(accepted, deps.catalogue),
|
|
47
|
+
].map((f) => ({
|
|
48
|
+
...f,
|
|
49
|
+
message: `${drafts.length > 1 ? `Video ${index + 1}: ` : ""}${f.message}`,
|
|
50
|
+
}));
|
|
51
|
+
return { draft: accepted, rendered: renderPicked(picked, accepted.values), fields };
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
return new Hono()
|
|
55
|
+
.get("/queue", (c) => c.json({ queue: queueEntries(deps.db) }))
|
|
56
|
+
.post("/estimate", zValidator("json", planningBody, onInvalid), (c) => {
|
|
57
|
+
const input = c.req.valid("json");
|
|
58
|
+
const runs = prepare(input);
|
|
59
|
+
const fields = runs.flatMap((r) => r.fields);
|
|
60
|
+
if (fields.length)
|
|
61
|
+
return problem(c, {
|
|
62
|
+
status: 400,
|
|
63
|
+
title: titleOf(400),
|
|
64
|
+
detail: "Review the run setup.",
|
|
65
|
+
extensions: { fields },
|
|
66
|
+
});
|
|
67
|
+
return c.json({
|
|
68
|
+
estimates: runs.map((r) => estimateRun(r.draft, r.rendered, input.expectedWords, deps.catalogue)),
|
|
69
|
+
});
|
|
70
|
+
})
|
|
71
|
+
.post("/batch", zValidator("json", batchBody, onInvalid), async (c) => {
|
|
72
|
+
const input = c.req.valid("json");
|
|
73
|
+
if (batchExists(deps.db, input.requestId))
|
|
74
|
+
return c.json({ queue: queueEntries(deps.db, input.requestId) });
|
|
75
|
+
const subtitles = input.draft.subtitles;
|
|
76
|
+
if (subtitles && subtitles.mode !== "off") {
|
|
77
|
+
try {
|
|
78
|
+
await resolveFont(deps.paths, subtitles.fontId);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return problem(c, {
|
|
82
|
+
status: 400,
|
|
83
|
+
title: titleOf(400),
|
|
84
|
+
detail: "Choose an available subtitle font before starting.",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const runs = prepare(input);
|
|
89
|
+
const fields = runs.flatMap((r) => r.fields);
|
|
90
|
+
if (fields.length)
|
|
91
|
+
return problem(c, {
|
|
92
|
+
status: 400,
|
|
93
|
+
title: titleOf(400),
|
|
94
|
+
detail: "No videos were queued. Review the listed fields.",
|
|
95
|
+
extensions: { fields },
|
|
96
|
+
});
|
|
97
|
+
const queue = enqueueBatch({ ...deps, emit: (event) => deps.hub.emitGlobal(event) }, input.requestId, runs);
|
|
98
|
+
pumpQueue(deps.db, deps.runner);
|
|
99
|
+
return c.json({ queue }, 201);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { zValidator } from "@hono/zod-validator";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
4
5
|
import { derive, progressOf } from "../../kernel/runner/graph.js";
|
|
5
6
|
import { listProjects, projectById, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
|
|
6
7
|
import { admit } from "../../slices/admission/rules.js";
|
|
@@ -75,13 +76,14 @@ export function projectRoutes(deps) {
|
|
|
75
76
|
staged: stagedFiles(deps.db),
|
|
76
77
|
requiredSlots: picked.requiredSlots,
|
|
77
78
|
});
|
|
78
|
-
|
|
79
|
+
const modelErrors = modelFields(picked.draft, deps.catalogue);
|
|
80
|
+
if (!admitted.ok || picked.missing.length > 0 || modelErrors.length > 0) {
|
|
79
81
|
return problem(c, {
|
|
80
82
|
status: 400,
|
|
81
83
|
title: titleOf(400),
|
|
82
84
|
detail: "This run cannot start yet; the listed fields need attention.",
|
|
83
85
|
extensions: {
|
|
84
|
-
fields: [...picked.missing, ...(admitted.ok ? [] : admitted.fields)],
|
|
86
|
+
fields: [...picked.missing, ...modelErrors, ...(admitted.ok ? [] : admitted.fields)],
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
}
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
|
|
5
5
|
import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
|
|
6
6
|
import { providerById, providerIds } from "../../slices/settings/model.js";
|
|
7
|
+
import { createModelCatalog } from "../../slices/settings/models.js";
|
|
7
8
|
import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
8
9
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
9
10
|
const providerParam = z.object({ id: z.enum(providerIds) });
|
|
@@ -17,10 +18,50 @@ const keyBody = z.object({ key: z.string().min(1).max(4096) });
|
|
|
17
18
|
export function providerRoutes(deps) {
|
|
18
19
|
const keys = { db: deps.db, clock: deps.clock };
|
|
19
20
|
const readiness = { db: deps.db, probe: deps.probe };
|
|
21
|
+
const catalog = createModelCatalog({
|
|
22
|
+
load: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
23
|
+
fallback: deps.fallbackModelsFor ?? (() => []),
|
|
24
|
+
now: () => deps.clock.now().getTime(),
|
|
25
|
+
report: (provider) => deps.log.write("warn", "model-catalog", { detail: `Could not load models for ${provider}` }),
|
|
26
|
+
});
|
|
20
27
|
return (new Hono()
|
|
28
|
+
.get("/catalogue", (c) => c.json(deps.catalogue?.status() ?? { warning: "Catalogue unavailable." }))
|
|
29
|
+
.post("/catalogue/refresh", async (c) => {
|
|
30
|
+
if (!deps.catalogue)
|
|
31
|
+
return problem(c, { status: 503, title: titleOf(503), detail: "Catalogue unavailable." });
|
|
32
|
+
try {
|
|
33
|
+
await deps.catalogue.refresh();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return problem(c, {
|
|
37
|
+
status: 502,
|
|
38
|
+
title: titleOf(502),
|
|
39
|
+
detail: "Could not update the catalogue. The last valid version remains in use.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return c.json(deps.catalogue.status());
|
|
43
|
+
})
|
|
21
44
|
// What Settings draws its rails from and Play its dropdowns: every provider, with
|
|
22
45
|
// the one fact that decides whether it is selectable.
|
|
23
46
|
.get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
|
|
47
|
+
.get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
|
|
48
|
+
const { id } = c.req.valid("param");
|
|
49
|
+
if (deps.catalogue)
|
|
50
|
+
return c.json({
|
|
51
|
+
models: deps.catalogue.models(id, providerById(id).family).map((m) => ({
|
|
52
|
+
...m,
|
|
53
|
+
...("llm" in m && m.llm.thinking
|
|
54
|
+
? { thinkingModes: Object.keys(m.llm.thinking) }
|
|
55
|
+
: {}),
|
|
56
|
+
})),
|
|
57
|
+
allowsCustom: false,
|
|
58
|
+
notice: "Curated active models from models.yaml. Edit it locally or refresh in Settings.",
|
|
59
|
+
warning: deps.catalogue.status().warning ?? undefined,
|
|
60
|
+
});
|
|
61
|
+
const result = await catalog.get(id, providerById(id).family, c.req.query("refresh") === "1");
|
|
62
|
+
c.header("Cache-Control", "no-store");
|
|
63
|
+
return c.json(result);
|
|
64
|
+
})
|
|
24
65
|
.put("/:id/path", zValidator("param", providerParam, onInvalid), zValidator("json", pathBody, onInvalid), async (c) => {
|
|
25
66
|
const result = await saveCliPath(readiness, c.req.valid("param").id, c.req.valid("json").path);
|
|
26
67
|
if (!result.ok)
|
|
@@ -30,6 +71,7 @@ export function providerRoutes(deps) {
|
|
|
30
71
|
detail: result.message,
|
|
31
72
|
extensions: { fields: [{ field: "path", message: result.message }] },
|
|
32
73
|
});
|
|
74
|
+
catalog.invalidate(c.req.valid("param").id);
|
|
33
75
|
return c.json(result.status);
|
|
34
76
|
})
|
|
35
77
|
.put("/:id/key", zValidator("param", providerParam, onInvalid), zValidator("json", keyBody, onInvalid), (c) => {
|
|
@@ -38,6 +80,7 @@ export function providerRoutes(deps) {
|
|
|
38
80
|
if (!result.ok) {
|
|
39
81
|
return refusal(c, id, result.reason);
|
|
40
82
|
}
|
|
83
|
+
catalog.invalidate(id);
|
|
41
84
|
// The response says a key is stored and shows the mask, never the value.
|
|
42
85
|
return c.json(keyStatus(keys, id));
|
|
43
86
|
})
|
|
@@ -47,6 +90,7 @@ export function providerRoutes(deps) {
|
|
|
47
90
|
if (!result.ok) {
|
|
48
91
|
return refusal(c, id, result.reason);
|
|
49
92
|
}
|
|
93
|
+
catalog.invalidate(id);
|
|
50
94
|
return c.body(null, 204);
|
|
51
95
|
}));
|
|
52
96
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { problem, titleOf } from "./problem.js";
|
|
3
|
+
export function updateRoutes(deps) {
|
|
4
|
+
const unavailable = {
|
|
5
|
+
currentVersion: deps.version,
|
|
6
|
+
latestVersion: null,
|
|
7
|
+
available: false,
|
|
8
|
+
busy: false,
|
|
9
|
+
canUpdate: false,
|
|
10
|
+
status: "idle",
|
|
11
|
+
blockedReason: "In-app updates are unavailable for this server.",
|
|
12
|
+
};
|
|
13
|
+
return new Hono()
|
|
14
|
+
.get("/ready", (c) => {
|
|
15
|
+
c.header("Cache-Control", "no-store");
|
|
16
|
+
if (!deps.updater?.ready(c.req.header("X-Slopify-Update-Token") ?? ""))
|
|
17
|
+
return problem(c, { status: 403, title: titleOf(403) });
|
|
18
|
+
return c.json({ status: "ok", version: deps.version });
|
|
19
|
+
})
|
|
20
|
+
.post("/activate", async (c) => {
|
|
21
|
+
c.header("Cache-Control", "no-store");
|
|
22
|
+
const token = c.req.header("X-Slopify-Update-Token") ?? "";
|
|
23
|
+
if (!deps.updater?.ready(token))
|
|
24
|
+
return problem(c, { status: 403, title: titleOf(403) });
|
|
25
|
+
if (!(await deps.updater.activate(token)))
|
|
26
|
+
return problem(c, { status: 409, title: titleOf(409) });
|
|
27
|
+
return c.json({ status: "ok", version: deps.version });
|
|
28
|
+
})
|
|
29
|
+
.get("/", async (c) => {
|
|
30
|
+
c.header("Cache-Control", "no-store");
|
|
31
|
+
return c.json(deps.updater === undefined
|
|
32
|
+
? unavailable
|
|
33
|
+
: await deps.updater.check(c.req.query("refresh") === "1"));
|
|
34
|
+
})
|
|
35
|
+
.post("/", async (c) => {
|
|
36
|
+
const origin = c.req.header("Origin");
|
|
37
|
+
if (origin !== undefined && origin !== new URL(c.req.url).origin)
|
|
38
|
+
return problem(c, {
|
|
39
|
+
status: 403,
|
|
40
|
+
title: titleOf(403),
|
|
41
|
+
detail: "Start updates from the Slopify page on this server.",
|
|
42
|
+
});
|
|
43
|
+
if (deps.updater === undefined)
|
|
44
|
+
return problem(c, { status: 409, title: titleOf(409), detail: unavailable.blockedReason });
|
|
45
|
+
const result = await deps.updater.start();
|
|
46
|
+
if (result.ok)
|
|
47
|
+
return c.json(result.info, 202);
|
|
48
|
+
const status = result.code ?? 409;
|
|
49
|
+
return problem(c, {
|
|
50
|
+
status,
|
|
51
|
+
title: titleOf(status),
|
|
52
|
+
detail: result.info.blockedReason ??
|
|
53
|
+
result.info.error ??
|
|
54
|
+
"No newer published update is available.",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { updatePlanSchema } from "../updater/plan.js";
|
|
3
|
+
import { performUpdate } from "../updater/worker.js";
|
|
4
|
+
const planPath = process.argv[2];
|
|
5
|
+
try {
|
|
6
|
+
if (planPath === undefined || !process.connected)
|
|
7
|
+
throw new Error("An update plan and parent process are required.");
|
|
8
|
+
const plan = updatePlanSchema.parse(JSON.parse(await readFile(planPath, "utf8")));
|
|
9
|
+
await rm(planPath, { force: true });
|
|
10
|
+
await performUpdate(plan, () => new Promise((resolve, reject) => {
|
|
11
|
+
if (!process.connected) {
|
|
12
|
+
reject(new Error("The update parent disconnected."));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const fail = () => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
reject(new Error("The update parent disconnected."));
|
|
18
|
+
};
|
|
19
|
+
const timer = setTimeout(() => reject(new Error("The update handoff timed out.")), 60_000);
|
|
20
|
+
process.once("disconnect", fail);
|
|
21
|
+
process.once("message", (message) => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
process.off("disconnect", fail);
|
|
24
|
+
if (typeof message === "object" &&
|
|
25
|
+
message !== null &&
|
|
26
|
+
"type" in message &&
|
|
27
|
+
message.type === "handoff")
|
|
28
|
+
resolve();
|
|
29
|
+
else
|
|
30
|
+
reject(new Error("The update handoff was refused."));
|
|
31
|
+
});
|
|
32
|
+
process.send?.({ type: "installed" }, (error) => {
|
|
33
|
+
if (error !== null)
|
|
34
|
+
fail();
|
|
35
|
+
});
|
|
36
|
+
}));
|
|
37
|
+
if (process.connected)
|
|
38
|
+
process.disconnect?.();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
if (process.connected)
|
|
42
|
+
process.disconnect?.();
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|