@gentbajko/slopify 0.5.0 → 0.5.1
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/dist/edge/events/hub.js +4 -0
- package/dist/edge/http/actions.js +68 -14
- package/dist/edge/http/projects.js +17 -10
- package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
- package/dist/kernel/pipeline.js +10 -5
- package/dist/kernel/runner/graph.js +18 -18
- package/dist/kernel/runner/index.js +10 -6
- package/dist/kernel/runner/piece-repo.js +8 -1
- package/dist/main.js +30 -3
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +20 -4
- package/dist/slices/admission/rules.js +26 -12
- package/dist/slices/admission/start.js +3 -1
- package/dist/slices/article/provided-entries.js +62 -0
- package/dist/slices/article/run.js +63 -101
- package/dist/slices/article/segments.js +73 -0
- package/dist/slices/cancel/index.js +27 -4
- package/dist/slices/control/index.js +155 -0
- package/dist/slices/control/lock.js +19 -0
- package/dist/slices/control/providers.js +59 -0
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/reruns/cascade.js +11 -7
- package/dist/slices/reruns/index.js +23 -1
- package/dist/slices/storage/layout.js +2 -0
- package/dist/slices/storage/model.js +1 -0
- package/dist/slices/video/audio-export.js +66 -0
- package/dist/slices/video/audio-inputs.js +32 -0
- package/dist/slices/video/ffmpeg.js +5 -5
- package/dist/slices/video/plan.js +9 -8
- package/dist/slices/video/run.js +25 -141
- package/dist/slices/video/write-export.js +98 -0
- package/dist/web/assets/index-C1fsrIP5.css +1 -0
- package/dist/web/assets/index-DwJ_XTl3.js +81 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BZvZKvIv.js +0 -81
- package/dist/web/assets/index-cpjfvs5E.css +0 -1
package/dist/edge/events/hub.js
CHANGED
|
@@ -60,6 +60,10 @@ export function createHub(deps) {
|
|
|
60
60
|
for (const subscriber of projects.get(projectId) ?? []) {
|
|
61
61
|
send(subscriber, event);
|
|
62
62
|
}
|
|
63
|
+
if (event.type === "project.state" || event.type === "project.updated") {
|
|
64
|
+
for (const subscriber of globals)
|
|
65
|
+
send(subscriber, event);
|
|
66
|
+
}
|
|
63
67
|
},
|
|
64
68
|
emitGlobal: (event) => {
|
|
65
69
|
if (event.type === "running.count") {
|
|
@@ -3,9 +3,13 @@ import { Hono } from "hono";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
5
5
|
import { derive } from "../../kernel/runner/graph.js";
|
|
6
|
-
import { projectById, stagesOf } from "../../slices/admission/repo.js";
|
|
6
|
+
import { projectById, projectPaused, stagesOf } from "../../slices/admission/repo.js";
|
|
7
7
|
import { cancelProject } from "../../slices/cancel/index.js";
|
|
8
|
+
import { changeProviders, pauseProject, resumeProject } from "../../slices/control/index.js";
|
|
9
|
+
import { withProjectControl } from "../../slices/control/lock.js";
|
|
10
|
+
import { providerChangesSchema } from "../../slices/control/providers.js";
|
|
8
11
|
import { deleteImage, editArticle, regenerateImage, rerunStage, retryStage, } from "../../slices/reruns/index.js";
|
|
12
|
+
import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
9
13
|
import { outputsOf } from "../../slices/storage/repo.js";
|
|
10
14
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
11
15
|
// The actions on the project header and on each stage: Cancel, Retry, Re-run, Save &
|
|
@@ -70,10 +74,20 @@ export function actionRoutes(deps) {
|
|
|
70
74
|
deps.hub.emit(projectId, event);
|
|
71
75
|
},
|
|
72
76
|
};
|
|
77
|
+
const control = {
|
|
78
|
+
...reruns,
|
|
79
|
+
runner: deps.runner,
|
|
80
|
+
emit: (projectId, event) => deps.hub.emit(projectId, event),
|
|
81
|
+
providers: () => providerStatuses({ db: deps.db, probe: deps.probe }),
|
|
82
|
+
modelsFor: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
83
|
+
};
|
|
73
84
|
const view = (projectId) => {
|
|
74
85
|
const stages = stagesOf(deps.db, projectId);
|
|
75
86
|
return {
|
|
76
|
-
project: {
|
|
87
|
+
project: {
|
|
88
|
+
...projectById(deps.db, projectId),
|
|
89
|
+
status: derive(stages, projectPaused(deps.db, projectId)),
|
|
90
|
+
},
|
|
77
91
|
stages,
|
|
78
92
|
outputs: outputsOf(deps.db, projectId),
|
|
79
93
|
};
|
|
@@ -88,37 +102,77 @@ export function actionRoutes(deps) {
|
|
|
88
102
|
detail: details[result.reason],
|
|
89
103
|
});
|
|
90
104
|
}
|
|
91
|
-
deps.
|
|
105
|
+
deps.hub.emit(projectId, { type: "project.updated", projectId });
|
|
106
|
+
if (!projectPaused(deps.db, projectId))
|
|
107
|
+
deps.runner.tick(projectId);
|
|
92
108
|
return c.json({ ...view(projectId), redone: result.redone });
|
|
93
109
|
};
|
|
110
|
+
const controlled = (c, id, result) => {
|
|
111
|
+
if (result.ok)
|
|
112
|
+
return c.json(view(id));
|
|
113
|
+
const codes = {
|
|
114
|
+
"no-project": 404,
|
|
115
|
+
running: 409,
|
|
116
|
+
"not-editable": 409,
|
|
117
|
+
"invalid-providers": 400,
|
|
118
|
+
"catalog-unavailable": 503,
|
|
119
|
+
};
|
|
120
|
+
const messages = {
|
|
121
|
+
"no-project": "No project has that id.",
|
|
122
|
+
running: "Pause the run and wait for its active calls to stop before changing providers.",
|
|
123
|
+
"not-editable": "Providers can be changed only while a project is paused or failed.",
|
|
124
|
+
"invalid-providers": "The provider choices need attention.",
|
|
125
|
+
"catalog-unavailable": "The provider model catalog could not be loaded. Try again.",
|
|
126
|
+
};
|
|
127
|
+
return problem(c, {
|
|
128
|
+
status: codes[result.reason],
|
|
129
|
+
title: titleOf(codes[result.reason]),
|
|
130
|
+
detail: messages[result.reason],
|
|
131
|
+
...(result.fields === undefined ? {} : { extensions: { fields: result.fields } }),
|
|
132
|
+
});
|
|
133
|
+
};
|
|
94
134
|
return new Hono()
|
|
135
|
+
.post("/:id/pause", zValidator("param", idParam, onInvalid), async (c) => {
|
|
136
|
+
const { id } = c.req.valid("param");
|
|
137
|
+
return controlled(c, id, await pauseProject(control, id));
|
|
138
|
+
})
|
|
139
|
+
.post("/:id/resume", zValidator("param", idParam, onInvalid), async (c) => {
|
|
140
|
+
const { id } = c.req.valid("param");
|
|
141
|
+
return controlled(c, id, await resumeProject(control, id));
|
|
142
|
+
})
|
|
143
|
+
.patch("/:id/providers", zValidator("param", idParam, onInvalid), zValidator("json", providerChangesSchema, onInvalid), async (c) => {
|
|
144
|
+
const { id } = c.req.valid("param");
|
|
145
|
+
return controlled(c, id, await changeProviders(control, id, c.req.valid("json")));
|
|
146
|
+
})
|
|
95
147
|
.post("/:id/cancel", zValidator("param", idParam, onInvalid), async (c) => {
|
|
96
148
|
const { id } = c.req.valid("param");
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
return withProjectControl(deps.db, id, async () => {
|
|
150
|
+
const result = await cancelProject(cancel, id);
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
return problem(c, { status: 404, title: titleOf(404), detail: details["no-project"] });
|
|
153
|
+
}
|
|
154
|
+
// No tick: a canceled project sits until the user retries a stage.
|
|
155
|
+
return c.json({ ...view(id), canceled: result.canceled });
|
|
156
|
+
});
|
|
103
157
|
})
|
|
104
158
|
.post("/:id/stages/:kind/retry", zValidator("param", stageParam, onInvalid), (c) => {
|
|
105
159
|
const { id, kind } = c.req.valid("param");
|
|
106
|
-
return started(c, id, retryStage(reruns, id, kind));
|
|
160
|
+
return withProjectControl(deps.db, id, () => started(c, id, retryStage(reruns, id, kind)));
|
|
107
161
|
})
|
|
108
162
|
.post("/:id/stages/:kind/rerun", zValidator("param", stageParam, onInvalid), (c) => {
|
|
109
163
|
const { id, kind } = c.req.valid("param");
|
|
110
|
-
return started(c, id, rerunStage(reruns, id, kind));
|
|
164
|
+
return withProjectControl(deps.db, id, () => started(c, id, rerunStage(reruns, id, kind)));
|
|
111
165
|
})
|
|
112
166
|
.put("/:id/article", zValidator("param", idParam, onInvalid), zValidator("json", articleBody, onInvalid), (c) => {
|
|
113
167
|
const { id } = c.req.valid("param");
|
|
114
|
-
return started(c, id, editArticle(reruns, id, c.req.valid("json").markdown));
|
|
168
|
+
return withProjectControl(deps.db, id, () => started(c, id, editArticle(reruns, id, c.req.valid("json").markdown)));
|
|
115
169
|
})
|
|
116
170
|
.delete("/:id/images/:outputId", zValidator("param", imageParam, onInvalid), (c) => {
|
|
117
171
|
const { id, outputId } = c.req.valid("param");
|
|
118
|
-
return started(c, id, deleteImage(reruns, id, outputId));
|
|
172
|
+
return withProjectControl(deps.db, id, () => started(c, id, deleteImage(reruns, id, outputId)));
|
|
119
173
|
})
|
|
120
174
|
.post("/:id/images/:outputId/regenerate", zValidator("param", imageParam, onInvalid), (c) => {
|
|
121
175
|
const { id, outputId } = c.req.valid("param");
|
|
122
|
-
return started(c, id, regenerateImage(reruns, id, outputId));
|
|
176
|
+
return withProjectControl(deps.db, id, () => started(c, id, regenerateImage(reruns, id, outputId)));
|
|
123
177
|
});
|
|
124
178
|
}
|
|
@@ -5,6 +5,7 @@ import { derive, progressOf } from "../../kernel/runner/graph.js";
|
|
|
5
5
|
import { listProjects, projectById, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
|
|
6
6
|
import { admit } from "../../slices/admission/rules.js";
|
|
7
7
|
import { startRun } from "../../slices/admission/start.js";
|
|
8
|
+
import { withProjectControl } from "../../slices/control/lock.js";
|
|
8
9
|
import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
|
|
9
10
|
import { deleteProject } from "../../slices/storage/delete-project.js";
|
|
10
11
|
import { outputsOf, stagedFiles } from "../../slices/storage/repo.js";
|
|
@@ -40,7 +41,7 @@ export function projectRoutes(deps) {
|
|
|
40
41
|
const storageForDelete = { db: deps.db, paths: deps.paths, log: deps.log };
|
|
41
42
|
const summarise = (project) => ({
|
|
42
43
|
...project,
|
|
43
|
-
status: derive(stagesOf(deps.db, project.id)),
|
|
44
|
+
status: derive(stagesOf(deps.db, project.id), project.paused),
|
|
44
45
|
});
|
|
45
46
|
return (new Hono()
|
|
46
47
|
.post("/", zValidator("json", runDraftSchema, onInvalid), (c) => {
|
|
@@ -82,7 +83,11 @@ export function projectRoutes(deps) {
|
|
|
82
83
|
const standings = stageStandingsByProject(deps.db);
|
|
83
84
|
const projects = listProjects(deps.db).map((project) => {
|
|
84
85
|
const stages = standings.get(project.id) ?? [];
|
|
85
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
...project,
|
|
88
|
+
status: derive(stages, project.paused),
|
|
89
|
+
progress: progressOf(stages),
|
|
90
|
+
};
|
|
86
91
|
});
|
|
87
92
|
return c.json({ projects });
|
|
88
93
|
})
|
|
@@ -104,14 +109,16 @@ export function projectRoutes(deps) {
|
|
|
104
109
|
// Irreversible, and only from the app: the Projects screen puts a confirmation
|
|
105
110
|
// dialog in front of it.
|
|
106
111
|
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const id = c.req.valid("param").id;
|
|
113
|
+
return withProjectControl(deps.db, id, () => {
|
|
114
|
+
const result = deleteProject(storageForDelete, id);
|
|
115
|
+
if (result.ok)
|
|
116
|
+
return c.body(null, 204);
|
|
117
|
+
return problem(c, {
|
|
118
|
+
status: deleteStatus[result.reason],
|
|
119
|
+
title: titleOf(deleteStatus[result.reason]),
|
|
120
|
+
detail: result.detail ?? deleteDetails[result.reason],
|
|
121
|
+
});
|
|
115
122
|
});
|
|
116
123
|
}));
|
|
117
124
|
}
|
package/dist/kernel/pipeline.js
CHANGED
|
@@ -13,8 +13,13 @@ export const stageStates = [
|
|
|
13
13
|
"provided",
|
|
14
14
|
"skipped",
|
|
15
15
|
];
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// A persisted pause overrides the stage-derived status. A completed run need not
|
|
17
|
+
// include a video: all selected stages being satisfied is enough.
|
|
18
|
+
export const projectStates = [
|
|
19
|
+
"running",
|
|
20
|
+
"paused",
|
|
21
|
+
"canceled",
|
|
22
|
+
"failed",
|
|
23
|
+
"done",
|
|
24
|
+
"pending",
|
|
25
|
+
];
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Images use saved prompts and can run immediately. A thumbnail's source and an
|
|
2
|
+
// audio-only export narrow these dependencies through dependenciesOf().
|
|
2
3
|
export const deps = {
|
|
3
4
|
research: [],
|
|
4
5
|
article: ["research"],
|
|
5
6
|
audio: ["article"],
|
|
6
|
-
images: [
|
|
7
|
+
images: [],
|
|
7
8
|
thumbnail: ["article"],
|
|
8
|
-
video: ["audio", "images", "thumbnail"],
|
|
9
|
+
video: ["article", "audio", "images", "thumbnail"],
|
|
9
10
|
};
|
|
10
11
|
// `provided` and `skipped` release a dependency exactly as `done` does.
|
|
11
12
|
export function satisfied(state) {
|
|
12
13
|
return state === "done" || state === "provided" || state === "skipped";
|
|
13
14
|
}
|
|
14
|
-
//
|
|
15
|
-
|
|
15
|
+
// Stage state is derived; an explicit persisted pause takes precedence while calls
|
|
16
|
+
// drain. Completed independent images do not imply that a run was canceled.
|
|
17
|
+
export function derive(stages, paused = false) {
|
|
18
|
+
if (paused)
|
|
19
|
+
return "paused";
|
|
16
20
|
if (stages.some((stage) => stage.state === "running")) {
|
|
17
21
|
return "running";
|
|
18
22
|
}
|
|
@@ -22,23 +26,19 @@ export function derive(stages) {
|
|
|
22
26
|
if (stages.some((stage) => stage.state === "failed")) {
|
|
23
27
|
return "failed";
|
|
24
28
|
}
|
|
25
|
-
if (stages.
|
|
29
|
+
if (stages.length > 0 && stages.every((stage) => satisfied(stage.state))) {
|
|
26
30
|
return "done";
|
|
27
31
|
}
|
|
28
|
-
//
|
|
29
|
-
// project and the runner claiming its first stage. A second case is reachable: a stage
|
|
30
|
-
// that stores its output in the same instant as the cancel stays `done`, so nothing is
|
|
31
|
-
// `canceled` and a stopped run would read as about to start. A stage in a terminal state
|
|
32
|
-
// tells the two apart - at creation none is `done`.
|
|
33
|
-
//
|
|
34
|
-
// ceiling: a process killed between a stage finishing and its dependent being claimed
|
|
35
|
-
// reads `canceled` too. Separating those needs the cancel stored on the project, and
|
|
36
|
-
// nothing about the status is stored.
|
|
37
|
-
if (stages.some((stage) => stage.state === "done")) {
|
|
38
|
-
return "canceled";
|
|
39
|
-
}
|
|
32
|
+
// Includes a run created but not yet claimed and unfinished work after restart.
|
|
40
33
|
return "pending";
|
|
41
34
|
}
|
|
35
|
+
export function dependenciesOf(kind, sources) {
|
|
36
|
+
if (kind === "thumbnail" && sources.thumbnail === "from_prompt")
|
|
37
|
+
return [];
|
|
38
|
+
if (kind === "video" && sources.video === "off")
|
|
39
|
+
return ["article", "audio"];
|
|
40
|
+
return deps[kind];
|
|
41
|
+
}
|
|
42
42
|
// The thin meter under a running row on Projects, averaging stage progress. Every stage
|
|
43
43
|
// the run asks for counts once: a finished one whole, a running one by its own progress, a
|
|
44
44
|
// waiting, failed or canceled one not at all. `provided` and `skipped` are left out rather
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { derive, deps as graph, satisfied } from "./graph.js";
|
|
2
|
+
const pauseReason = new Error("paused by user");
|
|
2
3
|
export function createRunner(deps) {
|
|
3
4
|
const inflight = new Map();
|
|
4
5
|
// ceiling: one entry per project ticked since boot, never evicted - a few dozen bytes
|
|
@@ -34,7 +35,7 @@ export function createRunner(deps) {
|
|
|
34
35
|
}
|
|
35
36
|
};
|
|
36
37
|
const announce = (projectId) => {
|
|
37
|
-
const state = derive(deps.stages.stagesOf(projectId));
|
|
38
|
+
const state = derive(deps.stages.stagesOf(projectId), deps.stages.paused?.(projectId));
|
|
38
39
|
if (announced.get(projectId) === state) {
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
@@ -75,7 +76,8 @@ export function createRunner(deps) {
|
|
|
75
76
|
if (controller.signal.aborted) {
|
|
76
77
|
// A late rejection from the aborted call is how a stage learns it was canceled, so
|
|
77
78
|
// it is not logged as a fault.
|
|
78
|
-
|
|
79
|
+
const paused = controller.signal.reason === pauseReason;
|
|
80
|
+
conclude(stage, paused ? "pending" : "canceled", paused ? null : "canceled by user");
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
deps.log.write("error", "stage.failed", {
|
|
@@ -127,7 +129,7 @@ export function createRunner(deps) {
|
|
|
127
129
|
// A stage finishing in the same instant as a shutdown or cancel would otherwise release
|
|
128
130
|
// its dependent, leaving the abort waiting on a render nobody asked for - or one running
|
|
129
131
|
// under a controller nobody aborted. The state still goes out, so an open page sees it.
|
|
130
|
-
if (!shuttingDown && !stopped.has(projectId)) {
|
|
132
|
+
if (!shuttingDown && !stopped.has(projectId) && !deps.stages.paused?.(projectId)) {
|
|
131
133
|
startEligible(projectId);
|
|
132
134
|
}
|
|
133
135
|
announce(projectId);
|
|
@@ -139,7 +141,8 @@ export function createRunner(deps) {
|
|
|
139
141
|
if (stage.state !== "pending" || inflight.has(stage.id)) {
|
|
140
142
|
continue;
|
|
141
143
|
}
|
|
142
|
-
|
|
144
|
+
const dependencies = deps.stages.dependenciesOf?.(projectId, stage.kind) ?? graph[stage.kind];
|
|
145
|
+
if (!dependencies.every((kind) => satisfied(stateOf(kind)))) {
|
|
143
146
|
continue;
|
|
144
147
|
}
|
|
145
148
|
// Nothing is awaited here: the fan-out starts audio, images and thumbnail together.
|
|
@@ -165,14 +168,15 @@ export function createRunner(deps) {
|
|
|
165
168
|
return {
|
|
166
169
|
tick,
|
|
167
170
|
settled,
|
|
168
|
-
|
|
171
|
+
hasInflight: (projectId) => [...inflight.values()].some((entry) => entry.projectId === projectId),
|
|
172
|
+
abortProject: async (projectId, mode = "cancel") => {
|
|
169
173
|
// Up before the first abort, down only once every stage has stopped. In between, the
|
|
170
174
|
// tick each finishing stage fires starts nothing.
|
|
171
175
|
stopped.add(projectId);
|
|
172
176
|
try {
|
|
173
177
|
for (const entry of inflight.values()) {
|
|
174
178
|
if (entry.projectId === projectId) {
|
|
175
|
-
entry.controller.abort();
|
|
179
|
+
entry.controller.abort(mode === "pause" ? pauseReason : undefined);
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
// No provider call continues after cancel returns. The wait makes it true.
|
|
@@ -3,7 +3,14 @@ import { z } from "zod";
|
|
|
3
3
|
// chunk, an image index. It sits beside the attempt store because the wrapper stamps
|
|
4
4
|
// `piece_id` on every row it writes. The payload stays the JSON text it was stored as - its
|
|
5
5
|
// shape belongs to whichever stage owns the kind, and nothing here reads inside it.
|
|
6
|
-
export const pieceKinds = [
|
|
6
|
+
export const pieceKinds = [
|
|
7
|
+
"chapter",
|
|
8
|
+
"chunk",
|
|
9
|
+
"segment",
|
|
10
|
+
"image",
|
|
11
|
+
"prompt_written",
|
|
12
|
+
"article_written",
|
|
13
|
+
];
|
|
7
14
|
export const pieceStates = ["pending", "running", "done", "failed"];
|
|
8
15
|
const pieceRow = z.object({
|
|
9
16
|
id: z.string(),
|
package/dist/main.js
CHANGED
|
@@ -4,6 +4,9 @@ import ffmpegStatic from "ffmpeg-static";
|
|
|
4
4
|
import { buildRegistry } from "./adapter-registry.js";
|
|
5
5
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
6
6
|
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";
|
|
7
10
|
import { createHub } from "./edge/events/hub.js";
|
|
8
11
|
import { createApp } from "./edge/http/app.js";
|
|
9
12
|
import { systemClock } from "./kernel/clock.js";
|
|
@@ -14,10 +17,12 @@ import { acquireInstanceLock } from "./kernel/lock.js";
|
|
|
14
17
|
import { openLog } from "./kernel/log.js";
|
|
15
18
|
import { ensureDirs, layout } from "./kernel/paths.js";
|
|
16
19
|
import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
|
|
20
|
+
import { dependenciesOf } from "./kernel/runner/graph.js";
|
|
17
21
|
import { createRunner } from "./kernel/runner/index.js";
|
|
18
22
|
import { stageProviders } from "./kernel/runner/providers.js";
|
|
19
23
|
import { readVersion } from "./kernel/version.js";
|
|
20
|
-
import { claimStage, finishStage, stagesOf } from "./slices/admission/repo.js";
|
|
24
|
+
import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
|
|
25
|
+
import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
|
|
21
26
|
import { runArticle } from "./slices/article/run.js";
|
|
22
27
|
import { runImages } from "./slices/images/run.js";
|
|
23
28
|
import { runNarration } from "./slices/narration/run.js";
|
|
@@ -78,6 +83,19 @@ export async function boot(config) {
|
|
|
78
83
|
paths,
|
|
79
84
|
hub,
|
|
80
85
|
runner,
|
|
86
|
+
modelsFor: (provider, family) => {
|
|
87
|
+
if (family === "llm")
|
|
88
|
+
return registry.llm(provider).models();
|
|
89
|
+
if (family === "image")
|
|
90
|
+
return registry.image(provider).models();
|
|
91
|
+
const models = {
|
|
92
|
+
elevenlabs: elevenLabsModel,
|
|
93
|
+
"openai-tts": openAiTtsModel,
|
|
94
|
+
cartesia: cartesiaModel,
|
|
95
|
+
};
|
|
96
|
+
const id = models[provider];
|
|
97
|
+
return Promise.resolve(id === undefined ? [] : [{ id, name: id }]);
|
|
98
|
+
},
|
|
81
99
|
clock,
|
|
82
100
|
ids,
|
|
83
101
|
log,
|
|
@@ -142,6 +160,11 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
|
|
|
142
160
|
return createRunner({
|
|
143
161
|
stages: {
|
|
144
162
|
stagesOf: (projectId) => stagesOf(db, projectId),
|
|
163
|
+
paused: (projectId) => projectPaused(db, projectId),
|
|
164
|
+
dependenciesOf: (projectId, kind) => {
|
|
165
|
+
const project = projectById(db, projectId);
|
|
166
|
+
return project === undefined ? [] : dependenciesOf(kind, project.config.sources);
|
|
167
|
+
},
|
|
145
168
|
claim: (stageId) => claimStage(db, stageId, clock.now().toISOString()),
|
|
146
169
|
finish: (stageId, state, failureReason) => finishStage(db, stageId, state, failureReason, clock.now().toISOString()),
|
|
147
170
|
},
|
|
@@ -150,7 +173,11 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
|
|
|
150
173
|
runs: {
|
|
151
174
|
research: (context) => runResearch(writing, context, stageProviders(providers, context)),
|
|
152
175
|
article: (context) => runArticle(writing, context, stageProviders(providers, context)),
|
|
153
|
-
audio: (context) =>
|
|
176
|
+
audio: async (context) => {
|
|
177
|
+
const wrapped = stageProviders(providers, context);
|
|
178
|
+
await prepareProvidedArticleSegments(writing, context, wrapped);
|
|
179
|
+
await runNarration(video, context, wrapped);
|
|
180
|
+
},
|
|
154
181
|
images: (context) => runImages(writing, context, stageProviders(providers, context)),
|
|
155
182
|
thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
|
|
156
183
|
video: (context) => renderVideo(video, context),
|
|
@@ -208,7 +235,7 @@ export function urlOf(host, port) {
|
|
|
208
235
|
// nothing auto-resumes, the user retries by hand.
|
|
209
236
|
export function markInterruptedStages(db, clock) {
|
|
210
237
|
const result = db
|
|
211
|
-
.prepare("UPDATE stages SET state = 'failed', failure_reason = 'interrupted', finished_at = ? WHERE state = 'running'")
|
|
238
|
+
.prepare("UPDATE stages SET state = CASE WHEN EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1) THEN 'pending' ELSE 'failed' END, failure_reason = CASE WHEN EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1) THEN NULL ELSE 'interrupted' END, finished_at = CASE WHEN EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1) THEN NULL ELSE ? END WHERE state = 'running'")
|
|
212
239
|
.run(clock.now().toISOString());
|
|
213
240
|
return Number(result.changes);
|
|
214
241
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The format is the kernel's: the image port asks for the same two aspects.
|
|
2
2
|
export { formats } from "../../kernel/pipeline.js";
|
|
3
|
-
// Generate, Provide or Off for most stages, plus the thumbnail's two Generate modes.
|
|
4
|
-
//
|
|
3
|
+
// Generate, Provide or Off for most stages, plus the thumbnail's two Generate modes.
|
|
4
|
+
// Article stays Generate or Provide; Video can be Generate or Off.
|
|
5
5
|
export const stageSources = ["generate", "provide", "off", "from_prompt", "prompt_by_llm"];
|
|
6
6
|
export const entryModes = ["text", "llm"];
|
|
@@ -50,6 +50,7 @@ const projectRow = z.object({
|
|
|
50
50
|
config: z.string(),
|
|
51
51
|
created_at: z.string(),
|
|
52
52
|
updated_at: z.string(),
|
|
53
|
+
paused: z.number().default(0),
|
|
53
54
|
});
|
|
54
55
|
const standingRow = z.object({
|
|
55
56
|
project_id: z.string(),
|
|
@@ -73,12 +74,14 @@ const stageRow = z.object({
|
|
|
73
74
|
});
|
|
74
75
|
export function insertProject(db, project) {
|
|
75
76
|
db.prepare("INSERT INTO projects (id, title, format, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(project.id, project.title, project.format, JSON.stringify(project.config), project.createdAt, project.updatedAt);
|
|
77
|
+
if (project.paused === true)
|
|
78
|
+
setProjectPaused(db, project.id, true, project.updatedAt);
|
|
76
79
|
}
|
|
77
80
|
export function insertStage(db, stage) {
|
|
78
81
|
db.prepare("INSERT INTO stages (id, project_id, kind, source, state, attempt_count) VALUES (?, ?, ?, ?, ?, ?)").run(stage.id, stage.projectId, stage.kind, stage.source, stage.state, stage.attemptCount);
|
|
79
82
|
}
|
|
80
83
|
export function projectById(db, id) {
|
|
81
|
-
const row = db.prepare(
|
|
84
|
+
const row = db.prepare(`${projectSelect} WHERE projects.id = ?`).get(id);
|
|
82
85
|
return row === undefined ? undefined : toProject(projectRow.parse(row));
|
|
83
86
|
}
|
|
84
87
|
// Existence alone, for a caller that needs no run configuration: `slices/cancel` only
|
|
@@ -90,7 +93,7 @@ export function projectExists(db, id) {
|
|
|
90
93
|
// Newest first.
|
|
91
94
|
export function listProjects(db) {
|
|
92
95
|
return db
|
|
93
|
-
.prepare(
|
|
96
|
+
.prepare(`${projectSelect} ORDER BY created_at DESC, id DESC`)
|
|
94
97
|
.all()
|
|
95
98
|
.map((row) => toProject(projectRow.parse(row)));
|
|
96
99
|
}
|
|
@@ -130,12 +133,12 @@ export function stagesOf(db, projectId) {
|
|
|
130
133
|
// The runner's claim: one statement, so two callers cannot both find the stage pending.
|
|
131
134
|
export function claimStage(db, stageId, at) {
|
|
132
135
|
const result = db
|
|
133
|
-
.prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending'")
|
|
136
|
+
.prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending' AND NOT EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1)")
|
|
134
137
|
.run(at, stageId);
|
|
135
138
|
return Number(result.changes) === 1;
|
|
136
139
|
}
|
|
137
140
|
export function finishStage(db, stageId, state, failureReason, at) {
|
|
138
|
-
db.prepare("UPDATE stages SET state = ?, failure_reason = ?, finished_at = ? WHERE id = ?").run(state, failureReason, at, stageId);
|
|
141
|
+
db.prepare("UPDATE stages SET state = ?, failure_reason = ?, finished_at = ? WHERE id = ?").run(state, failureReason, state === "pending" ? null : at, stageId);
|
|
139
142
|
}
|
|
140
143
|
// A stage put back to `pending` by a re-run, a cascade or a retry starts again from a clean row
|
|
141
144
|
// - no error text, no progress from the run before it, and a fresh attempt budget.
|
|
@@ -153,8 +156,21 @@ function toProject(row) {
|
|
|
153
156
|
config: parseConfig(row.config),
|
|
154
157
|
createdAt: row.created_at,
|
|
155
158
|
updatedAt: row.updated_at,
|
|
159
|
+
paused: row.paused === 1,
|
|
156
160
|
};
|
|
157
161
|
}
|
|
162
|
+
const projectSelect = "SELECT projects.*, COALESCE(project_controls.paused, 0) AS paused FROM projects LEFT JOIN project_controls ON project_controls.project_id = projects.id";
|
|
163
|
+
export function projectPaused(db, projectId) {
|
|
164
|
+
return (db.prepare("SELECT paused FROM project_controls WHERE project_id = ?").get(projectId)
|
|
165
|
+
?.paused === 1);
|
|
166
|
+
}
|
|
167
|
+
export function setProjectPaused(db, projectId, paused, at) {
|
|
168
|
+
db.prepare("INSERT INTO project_controls (project_id, paused) VALUES (?, ?) ON CONFLICT(project_id) DO UPDATE SET paused = excluded.paused").run(projectId, paused ? 1 : 0);
|
|
169
|
+
db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(at, projectId);
|
|
170
|
+
}
|
|
171
|
+
export function updateProjectConfig(db, projectId, config, at) {
|
|
172
|
+
db.prepare("UPDATE projects SET config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), at, projectId);
|
|
173
|
+
}
|
|
158
174
|
// A JSON column never reaches a caller as a string.
|
|
159
175
|
function parseConfig(config) {
|
|
160
176
|
const parsed = JSON.parse(config);
|
|
@@ -6,7 +6,7 @@ export const imagesPerRunMax = 60;
|
|
|
6
6
|
// ceiling: the default gap is 3 s and nothing fixes an upper or lower bound, so the range
|
|
7
7
|
// is this module's. A wider gap is a settings change, not a schema change.
|
|
8
8
|
export const silenceGapSecondsMax = 30;
|
|
9
|
-
// The legal source for each stage
|
|
9
|
+
// The legal source for each stage. Exported because Play's
|
|
10
10
|
// source switches offer exactly these and nothing else, so the segmented control and the
|
|
11
11
|
// refusal below it are the same list and a stage whose legal set changes cannot leave a
|
|
12
12
|
// switch offering something the server refuses. The order is the order the segments are
|
|
@@ -14,14 +14,14 @@ export const silenceGapSecondsMax = 30;
|
|
|
14
14
|
export const allowedSources = {
|
|
15
15
|
research: ["off", "generate", "provide"],
|
|
16
16
|
article: ["generate", "provide"],
|
|
17
|
-
audio: ["generate", "provide"],
|
|
18
|
-
images: ["generate", "provide"],
|
|
17
|
+
audio: ["off", "generate", "provide"],
|
|
18
|
+
images: ["off", "generate", "provide"],
|
|
19
19
|
thumbnail: ["off", "from_prompt", "prompt_by_llm", "provide"],
|
|
20
|
-
video: ["generate"],
|
|
20
|
+
video: ["off", "generate"],
|
|
21
21
|
};
|
|
22
22
|
export function admit(input) {
|
|
23
23
|
const fields = [];
|
|
24
|
-
const draft =
|
|
24
|
+
const draft = normaliseDraft(input.draft);
|
|
25
25
|
const { sources } = draft;
|
|
26
26
|
if (draft.title === "") {
|
|
27
27
|
fields.push({ field: "title", message: "A title is required." });
|
|
@@ -60,9 +60,12 @@ export function admit(input) {
|
|
|
60
60
|
}
|
|
61
61
|
if (sources.images === "generate") {
|
|
62
62
|
checkImagePrompts(draft, fields);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
}
|
|
64
|
+
if ((sources.images === "generate" ||
|
|
65
|
+
sources.thumbnail === "from_prompt" ||
|
|
66
|
+
sources.thumbnail === "prompt_by_llm") &&
|
|
67
|
+
!chosen(draft.images)) {
|
|
68
|
+
fields.push({ field: "images", message: "Pick an image provider and model." });
|
|
66
69
|
}
|
|
67
70
|
if ((sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") &&
|
|
68
71
|
blank(draft.thumbnailPrompt)) {
|
|
@@ -81,12 +84,16 @@ export function admit(input) {
|
|
|
81
84
|
return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
|
|
82
85
|
}
|
|
83
86
|
// Research only feeds article writing, so a provided article hides it.
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
// Disabling images chooses audio export. Uploaded narration is used as-is, and
|
|
88
|
+
// only generated narration uses separately selected intro/outro entries.
|
|
89
|
+
export function normaliseDraft(draft) {
|
|
90
|
+
const sources = { ...draft.sources };
|
|
87
91
|
if (sources.article === "provide") {
|
|
88
92
|
sources.research = "off";
|
|
89
93
|
}
|
|
94
|
+
if (sources.images === "off" && sources.video === "generate") {
|
|
95
|
+
sources.video = "off";
|
|
96
|
+
}
|
|
90
97
|
// Prototype-free, because a slot name is user-authored: `{{constructor}}` would
|
|
91
98
|
// otherwise answer with Object rather than undefined and pass the "required" check, and
|
|
92
99
|
// a value posted under `__proto__` would run a setter instead of being stored.
|
|
@@ -94,7 +101,14 @@ function normalise(draft) {
|
|
|
94
101
|
for (const [name, value] of Object.entries(draft.values)) {
|
|
95
102
|
values[name] = value.trim();
|
|
96
103
|
}
|
|
97
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
...draft,
|
|
106
|
+
title: draft.title.trim(),
|
|
107
|
+
sources,
|
|
108
|
+
values,
|
|
109
|
+
intro: sources.audio === "generate" ? draft.intro : undefined,
|
|
110
|
+
outro: sources.audio === "generate" ? draft.outro : undefined,
|
|
111
|
+
};
|
|
98
112
|
}
|
|
99
113
|
function checkImagePrompts(draft, fields) {
|
|
100
114
|
if (draft.imagePrompts.length === 0) {
|
|
@@ -30,7 +30,9 @@ export function startRun(deps, draft, rendered) {
|
|
|
30
30
|
projectId: id,
|
|
31
31
|
kind,
|
|
32
32
|
source: draft.sources[kind],
|
|
33
|
-
state:
|
|
33
|
+
state: kind === "video" && draft.sources.video === "off" && draft.sources.audio !== "off"
|
|
34
|
+
? "pending"
|
|
35
|
+
: initialState(draft.sources[kind]),
|
|
34
36
|
failureReason: null,
|
|
35
37
|
attemptCount: 0,
|
|
36
38
|
progressCurrent: null,
|