@gentbajko/slopify 0.4.3 → 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.
Files changed (37) hide show
  1. package/dist/edge/events/hub.js +4 -0
  2. package/dist/edge/http/actions.js +68 -14
  3. package/dist/edge/http/projects.js +17 -10
  4. package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
  5. package/dist/kernel/pipeline.js +10 -5
  6. package/dist/kernel/runner/graph.js +18 -18
  7. package/dist/kernel/runner/index.js +10 -6
  8. package/dist/kernel/runner/piece-repo.js +8 -1
  9. package/dist/main.js +30 -3
  10. package/dist/slices/admission/model.js +2 -2
  11. package/dist/slices/admission/repo.js +20 -4
  12. package/dist/slices/admission/rules.js +26 -12
  13. package/dist/slices/admission/start.js +3 -1
  14. package/dist/slices/article/provided-entries.js +62 -0
  15. package/dist/slices/article/run.js +63 -101
  16. package/dist/slices/article/segments.js +73 -0
  17. package/dist/slices/cancel/index.js +27 -4
  18. package/dist/slices/control/index.js +155 -0
  19. package/dist/slices/control/lock.js +19 -0
  20. package/dist/slices/control/providers.js +59 -0
  21. package/dist/slices/library/slots.js +3 -1
  22. package/dist/slices/reruns/cascade.js +11 -7
  23. package/dist/slices/reruns/index.js +23 -1
  24. package/dist/slices/storage/layout.js +2 -0
  25. package/dist/slices/storage/model.js +1 -0
  26. package/dist/slices/video/audio-export.js +66 -0
  27. package/dist/slices/video/audio-inputs.js +32 -0
  28. package/dist/slices/video/ffmpeg.js +5 -5
  29. package/dist/slices/video/plan.js +9 -8
  30. package/dist/slices/video/run.js +25 -141
  31. package/dist/slices/video/write-export.js +98 -0
  32. package/dist/web/assets/index-C1fsrIP5.css +1 -0
  33. package/dist/web/assets/index-DwJ_XTl3.js +81 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-D_sIXvT7.js +0 -81
  37. package/dist/web/assets/index-sG_6cp6k.css +0 -1
@@ -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: { ...projectById(deps.db, projectId), status: derive(stages) },
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.runner.tick(projectId);
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
- const result = await cancelProject(cancel, id);
98
- if (!result.ok) {
99
- return problem(c, { status: 404, title: titleOf(404), detail: details["no-project"] });
100
- }
101
- // No tick: a canceled project sits until the user retries a stage.
102
- return c.json({ ...view(id), canceled: result.canceled });
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 { ...project, status: derive(stages), progress: progressOf(stages) };
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 result = deleteProject(storageForDelete, c.req.valid("param").id);
108
- if (result.ok) {
109
- return c.body(null, 204);
110
- }
111
- return problem(c, {
112
- status: deleteStatus[result.reason],
113
- title: titleOf(deleteStatus[result.reason]),
114
- detail: result.detail ?? deleteDetails[result.reason],
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
  }
@@ -0,0 +1,4 @@
1
+ CREATE TABLE project_controls (
2
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
3
+ paused INTEGER NOT NULL DEFAULT 0 CHECK(paused IN (0, 1))
4
+ );
@@ -13,8 +13,13 @@ export const stageStates = [
13
13
  "provided",
14
14
  "skipped",
15
15
  ];
16
- // Four project states, derived in order: running if any stage is running, else canceled,
17
- // else failed, else done when video is done. A project whose stages are all pending matches
18
- // none of them - the real window between creating it and the runner starting the first
19
- // stage - so `pending` is the fallback. Nothing stores it.
20
- export const projectStates = ["running", "canceled", "failed", "done", "pending"];
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
- // Research → article → {audio ∥ images ∥ thumbnail} → video.
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: ["article"],
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
- // The four states, in their order. Derived on every read, never stored.
15
- export function derive(stages) {
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.some((stage) => stage.kind === "video" && stage.state === "done")) {
29
+ if (stages.length > 0 && stages.every((stage) => satisfied(stage.state))) {
26
30
  return "done";
27
31
  }
28
- // Extended rule, beyond the four above. `pending` covers the window between creating a
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
- conclude(stage, "canceled", "canceled by user");
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
- if (!graph[stage.kind].every((kind) => satisfied(stateOf(kind)))) {
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
- abortProject: async (projectId) => {
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 = ["chapter", "chunk", "segment", "image", "prompt_written"];
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) => runNarration(video, context, stageProviders(providers, 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. Video
4
- // is always `generate`.
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("SELECT * FROM projects WHERE id = ?").get(id);
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("SELECT * FROM projects ORDER BY created_at DESC, id DESC")
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; video is always generated. Exported because Play's
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 = normalise(input.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
- if (!chosen(draft.images)) {
64
- fields.push({ field: "images", message: "Pick an image provider and model." });
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
- // Video is generated whatever the form said.
85
- function normalise(draft) {
86
- const sources = { ...draft.sources, video: "generate" };
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 { ...draft, title: draft.title.trim(), sources, values };
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: initialState(draft.sources[kind]),
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,