@gentbajko/slopify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// package.json sits two directories above this module both in src/ and in dist/,
|
|
3
|
+
// because the build mirrors the source tree (src/kernel/version.ts -> dist/kernel/
|
|
4
|
+
// version.js). Reading it through import.meta.url survives `npx slopify` from any cwd.
|
|
5
|
+
export function readVersion() {
|
|
6
|
+
const parsed = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
7
|
+
if (typeof parsed !== "object" ||
|
|
8
|
+
parsed === null ||
|
|
9
|
+
!("version" in parsed) ||
|
|
10
|
+
typeof parsed.version !== "string") {
|
|
11
|
+
throw new Error("package.json has no version string");
|
|
12
|
+
}
|
|
13
|
+
return parsed.version;
|
|
14
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import ffmpegStatic from "ffmpeg-static";
|
|
4
|
+
import { buildRegistry } from "./adapter-registry.js";
|
|
5
|
+
import { nodeRunCli } from "./adapters/llm/run-cli.js";
|
|
6
|
+
import { createHub } from "./edge/events/hub.js";
|
|
7
|
+
import { createApp } from "./edge/http/app.js";
|
|
8
|
+
import { systemClock } from "./kernel/clock.js";
|
|
9
|
+
import { openDb } from "./kernel/db/index.js";
|
|
10
|
+
import { migrate } from "./kernel/db/migrate.js";
|
|
11
|
+
import { ulidIds } from "./kernel/ids.js";
|
|
12
|
+
import { acquireInstanceLock } from "./kernel/lock.js";
|
|
13
|
+
import { openLog } from "./kernel/log.js";
|
|
14
|
+
import { ensureDirs, layout } from "./kernel/paths.js";
|
|
15
|
+
import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
|
|
16
|
+
import { createRunner } from "./kernel/runner/index.js";
|
|
17
|
+
import { stageProviders } from "./kernel/runner/providers.js";
|
|
18
|
+
import { readVersion } from "./kernel/version.js";
|
|
19
|
+
import { claimStage, finishStage, stagesOf } from "./slices/admission/repo.js";
|
|
20
|
+
import { runArticle } from "./slices/article/run.js";
|
|
21
|
+
import { runImages } from "./slices/images/run.js";
|
|
22
|
+
import { runNarration } from "./slices/narration/run.js";
|
|
23
|
+
import { runResearch } from "./slices/research/run.js";
|
|
24
|
+
import { nodeCliProbe } from "./slices/settings/cli-status.js";
|
|
25
|
+
import { reconcileStorage } from "./slices/storage/reconcile.js";
|
|
26
|
+
import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
|
|
27
|
+
import { createFlusher } from "./slices/telemetry/flush.js";
|
|
28
|
+
import { record } from "./slices/telemetry/record.js";
|
|
29
|
+
import { runThumbnail } from "./slices/thumbnail/run.js";
|
|
30
|
+
import { resolveFfmpeg } from "./slices/video/ffmpeg.js";
|
|
31
|
+
import { renderVideo } from "./slices/video/run.js";
|
|
32
|
+
// ceiling: a burst of finished stages coalesces into one delivery a second later, and the
|
|
33
|
+
// collector gets ten seconds to answer before the attempt is abandoned and the events
|
|
34
|
+
// stay queued.
|
|
35
|
+
const flushDelayMs = 1000;
|
|
36
|
+
const collectorTimeoutMs = 10_000;
|
|
37
|
+
export async function boot(config) {
|
|
38
|
+
const clock = systemClock;
|
|
39
|
+
const ids = ulidIds;
|
|
40
|
+
const paths = layout(config.dataDir);
|
|
41
|
+
ensureDirs(paths, { mode: 0o700 });
|
|
42
|
+
const lock = acquireInstanceLock(paths.lock);
|
|
43
|
+
let db;
|
|
44
|
+
try {
|
|
45
|
+
db = openDb(paths.db);
|
|
46
|
+
migrate(db, clock);
|
|
47
|
+
const interrupted = markInterruptedStages(db, clock);
|
|
48
|
+
const reconciled = reconcileStorage(db, paths);
|
|
49
|
+
const log = openLog(paths.logs, clock);
|
|
50
|
+
log.write("info", "boot", {
|
|
51
|
+
detail: `interrupted stages ${interrupted}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
|
|
52
|
+
});
|
|
53
|
+
const hub = createHub({ ids, log });
|
|
54
|
+
const version = readVersion();
|
|
55
|
+
const telemetry = { db, ids, clock, log, appVersion: version };
|
|
56
|
+
const flusher = createFlusher({
|
|
57
|
+
db,
|
|
58
|
+
clock,
|
|
59
|
+
log,
|
|
60
|
+
post: httpPostEvents(collectorEndpoint(process.env), collectorTimeoutMs),
|
|
61
|
+
}, flushDelayMs);
|
|
62
|
+
const registry = buildRegistry({
|
|
63
|
+
db,
|
|
64
|
+
fetch: globalThis.fetch,
|
|
65
|
+
spawn: nodeRunCli,
|
|
66
|
+
clock,
|
|
67
|
+
probe: nodeCliProbe,
|
|
68
|
+
});
|
|
69
|
+
const runner = wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry });
|
|
70
|
+
const app = createApp({
|
|
71
|
+
db,
|
|
72
|
+
paths,
|
|
73
|
+
hub,
|
|
74
|
+
runner,
|
|
75
|
+
clock,
|
|
76
|
+
ids,
|
|
77
|
+
log,
|
|
78
|
+
version,
|
|
79
|
+
webDist: fileURLToPath(new URL("../dist/web", import.meta.url)),
|
|
80
|
+
flushSoon: flusher.soon,
|
|
81
|
+
probe: nodeCliProbe,
|
|
82
|
+
});
|
|
83
|
+
const server = await listen(app, config, log);
|
|
84
|
+
// logic/16 step 5: whatever last run left queued goes out at start. Nothing waits for
|
|
85
|
+
// it, and an unreachable collector costs one refused socket.
|
|
86
|
+
flusher.soon();
|
|
87
|
+
const open = db;
|
|
88
|
+
return {
|
|
89
|
+
paths,
|
|
90
|
+
url: urlOf(config.host, portOf(server) ?? config.port),
|
|
91
|
+
stop: async () => {
|
|
92
|
+
try {
|
|
93
|
+
// The listener goes first. Aborting the runner while the socket still accepted
|
|
94
|
+
// requests let a Play arriving during the await start a stage under a fresh
|
|
95
|
+
// controller nobody had aborted, which abortAll would then have waited out.
|
|
96
|
+
// Only once nothing new can arrive is the runner drained and the database shut.
|
|
97
|
+
await close(server);
|
|
98
|
+
await runner.abortAll();
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
// The pending timer is cancelled rather than awaited: a shutdown must not wait
|
|
102
|
+
// on the collector. A flush already in flight may land after the database
|
|
103
|
+
// closes and fail to mark its batch delivered, which costs one re-send that
|
|
104
|
+
// the collector deduplicates by event id (logic/16 §Q134).
|
|
105
|
+
flusher.stop();
|
|
106
|
+
log.write("info", "shutdown");
|
|
107
|
+
open.close();
|
|
108
|
+
lock.release();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
db?.close();
|
|
115
|
+
lock.release();
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function wire({ db, paths, clock, ids, log, hub, telemetry, flusher, registry }) {
|
|
120
|
+
// Resolved once at boot rather than per render, so a machine with no usable binary
|
|
121
|
+
// fails at start with one message instead of on every project's last stage.
|
|
122
|
+
const ffmpeg = resolveFfmpeg(process.env, ffmpegStatic);
|
|
123
|
+
// logic/16 steps 2 and 5: a stage counts what it did and the queue is flushed after
|
|
124
|
+
// each new event. `record` swallows its own failures, so this can neither fail a stage
|
|
125
|
+
// nor widen what leaves the machine - the payload allow-list is checked inside it.
|
|
126
|
+
const count = (type, counters) => {
|
|
127
|
+
record(telemetry, type, counters);
|
|
128
|
+
flusher.soon();
|
|
129
|
+
};
|
|
130
|
+
// The audio stage joins its chunks with the same binary the render uses, so it takes
|
|
131
|
+
// the same seven dependencies.
|
|
132
|
+
const video = { db, paths, ids, clock, log, ffmpeg, count };
|
|
133
|
+
// Research, the article, the images and the thumbnail all write into the same project
|
|
134
|
+
// folder from the same database handle, so they take the same six dependencies.
|
|
135
|
+
const writing = { db, paths, ids, clock, log, count };
|
|
136
|
+
// A stage slice is handed the wrapped calls, never the registry: every provider call
|
|
137
|
+
// it makes is already inside the retry policy (kernel/runner/providers.ts).
|
|
138
|
+
const providers = { registry, attempts: sqliteAttempts(db, ids), clock, log };
|
|
139
|
+
return createRunner({
|
|
140
|
+
stages: {
|
|
141
|
+
stagesOf: (projectId) => stagesOf(db, projectId),
|
|
142
|
+
claim: (stageId) => claimStage(db, stageId, clock.now().toISOString()),
|
|
143
|
+
finish: (stageId, state, failureReason) => finishStage(db, stageId, state, failureReason, clock.now().toISOString()),
|
|
144
|
+
},
|
|
145
|
+
// Only the stages that exist. A pending stage with no entry here fails loudly with
|
|
146
|
+
// that sentence rather than waiting for a runner that will never call it.
|
|
147
|
+
runs: {
|
|
148
|
+
research: (context) => runResearch(writing, context, stageProviders(providers, context)),
|
|
149
|
+
article: (context) => runArticle(writing, context, stageProviders(providers, context)),
|
|
150
|
+
audio: (context) => runNarration(video, context, stageProviders(providers, context)),
|
|
151
|
+
images: (context) => runImages(writing, context, stageProviders(providers, context)),
|
|
152
|
+
thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
|
|
153
|
+
video: (context) => renderVideo(video, context),
|
|
154
|
+
},
|
|
155
|
+
// logic/16 step 2 counts finer than a stage reaching `done` - each intro and outro
|
|
156
|
+
// text, each narrated segment - and its counters are the provider names, the token
|
|
157
|
+
// usage and the durations only the stage slice ever sees. Each slice therefore
|
|
158
|
+
// records its own units through `count` above, and the runner is left counting
|
|
159
|
+
// nothing: the kernel may not import a slice (03-conventions).
|
|
160
|
+
emit: (projectId, event) => {
|
|
161
|
+
hub.emit(projectId, event);
|
|
162
|
+
},
|
|
163
|
+
emitRunningCount: (running) => {
|
|
164
|
+
hub.emitGlobal({ type: "running.count", count: running });
|
|
165
|
+
},
|
|
166
|
+
log,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function listen(app, config, log) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const server = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, () => {
|
|
172
|
+
server.off("error", reject);
|
|
173
|
+
server.on("error", (error) => {
|
|
174
|
+
log.write("error", "http.server", { detail: error.message });
|
|
175
|
+
});
|
|
176
|
+
resolve(server);
|
|
177
|
+
});
|
|
178
|
+
server.once("error", reject);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function close(server) {
|
|
182
|
+
// An SSE response never ends by itself, and close() waits for every open connection,
|
|
183
|
+
// so a shutdown that only called close() would hang for as long as a page is open.
|
|
184
|
+
if ("closeAllConnections" in server) {
|
|
185
|
+
server.closeAllConnections();
|
|
186
|
+
}
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
server.close((error) => {
|
|
189
|
+
if (error === undefined || error === null) {
|
|
190
|
+
resolve();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
reject(error);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function portOf(server) {
|
|
198
|
+
const address = server.address();
|
|
199
|
+
return address === null || typeof address === "string" ? undefined : address.port;
|
|
200
|
+
}
|
|
201
|
+
export function urlOf(host, port) {
|
|
202
|
+
// An IPv6 literal has to be bracketed before it is a URL authority.
|
|
203
|
+
return `http://${host.includes(":") ? `[${host}]` : host}:${port}`;
|
|
204
|
+
}
|
|
205
|
+
// A stage can only be `running` at boot if the previous process died mid-run;
|
|
206
|
+
// nothing auto-resumes, the user retries by hand (logic/01 §Q7).
|
|
207
|
+
export function markInterruptedStages(db, clock) {
|
|
208
|
+
const result = db
|
|
209
|
+
.prepare("UPDATE stages SET state = 'failed', failure_reason = 'interrupted', finished_at = ? WHERE state = 'running'")
|
|
210
|
+
.run(clock.now().toISOString());
|
|
211
|
+
return Number(result.changes);
|
|
212
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The format is the kernel's: the image port asks for the same two aspects.
|
|
2
|
+
export { formats } from "../../kernel/pipeline.js";
|
|
3
|
+
// mockup §Q28 and logic/10 §Q78: Generate / Provide / Off for most stages, and the
|
|
4
|
+
// thumbnail's two Generate modes. Video is always `generate` (logic/01 step 5).
|
|
5
|
+
export const stageSources = ["generate", "provide", "off", "from_prompt", "prompt_by_llm"];
|
|
6
|
+
export const entryModes = ["text", "llm"];
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
|
+
import { chunkModes } from "../narration/chunk.js";
|
|
4
|
+
import { entryModes, formats, stageSources } from "./model.js";
|
|
5
|
+
const providerChoice = z.object({ provider: z.string(), model: z.string() });
|
|
6
|
+
const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
|
|
7
|
+
// The shape Play posts and the shape `projects.config` holds, in one place: the second
|
|
8
|
+
// is the first plus the rendered prompt texts (logic/03 §Q25).
|
|
9
|
+
export const runDraftSchema = z.object({
|
|
10
|
+
title: z.string(),
|
|
11
|
+
format: z.enum(formats),
|
|
12
|
+
// Spelled out rather than built from stageKinds, so the inferred type carries the six
|
|
13
|
+
// keys and this schema stays assignable to the domain type without a cast.
|
|
14
|
+
sources: z.object({
|
|
15
|
+
research: z.enum(stageSources),
|
|
16
|
+
article: z.enum(stageSources),
|
|
17
|
+
audio: z.enum(stageSources),
|
|
18
|
+
images: z.enum(stageSources),
|
|
19
|
+
thumbnail: z.enum(stageSources),
|
|
20
|
+
video: z.enum(stageSources),
|
|
21
|
+
}),
|
|
22
|
+
llm: providerChoice.optional(),
|
|
23
|
+
audio: providerChoice.extend({ voice: z.string() }).optional(),
|
|
24
|
+
images: providerChoice.optional(),
|
|
25
|
+
articlePrompt: z.string().optional(),
|
|
26
|
+
imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
|
|
27
|
+
thumbnailPrompt: z.string().optional(),
|
|
28
|
+
intro: entryChoice.optional(),
|
|
29
|
+
outro: entryChoice.optional(),
|
|
30
|
+
values: z.record(z.string(), z.string()),
|
|
31
|
+
provided: z.object({
|
|
32
|
+
research: z.string().optional(),
|
|
33
|
+
article: z.string().optional(),
|
|
34
|
+
audio: z.string().optional(),
|
|
35
|
+
images: z.array(z.string()).optional(),
|
|
36
|
+
thumbnail: z.string().optional(),
|
|
37
|
+
}),
|
|
38
|
+
// logic/08 §Q65. Optional until Play carries the control; unknown keys are stripped by
|
|
39
|
+
// this schema, so a mode that is not listed here would never reach the audio stage.
|
|
40
|
+
chunking: z.object({ mode: z.enum(chunkModes), words: z.number().optional() }).optional(),
|
|
41
|
+
silenceGapSeconds: z.number(),
|
|
42
|
+
});
|
|
43
|
+
export const runConfigSchema = runDraftSchema.extend({
|
|
44
|
+
rendered: z.record(z.string(), z.string()),
|
|
45
|
+
});
|
|
46
|
+
const projectRow = z.object({
|
|
47
|
+
id: z.string(),
|
|
48
|
+
title: z.string(),
|
|
49
|
+
format: z.enum(formats),
|
|
50
|
+
config: z.string(),
|
|
51
|
+
created_at: z.string(),
|
|
52
|
+
updated_at: z.string(),
|
|
53
|
+
});
|
|
54
|
+
const standingRow = z.object({
|
|
55
|
+
project_id: z.string(),
|
|
56
|
+
kind: z.enum(stageKinds),
|
|
57
|
+
state: z.enum(stageStates),
|
|
58
|
+
progress_current: z.number().nullable(),
|
|
59
|
+
progress_total: z.number().nullable(),
|
|
60
|
+
});
|
|
61
|
+
const stageRow = z.object({
|
|
62
|
+
id: z.string(),
|
|
63
|
+
project_id: z.string(),
|
|
64
|
+
kind: z.enum(stageKinds),
|
|
65
|
+
source: z.enum(stageSources),
|
|
66
|
+
state: z.enum(stageStates),
|
|
67
|
+
failure_reason: z.string().nullable(),
|
|
68
|
+
attempt_count: z.number(),
|
|
69
|
+
progress_current: z.number().nullable(),
|
|
70
|
+
progress_total: z.number().nullable(),
|
|
71
|
+
started_at: z.string().nullable(),
|
|
72
|
+
finished_at: z.string().nullable(),
|
|
73
|
+
});
|
|
74
|
+
export function insertProject(db, project) {
|
|
75
|
+
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);
|
|
76
|
+
}
|
|
77
|
+
export function insertStage(db, stage) {
|
|
78
|
+
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
|
+
}
|
|
80
|
+
export function projectById(db, id) {
|
|
81
|
+
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
|
|
82
|
+
return row === undefined ? undefined : toProject(projectRow.parse(row));
|
|
83
|
+
}
|
|
84
|
+
// Existence alone, for a caller that needs no run configuration: `slices/cancel` only
|
|
85
|
+
// asks whether the id names a project, and parsing the config to answer that would tie
|
|
86
|
+
// cancelling to a schema it never reads.
|
|
87
|
+
export function projectExists(db, id) {
|
|
88
|
+
return db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id) !== undefined;
|
|
89
|
+
}
|
|
90
|
+
// mockup/07: newest first.
|
|
91
|
+
export function listProjects(db) {
|
|
92
|
+
return db
|
|
93
|
+
.prepare("SELECT * FROM projects ORDER BY created_at DESC, id DESC")
|
|
94
|
+
.all()
|
|
95
|
+
.map((row) => toProject(projectRow.parse(row)));
|
|
96
|
+
}
|
|
97
|
+
// What 07 Projects needs of every project's stages and no more: the state each one is in
|
|
98
|
+
// and how far through itself a running one is. One statement for the whole list rather
|
|
99
|
+
// than `stagesOf` per row, and five columns rather than the whole stage table, because
|
|
100
|
+
// the screen shows a status word and a meter and reads nothing else from a stage.
|
|
101
|
+
export function stageStandingsByProject(db) {
|
|
102
|
+
const grouped = new Map();
|
|
103
|
+
const rows = db
|
|
104
|
+
.prepare("SELECT project_id, kind, state, progress_current, progress_total FROM stages ORDER BY rowid")
|
|
105
|
+
.all();
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const parsed = standingRow.parse(row);
|
|
108
|
+
const standing = {
|
|
109
|
+
kind: parsed.kind,
|
|
110
|
+
state: parsed.state,
|
|
111
|
+
progressCurrent: parsed.progress_current,
|
|
112
|
+
progressTotal: parsed.progress_total,
|
|
113
|
+
};
|
|
114
|
+
const seen = grouped.get(parsed.project_id);
|
|
115
|
+
if (seen === undefined) {
|
|
116
|
+
grouped.set(parsed.project_id, [standing]);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
seen.push(standing);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return grouped;
|
|
123
|
+
}
|
|
124
|
+
export function stagesOf(db, projectId) {
|
|
125
|
+
return db
|
|
126
|
+
.prepare("SELECT * FROM stages WHERE project_id = ? ORDER BY rowid")
|
|
127
|
+
.all(projectId)
|
|
128
|
+
.map((row) => toStage(stageRow.parse(row)));
|
|
129
|
+
}
|
|
130
|
+
// The runner's claim: one statement, so two callers cannot both find the stage pending.
|
|
131
|
+
export function claimStage(db, stageId, at) {
|
|
132
|
+
const result = db
|
|
133
|
+
.prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending'")
|
|
134
|
+
.run(at, stageId);
|
|
135
|
+
return Number(result.changes) === 1;
|
|
136
|
+
}
|
|
137
|
+
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);
|
|
139
|
+
}
|
|
140
|
+
// `logic/12` step 9 and `logic/13` step 5: a stage put back to `pending` by a re-run, a
|
|
141
|
+
// cascade or a retry starts again from a clean row - no error text, no progress from the
|
|
142
|
+
// run before it, and the fresh attempt budget of `logic/01` §Q5.
|
|
143
|
+
export function resetStage(db, stageId) {
|
|
144
|
+
db.prepare("UPDATE stages SET state = 'pending', failure_reason = NULL, attempt_count = 0, progress_current = NULL, progress_total = NULL, started_at = NULL, finished_at = NULL WHERE id = ?").run(stageId);
|
|
145
|
+
}
|
|
146
|
+
export function setStageProgress(db, stageId, current, total) {
|
|
147
|
+
db.prepare("UPDATE stages SET progress_current = ?, progress_total = ? WHERE id = ?").run(current, total, stageId);
|
|
148
|
+
}
|
|
149
|
+
function toProject(row) {
|
|
150
|
+
return {
|
|
151
|
+
id: row.id,
|
|
152
|
+
title: row.title,
|
|
153
|
+
format: row.format,
|
|
154
|
+
config: parseConfig(row.config),
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
updatedAt: row.updated_at,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// A JSON column never reaches a caller as a string.
|
|
160
|
+
function parseConfig(config) {
|
|
161
|
+
const parsed = JSON.parse(config);
|
|
162
|
+
return runConfigSchema.parse(parsed);
|
|
163
|
+
}
|
|
164
|
+
function toStage(row) {
|
|
165
|
+
return {
|
|
166
|
+
id: row.id,
|
|
167
|
+
projectId: row.project_id,
|
|
168
|
+
kind: row.kind,
|
|
169
|
+
source: row.source,
|
|
170
|
+
state: row.state,
|
|
171
|
+
failureReason: row.failure_reason,
|
|
172
|
+
attemptCount: row.attempt_count,
|
|
173
|
+
progressCurrent: row.progress_current,
|
|
174
|
+
progressTotal: row.progress_total,
|
|
175
|
+
startedAt: row.started_at,
|
|
176
|
+
finishedAt: row.finished_at,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
export const titleMax = 200;
|
|
3
|
+
export const valueMax = 200;
|
|
4
|
+
export const numberPerPromptMax = 20;
|
|
5
|
+
export const imagesPerRunMax = 60;
|
|
6
|
+
// ceiling: logic/11 §Q99 and logic/02 fix the default at 3 s but name no bounds, so the
|
|
7
|
+
// range is this module's. A wider gap is a settings change, not a schema change.
|
|
8
|
+
export const silenceGapSecondsMax = 30;
|
|
9
|
+
// mockup §Q28, logic/04 §Q31, logic/10 §Q78. Video is always generated (logic/01 step 5).
|
|
10
|
+
// Exported because Play's source switches offer exactly these and nothing else: the
|
|
11
|
+
// segmented control and the refusal below it are then the same list, and a stage whose
|
|
12
|
+
// legal set changes cannot leave a switch behind offering something the server refuses.
|
|
13
|
+
// The order is the order the segments are drawn in on the reference sheet; the rule
|
|
14
|
+
// itself only asks whether a source is on its stage's list.
|
|
15
|
+
export const allowedSources = {
|
|
16
|
+
research: ["off", "generate", "provide"],
|
|
17
|
+
article: ["generate", "provide"],
|
|
18
|
+
audio: ["generate", "provide"],
|
|
19
|
+
images: ["generate", "provide"],
|
|
20
|
+
thumbnail: ["off", "from_prompt", "prompt_by_llm", "provide"],
|
|
21
|
+
video: ["generate"],
|
|
22
|
+
};
|
|
23
|
+
export function admit(input) {
|
|
24
|
+
const fields = [];
|
|
25
|
+
const draft = normalise(input.draft);
|
|
26
|
+
const { sources } = draft;
|
|
27
|
+
if (draft.title === "") {
|
|
28
|
+
fields.push({ field: "title", message: "A title is required." });
|
|
29
|
+
}
|
|
30
|
+
else if (draft.title.length > titleMax) {
|
|
31
|
+
fields.push({ field: "title", message: `A title is at most ${titleMax} characters.` });
|
|
32
|
+
}
|
|
33
|
+
for (const kind of stageKinds) {
|
|
34
|
+
if (!allowedSources[kind].includes(sources[kind])) {
|
|
35
|
+
fields.push({
|
|
36
|
+
field: `sources.${kind}`,
|
|
37
|
+
message: `The ${kind} stage cannot be set to ${sources[kind]}.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// logic/04 §Q28 with logic/10 §Q81 and §Q97: the LLM row is required only when
|
|
42
|
+
// something in the run actually asks an LLM for text.
|
|
43
|
+
const needsLlm = sources.research === "generate" ||
|
|
44
|
+
sources.article === "generate" ||
|
|
45
|
+
sources.thumbnail === "prompt_by_llm" ||
|
|
46
|
+
draft.intro?.mode === "llm" ||
|
|
47
|
+
draft.outro?.mode === "llm";
|
|
48
|
+
if (needsLlm && !chosen(draft.llm)) {
|
|
49
|
+
fields.push({ field: "llm", message: "Pick an LLM provider and model." });
|
|
50
|
+
}
|
|
51
|
+
if (sources.article === "generate" && blank(draft.articlePrompt)) {
|
|
52
|
+
fields.push({ field: "articlePrompt", message: "Pick an article prompt." });
|
|
53
|
+
}
|
|
54
|
+
const voiced = draft.audio;
|
|
55
|
+
if (sources.audio === "generate") {
|
|
56
|
+
if (voiced === undefined || !chosen(voiced)) {
|
|
57
|
+
fields.push({ field: "audio", message: "Pick a narration provider and model." });
|
|
58
|
+
}
|
|
59
|
+
else if (voiced.voice.trim() === "") {
|
|
60
|
+
fields.push({ field: "audio.voice", message: "Pick a voice." });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (sources.images === "generate") {
|
|
64
|
+
checkImagePrompts(draft, fields);
|
|
65
|
+
if (!chosen(draft.images)) {
|
|
66
|
+
fields.push({ field: "images", message: "Pick an image provider and model." });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if ((sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") &&
|
|
70
|
+
blank(draft.thumbnailPrompt)) {
|
|
71
|
+
fields.push({ field: "thumbnailPrompt", message: "Pick a thumbnail prompt." });
|
|
72
|
+
}
|
|
73
|
+
checkProvided(draft, input.staged, fields);
|
|
74
|
+
checkValues(draft, input.requiredSlots, fields);
|
|
75
|
+
if (!Number.isFinite(draft.silenceGapSeconds) ||
|
|
76
|
+
draft.silenceGapSeconds < 0 ||
|
|
77
|
+
draft.silenceGapSeconds > silenceGapSecondsMax) {
|
|
78
|
+
fields.push({
|
|
79
|
+
field: "silenceGapSeconds",
|
|
80
|
+
message: `The silence gap is between 0 and ${silenceGapSecondsMax} seconds.`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
|
|
84
|
+
}
|
|
85
|
+
// logic/05 §Q41: research only feeds article writing, so a provided article hides it.
|
|
86
|
+
// logic/01 step 5: video is generated whatever the form said.
|
|
87
|
+
function normalise(draft) {
|
|
88
|
+
const sources = { ...draft.sources, video: "generate" };
|
|
89
|
+
if (sources.article === "provide") {
|
|
90
|
+
sources.research = "off";
|
|
91
|
+
}
|
|
92
|
+
// Prototype-free, because a slot name is user-authored: `{{constructor}}` would
|
|
93
|
+
// otherwise answer with Object rather than undefined and pass the "required" check, and
|
|
94
|
+
// a value posted under `__proto__` would run a setter instead of being stored.
|
|
95
|
+
const values = Object.create(null);
|
|
96
|
+
for (const [name, value] of Object.entries(draft.values)) {
|
|
97
|
+
values[name] = value.trim();
|
|
98
|
+
}
|
|
99
|
+
return { ...draft, title: draft.title.trim(), sources, values };
|
|
100
|
+
}
|
|
101
|
+
function checkImagePrompts(draft, fields) {
|
|
102
|
+
if (draft.imagePrompts.length === 0) {
|
|
103
|
+
// logic/04 §Q31: a run always has an image source.
|
|
104
|
+
fields.push({ field: "imagePrompts", message: "Tick at least one image prompt." });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let total = 0;
|
|
108
|
+
for (const [index, prompt] of draft.imagePrompts.entries()) {
|
|
109
|
+
total += prompt.number;
|
|
110
|
+
if (blank(prompt.name)) {
|
|
111
|
+
fields.push({ field: `imagePrompts.${index}.name`, message: "Pick an image prompt." });
|
|
112
|
+
}
|
|
113
|
+
if (!Number.isInteger(prompt.number) ||
|
|
114
|
+
prompt.number < 1 ||
|
|
115
|
+
prompt.number > numberPerPromptMax) {
|
|
116
|
+
fields.push({
|
|
117
|
+
field: `imagePrompts.${index}.number`,
|
|
118
|
+
message: `Number is between 1 and ${numberPerPromptMax}.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (total > imagesPerRunMax) {
|
|
123
|
+
fields.push({
|
|
124
|
+
field: "imagePrompts",
|
|
125
|
+
message: `A run makes at most ${imagesPerRunMax} images; this one asks for ${total}.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function checkProvided(draft, staged, fields) {
|
|
130
|
+
const { sources, provided } = draft;
|
|
131
|
+
if (sources.research === "provide" && blank(provided.research)) {
|
|
132
|
+
fields.push({ field: "provided.research", message: "Paste the research notes." });
|
|
133
|
+
}
|
|
134
|
+
if (sources.article === "provide" && blank(provided.article)) {
|
|
135
|
+
fields.push({ field: "provided.article", message: "Paste the article." });
|
|
136
|
+
}
|
|
137
|
+
if (sources.audio === "provide") {
|
|
138
|
+
checkFile(staged, provided.audio, "audio", "provided.audio", "Pick an audio file.", fields);
|
|
139
|
+
}
|
|
140
|
+
if (sources.thumbnail === "provide") {
|
|
141
|
+
checkFile(staged, provided.thumbnail, "thumbnail", "provided.thumbnail", "Pick a thumbnail image.", fields);
|
|
142
|
+
}
|
|
143
|
+
if (sources.images === "provide") {
|
|
144
|
+
const ids = provided.images ?? [];
|
|
145
|
+
if (ids.length === 0) {
|
|
146
|
+
fields.push({ field: "provided.images", message: "Pick at least one image." });
|
|
147
|
+
}
|
|
148
|
+
else if (ids.length > imagesPerRunMax) {
|
|
149
|
+
fields.push({
|
|
150
|
+
field: "provided.images",
|
|
151
|
+
message: `A run holds at most ${imagesPerRunMax} images.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (new Set(ids).size !== ids.length) {
|
|
155
|
+
fields.push({ field: "provided.images", message: "The same image was picked twice." });
|
|
156
|
+
}
|
|
157
|
+
for (const [index, id] of ids.entries()) {
|
|
158
|
+
checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// logic/05 §Q44: a run never starts with provided content that is missing or still copying.
|
|
163
|
+
function checkFile(staged, id, kind, field, missing, fields) {
|
|
164
|
+
if (id === undefined || id === "") {
|
|
165
|
+
fields.push({ field, message: missing });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const file = staged.find((candidate) => candidate.id === id);
|
|
169
|
+
if (file === undefined || file.stageKind !== kind) {
|
|
170
|
+
fields.push({ field, message: "That upload is no longer available; pick the file again." });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (file.state !== "staged") {
|
|
174
|
+
fields.push({ field, message: "This upload is still copying." });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// logic/03 step 4 and §Q26.
|
|
178
|
+
function checkValues(draft, requiredSlots, fields) {
|
|
179
|
+
for (const name of requiredSlots) {
|
|
180
|
+
const value = Object.hasOwn(draft.values, name) ? draft.values[name] : undefined;
|
|
181
|
+
if (value === undefined || value === "") {
|
|
182
|
+
fields.push({ field: `values.${name}`, message: "This field is required." });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (value.length > valueMax) {
|
|
186
|
+
fields.push({
|
|
187
|
+
field: `values.${name}`,
|
|
188
|
+
message: `A value is at most ${valueMax} characters.`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (/[\n\r]/.test(value)) {
|
|
192
|
+
fields.push({ field: `values.${name}`, message: "A value is a single line." });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function blank(value) {
|
|
197
|
+
return value === undefined || value.trim() === "";
|
|
198
|
+
}
|
|
199
|
+
function chosen(choice) {
|
|
200
|
+
return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
|
|
201
|
+
}
|