@gentbajko/slopify 0.5.0 → 0.6.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 (84) hide show
  1. package/SUBTITLES.md +21 -0
  2. package/dist/adapter-registry.js +8 -3
  3. package/dist/adapters/alignment/audio.js +45 -0
  4. package/dist/adapters/alignment/cache.js +91 -0
  5. package/dist/adapters/alignment/ctc.js +128 -0
  6. package/dist/adapters/alignment/index.js +34 -0
  7. package/dist/adapters/alignment/lock.js +58 -0
  8. package/dist/adapters/alignment/numbers.js +91 -0
  9. package/dist/adapters/alignment/protocol.js +23 -0
  10. package/dist/adapters/alignment/quality.js +17 -0
  11. package/dist/adapters/alignment/runner.js +76 -0
  12. package/dist/adapters/alignment/text.js +61 -0
  13. package/dist/adapters/alignment/vocabulary.js +69 -0
  14. package/dist/adapters/alignment/worker.js +163 -0
  15. package/dist/adapters/llm/claude-code.js +6 -3
  16. package/dist/adapters/llm/codex.js +3 -2
  17. package/dist/adapters/llm/gemini-workspace.js +70 -0
  18. package/dist/adapters/llm/gemini.js +141 -0
  19. package/dist/adapters/llm/run-cli.js +6 -9
  20. package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
  21. package/dist/assets/fonts/OFL.txt +93 -0
  22. package/dist/assets/fonts/SOURCE.txt +7 -0
  23. package/dist/edge/events/hub.js +4 -0
  24. package/dist/edge/http/actions.js +68 -14
  25. package/dist/edge/http/app.js +4 -0
  26. package/dist/edge/http/fonts.js +147 -0
  27. package/dist/edge/http/projects.js +40 -11
  28. package/dist/edge/http/providers.js +13 -0
  29. package/dist/edge/http/subtitles.js +92 -0
  30. package/dist/kernel/cli-command.js +45 -0
  31. package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
  32. package/dist/kernel/pipeline.js +10 -5
  33. package/dist/kernel/ports/subtitles.js +1 -0
  34. package/dist/kernel/runner/graph.js +18 -18
  35. package/dist/kernel/runner/index.js +10 -6
  36. package/dist/kernel/runner/piece-repo.js +8 -1
  37. package/dist/kernel/runner/providers.js +3 -2
  38. package/dist/main.js +32 -4
  39. package/dist/slices/admission/model.js +2 -2
  40. package/dist/slices/admission/repo.js +22 -4
  41. package/dist/slices/admission/rules.js +32 -12
  42. package/dist/slices/admission/start.js +3 -1
  43. package/dist/slices/article/provided-entries.js +62 -0
  44. package/dist/slices/article/run.js +63 -101
  45. package/dist/slices/article/segments.js +73 -0
  46. package/dist/slices/cancel/index.js +27 -4
  47. package/dist/slices/control/index.js +155 -0
  48. package/dist/slices/control/lock.js +19 -0
  49. package/dist/slices/control/providers.js +59 -0
  50. package/dist/slices/fonts/catalog.js +95 -0
  51. package/dist/slices/fonts/discovery.js +81 -0
  52. package/dist/slices/fonts/files.js +37 -0
  53. package/dist/slices/fonts/index.js +5 -0
  54. package/dist/slices/fonts/model.js +4 -0
  55. package/dist/slices/fonts/preview.js +14 -0
  56. package/dist/slices/fonts/sfnt.js +262 -0
  57. package/dist/slices/fonts/upload.js +40 -0
  58. package/dist/slices/library/slots.js +3 -1
  59. package/dist/slices/reruns/cascade.js +11 -7
  60. package/dist/slices/reruns/index.js +23 -1
  61. package/dist/slices/settings/cli-paths.js +98 -0
  62. package/dist/slices/settings/cli-status.js +21 -12
  63. package/dist/slices/settings/model.js +9 -0
  64. package/dist/slices/settings/readiness.js +13 -9
  65. package/dist/slices/storage/downloads.js +2 -0
  66. package/dist/slices/storage/layout.js +12 -0
  67. package/dist/slices/storage/model.js +6 -0
  68. package/dist/slices/storage/repo.js +1 -0
  69. package/dist/slices/subtitles/captions.js +88 -0
  70. package/dist/slices/subtitles/model.js +18 -0
  71. package/dist/slices/subtitles/prepare.js +162 -0
  72. package/dist/slices/subtitles/transcript.js +34 -0
  73. package/dist/slices/video/audio-export.js +69 -0
  74. package/dist/slices/video/audio-inputs.js +32 -0
  75. package/dist/slices/video/ffmpeg.js +19 -12
  76. package/dist/slices/video/plan.js +13 -12
  77. package/dist/slices/video/run.js +28 -141
  78. package/dist/slices/video/write-export.js +163 -0
  79. package/dist/web/assets/index-CNYs0noB.js +130 -0
  80. package/dist/web/assets/index-Mk0bvBg-.css +1 -0
  81. package/dist/web/index.html +2 -2
  82. package/package.json +4 -2
  83. package/dist/web/assets/index-BZvZKvIv.js +0 -81
  84. package/dist/web/assets/index-cpjfvs5E.css +0 -1
@@ -0,0 +1,45 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, resolve, win32 } from "node:path";
3
+ // Keep prompts out of cmd.exe: npm launchers name a JavaScript entry point that
4
+ // Node can execute directly with an argv array, including quotes and newlines.
5
+ export function cliCommand(binary) {
6
+ const file = process.platform === "win32" ? windowsFile(binary) : binary;
7
+ if (/\.[cm]?js$/i.test(file))
8
+ return { file: process.execPath, args: [resolve(file)] };
9
+ if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(file))
10
+ return { file, args: [] };
11
+ const stat = statSync(file);
12
+ if (!stat.isFile() || stat.size > 64 * 1024)
13
+ throw unsupportedShim();
14
+ const target = windowsShimTarget(file, readFileSync(file, "utf8"));
15
+ if (target === undefined || !statSync(target, { throwIfNoEntry: false })?.isFile())
16
+ throw unsupportedShim();
17
+ return { file: process.execPath, args: [target] };
18
+ }
19
+ export function windowsShimTarget(file, source) {
20
+ if (!/(?:node|bun)(?:\.exe)?/i.test(source))
21
+ return undefined;
22
+ // npm's cmd-shim uses %dp0%, older launchers use %~dp0. The remainder must
23
+ // be a literal relative script path, followed by the original argument list.
24
+ const matched = /"(?:%dp0%|%~dp0)[\\/]?([^"%\r\n]+\.[cm]?js)"\s+%\*/i.exec(source);
25
+ if (matched?.[1] === undefined || win32.isAbsolute(matched[1]))
26
+ return undefined;
27
+ return win32.resolve(win32.dirname(file), matched[1]);
28
+ }
29
+ function windowsFile(binary) {
30
+ const path = Object.entries(process.env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
31
+ const directories = isAbsolute(binary) || /[\\/]/.test(binary) ? [""] : path.split(";");
32
+ const suffixes = extname(binary) === "" ? [".exe", ".com", ".cmd", ".bat", ""] : [""];
33
+ for (const directory of directories) {
34
+ for (const suffix of suffixes) {
35
+ const file = directory === "" ? binary + suffix : join(directory.replace(/^"|"$/g, ""), binary + suffix);
36
+ if (statSync(file, { throwIfNoEntry: false })?.isFile())
37
+ return resolve(file);
38
+ }
39
+ }
40
+ // Let spawn report its usual ENOENT when no executable exists.
41
+ return binary;
42
+ }
43
+ function unsupportedShim() {
44
+ return new Error("This batch launcher is not a supported Node CLI shim. Select the CLI's .exe or JavaScript entry file instead.");
45
+ }
@@ -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
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -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(),
@@ -30,11 +30,12 @@ export function stageProviders(deps, context, pieceId) {
30
30
  if (event.type === "delta") {
31
31
  text += event.text;
32
32
  }
33
- else {
33
+ else if (event.type === "done") {
34
34
  usage = event.usage;
35
35
  finishReason = event.finishReason;
36
36
  }
37
- onEvent?.(event);
37
+ if (event.type !== "activity")
38
+ onEvent?.(event);
38
39
  }
39
40
  const answer = { text, usage, finishReason };
40
41
  const unusable = call.check?.(answer);
package/dist/main.js CHANGED
@@ -2,8 +2,12 @@ import { fileURLToPath } from "node:url";
2
2
  import { serve } from "@hono/node-server";
3
3
  import ffmpegStatic from "ffmpeg-static";
4
4
  import { buildRegistry } from "./adapter-registry.js";
5
+ import { alignSubtitles } from "./adapters/alignment/index.js";
5
6
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
6
7
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
8
+ import { cartesiaModel } from "./adapters/tts/cartesia.js";
9
+ import { elevenLabsModel } from "./adapters/tts/elevenlabs.js";
10
+ import { openAiTtsModel } from "./adapters/tts/openai.js";
7
11
  import { createHub } from "./edge/events/hub.js";
8
12
  import { createApp } from "./edge/http/app.js";
9
13
  import { systemClock } from "./kernel/clock.js";
@@ -14,10 +18,12 @@ import { acquireInstanceLock } from "./kernel/lock.js";
14
18
  import { openLog } from "./kernel/log.js";
15
19
  import { ensureDirs, layout } from "./kernel/paths.js";
16
20
  import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
21
+ import { dependenciesOf } from "./kernel/runner/graph.js";
17
22
  import { createRunner } from "./kernel/runner/index.js";
18
23
  import { stageProviders } from "./kernel/runner/providers.js";
19
24
  import { readVersion } from "./kernel/version.js";
20
- import { claimStage, finishStage, stagesOf } from "./slices/admission/repo.js";
25
+ import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
26
+ import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
21
27
  import { runArticle } from "./slices/article/run.js";
22
28
  import { runImages } from "./slices/images/run.js";
23
29
  import { runNarration } from "./slices/narration/run.js";
@@ -78,6 +84,19 @@ export async function boot(config) {
78
84
  paths,
79
85
  hub,
80
86
  runner,
87
+ modelsFor: (provider, family) => {
88
+ if (family === "llm")
89
+ return registry.llm(provider).models();
90
+ if (family === "image")
91
+ return registry.image(provider).models();
92
+ const models = {
93
+ elevenlabs: elevenLabsModel,
94
+ "openai-tts": openAiTtsModel,
95
+ cartesia: cartesiaModel,
96
+ };
97
+ const id = models[provider];
98
+ return Promise.resolve(id === undefined ? [] : [{ id, name: id }]);
99
+ },
81
100
  clock,
82
101
  ids,
83
102
  log,
@@ -132,7 +151,7 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
132
151
  };
133
152
  // The audio stage joins its chunks with the same binary the render uses, so it takes
134
153
  // the same seven dependencies.
135
- const video = { db, paths, ids, clock, log, ffmpeg, count };
154
+ const video = { db, paths, ids, clock, log, ffmpeg, count, alignSubtitles };
136
155
  // Research, the article, the images and the thumbnail all write into the same project
137
156
  // folder from the same database handle, so they take the same six dependencies.
138
157
  const writing = { db, paths, ids, clock, log, count };
@@ -142,6 +161,11 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
142
161
  return createRunner({
143
162
  stages: {
144
163
  stagesOf: (projectId) => stagesOf(db, projectId),
164
+ paused: (projectId) => projectPaused(db, projectId),
165
+ dependenciesOf: (projectId, kind) => {
166
+ const project = projectById(db, projectId);
167
+ return project === undefined ? [] : dependenciesOf(kind, project.config.sources);
168
+ },
145
169
  claim: (stageId) => claimStage(db, stageId, clock.now().toISOString()),
146
170
  finish: (stageId, state, failureReason) => finishStage(db, stageId, state, failureReason, clock.now().toISOString()),
147
171
  },
@@ -150,7 +174,11 @@ function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry, f
150
174
  runs: {
151
175
  research: (context) => runResearch(writing, context, stageProviders(providers, context)),
152
176
  article: (context) => runArticle(writing, context, stageProviders(providers, context)),
153
- audio: (context) => runNarration(video, context, stageProviders(providers, context)),
177
+ audio: async (context) => {
178
+ const wrapped = stageProviders(providers, context);
179
+ await prepareProvidedArticleSegments(writing, context, wrapped);
180
+ await runNarration(video, context, wrapped);
181
+ },
154
182
  images: (context) => runImages(writing, context, stageProviders(providers, context)),
155
183
  thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
156
184
  video: (context) => renderVideo(video, context),
@@ -208,7 +236,7 @@ export function urlOf(host, port) {
208
236
  // nothing auto-resumes, the user retries by hand.
209
237
  export function markInterruptedStages(db, clock) {
210
238
  const result = db
211
- .prepare("UPDATE stages SET state = 'failed', failure_reason = 'interrupted', finished_at = ? WHERE state = 'running'")
239
+ .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
240
  .run(clock.now().toISOString());
213
241
  return Number(result.changes);
214
242
  }
@@ -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"];
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds, stageStates } from "../../kernel/pipeline.js";
3
3
  import { chunkModes } from "../narration/chunk.js";
4
+ import { subtitleConfigSchema } from "../subtitles/model.js";
4
5
  import { entryModes, formats, stageSources } from "./model.js";
5
6
  const providerChoice = z.object({ provider: z.string(), model: z.string() });
6
7
  const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
@@ -39,6 +40,7 @@ export const runDraftSchema = z.object({
39
40
  // a mode not listed here would never reach the audio stage.
40
41
  chunking: z.object({ mode: z.enum(chunkModes), words: z.number().optional() }).optional(),
41
42
  silenceGapSeconds: z.number(),
43
+ subtitles: subtitleConfigSchema.optional(),
42
44
  });
43
45
  export const runConfigSchema = runDraftSchema.extend({
44
46
  rendered: z.record(z.string(), z.string()),
@@ -50,6 +52,7 @@ const projectRow = z.object({
50
52
  config: z.string(),
51
53
  created_at: z.string(),
52
54
  updated_at: z.string(),
55
+ paused: z.number().default(0),
53
56
  });
54
57
  const standingRow = z.object({
55
58
  project_id: z.string(),
@@ -73,12 +76,14 @@ const stageRow = z.object({
73
76
  });
74
77
  export function insertProject(db, project) {
75
78
  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);
79
+ if (project.paused === true)
80
+ setProjectPaused(db, project.id, true, project.updatedAt);
76
81
  }
77
82
  export function insertStage(db, stage) {
78
83
  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
84
  }
80
85
  export function projectById(db, id) {
81
- const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
86
+ const row = db.prepare(`${projectSelect} WHERE projects.id = ?`).get(id);
82
87
  return row === undefined ? undefined : toProject(projectRow.parse(row));
83
88
  }
84
89
  // Existence alone, for a caller that needs no run configuration: `slices/cancel` only
@@ -90,7 +95,7 @@ export function projectExists(db, id) {
90
95
  // Newest first.
91
96
  export function listProjects(db) {
92
97
  return db
93
- .prepare("SELECT * FROM projects ORDER BY created_at DESC, id DESC")
98
+ .prepare(`${projectSelect} ORDER BY created_at DESC, id DESC`)
94
99
  .all()
95
100
  .map((row) => toProject(projectRow.parse(row)));
96
101
  }
@@ -130,12 +135,12 @@ export function stagesOf(db, projectId) {
130
135
  // The runner's claim: one statement, so two callers cannot both find the stage pending.
131
136
  export function claimStage(db, stageId, at) {
132
137
  const result = db
133
- .prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending'")
138
+ .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
139
  .run(at, stageId);
135
140
  return Number(result.changes) === 1;
136
141
  }
137
142
  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);
143
+ db.prepare("UPDATE stages SET state = ?, failure_reason = ?, finished_at = ? WHERE id = ?").run(state, failureReason, state === "pending" ? null : at, stageId);
139
144
  }
140
145
  // A stage put back to `pending` by a re-run, a cascade or a retry starts again from a clean row
141
146
  // - no error text, no progress from the run before it, and a fresh attempt budget.
@@ -153,8 +158,21 @@ function toProject(row) {
153
158
  config: parseConfig(row.config),
154
159
  createdAt: row.created_at,
155
160
  updatedAt: row.updated_at,
161
+ paused: row.paused === 1,
156
162
  };
157
163
  }
164
+ const projectSelect = "SELECT projects.*, COALESCE(project_controls.paused, 0) AS paused FROM projects LEFT JOIN project_controls ON project_controls.project_id = projects.id";
165
+ export function projectPaused(db, projectId) {
166
+ return (db.prepare("SELECT paused FROM project_controls WHERE project_id = ?").get(projectId)
167
+ ?.paused === 1);
168
+ }
169
+ export function setProjectPaused(db, projectId, paused, at) {
170
+ 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);
171
+ db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(at, projectId);
172
+ }
173
+ export function updateProjectConfig(db, projectId, config, at) {
174
+ db.prepare("UPDATE projects SET config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), at, projectId);
175
+ }
158
176
  // A JSON column never reaches a caller as a string.
159
177
  function parseConfig(config) {
160
178
  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,14 +60,20 @@ 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)) {
69
72
  fields.push({ field: "thumbnailPrompt", message: "Pick a thumbnail prompt." });
70
73
  }
74
+ if (sources.audio === "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off") {
75
+ fields.push({ field: "subtitles.mode", message: "Subtitles need narration audio." });
76
+ }
71
77
  checkProvided(draft, input.staged, fields);
72
78
  checkValues(draft, input.requiredSlots, fields);
73
79
  if (!Number.isFinite(draft.silenceGapSeconds) ||
@@ -81,12 +87,16 @@ export function admit(input) {
81
87
  return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
82
88
  }
83
89
  // 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" };
90
+ // Disabling images chooses audio export. Uploaded narration is used as-is, and
91
+ // only generated narration uses separately selected intro/outro entries.
92
+ export function normaliseDraft(draft) {
93
+ const sources = { ...draft.sources };
87
94
  if (sources.article === "provide") {
88
95
  sources.research = "off";
89
96
  }
97
+ if (sources.images === "off" && sources.video === "generate") {
98
+ sources.video = "off";
99
+ }
90
100
  // Prototype-free, because a slot name is user-authored: `{{constructor}}` would
91
101
  // otherwise answer with Object rather than undefined and pass the "required" check, and
92
102
  // a value posted under `__proto__` would run a setter instead of being stored.
@@ -94,7 +104,17 @@ function normalise(draft) {
94
104
  for (const [name, value] of Object.entries(draft.values)) {
95
105
  values[name] = value.trim();
96
106
  }
97
- return { ...draft, title: draft.title.trim(), sources, values };
107
+ return {
108
+ ...draft,
109
+ title: draft.title.trim(),
110
+ subtitles: sources.video === "off" && draft.subtitles?.mode === "burn-in"
111
+ ? { ...draft.subtitles, mode: "files" }
112
+ : draft.subtitles,
113
+ sources,
114
+ values,
115
+ intro: sources.audio === "generate" ? draft.intro : undefined,
116
+ outro: sources.audio === "generate" ? draft.outro : undefined,
117
+ };
98
118
  }
99
119
  function checkImagePrompts(draft, fields) {
100
120
  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,
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { piecesOf } from "../../kernel/runner/piece-repo.js";
3
+ import { projectById, stagesOf } from "../admission/repo.js";
4
+ import { outputPath } from "../storage/layout.js";
5
+ import { outputsOf } from "../storage/repo.js";
6
+ import { storeText } from "../storage/staging.js";
7
+ import { categories, instructionsText, keepSegment, writeSegment } from "./segments.js";
8
+ // A provided Article never runs its writer. Prepare its selected entries as part
9
+ // of Audio's cancellable work, retaining Article ownership of the resulting text.
10
+ export async function prepareProvidedArticleSegments(deps, audioContext, providers) {
11
+ const { projectId } = audioContext.stage;
12
+ const project = projectById(deps.db, projectId);
13
+ if (!project)
14
+ throw new Error(`project ${projectId} has no row`);
15
+ const { config } = project;
16
+ if (config.sources.article !== "provide" || config.sources.audio !== "generate")
17
+ return;
18
+ if (!config.intro && !config.outro)
19
+ return;
20
+ audioContext.signal.throwIfAborted();
21
+ const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
22
+ if (!article)
23
+ throw new Error(`project ${projectId} has no article stage`);
24
+ const outputs = outputsOf(deps.db, projectId);
25
+ const plain = outputs.find((output) => output.role === "article_txt");
26
+ if (!plain)
27
+ throw new Error("the run has no provided article text");
28
+ const narration = readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8");
29
+ const saved = outputs.find((output) => output.stageKind === "article" && output.role === "instructions");
30
+ let instructions = saved
31
+ ? readFileSync(outputPath(deps.paths, projectId, saved.path), "utf8")
32
+ : "";
33
+ const completed = piecesOf(deps.db, article.id, "segment");
34
+ for (const [index, category] of categories.entries()) {
35
+ audioContext.signal.throwIfAborted();
36
+ if (completed.some((piece) => piece.idx === index + 1 && piece.state === "done"))
37
+ continue;
38
+ const sent = [];
39
+ const segment = await writeSegment(providers, config.llm, config, category, narration, sent);
40
+ audioContext.signal.throwIfAborted();
41
+ if (!segment)
42
+ continue;
43
+ if (sent.length > 0) {
44
+ instructions += `${instructions === "" ? "" : "\n"}${instructionsText(sent)}`;
45
+ storeText(deps, {
46
+ projectId,
47
+ stageKind: "article",
48
+ role: "instructions",
49
+ text: instructions,
50
+ });
51
+ }
52
+ keepSegment(deps, { stage: article }, segment, index + 1);
53
+ deps.count("stage.completed", {
54
+ stage: "article",
55
+ segment: category,
56
+ ...(segment.mode === "llm" && config.llm
57
+ ? { provider: config.llm.provider, model: config.llm.model }
58
+ : {}),
59
+ ...segment.tokens,
60
+ });
61
+ }
62
+ }