@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.
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +8 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +3 -2
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +141 -0
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/events/hub.js +4 -0
- package/dist/edge/http/actions.js +68 -14
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +40 -11
- package/dist/edge/http/providers.js +13 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
- package/dist/kernel/pipeline.js +10 -5
- package/dist/kernel/ports/subtitles.js +1 -0
- 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/kernel/runner/providers.js +3 -2
- package/dist/main.js +32 -4
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +22 -4
- package/dist/slices/admission/rules.js +32 -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/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -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/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +12 -0
- package/dist/slices/storage/model.js +6 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +88 -0
- package/dist/slices/subtitles/model.js +18 -0
- package/dist/slices/subtitles/prepare.js +162 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +69 -0
- package/dist/slices/video/audio-inputs.js +32 -0
- package/dist/slices/video/ffmpeg.js +19 -12
- package/dist/slices/video/plan.js +13 -12
- package/dist/slices/video/run.js +28 -141
- package/dist/slices/video/write-export.js +163 -0
- package/dist/web/assets/index-CNYs0noB.js +130 -0
- package/dist/web/assets/index-Mk0bvBg-.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-BZvZKvIv.js +0 -81
- 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
|
+
}
|
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
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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(),
|
|
@@ -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
|
-
|
|
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) =>
|
|
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.
|
|
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"];
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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,14 +60,20 @@ 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)) {
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
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:
|
|
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
|
+
}
|