@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,79 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { probeDurationMs, runFfmpeg } from "../video/ffmpeg.js";
|
|
4
|
+
// The concat demuxer's script format. A path is single-quoted, and the one character that
|
|
5
|
+
// can end the quoting is spelled out, so a folder with an apostrophe in it cannot become
|
|
6
|
+
// two arguments. A quoted line can also never be read as the `#` comment the format has.
|
|
7
|
+
export function concatListText(files) {
|
|
8
|
+
return `${files.map((file) => `file '${file.replaceAll("'", "'\\''")}'`).join("\n")}\n`;
|
|
9
|
+
}
|
|
10
|
+
export function concatArgs(listPath, output) {
|
|
11
|
+
return [
|
|
12
|
+
"-hide_banner",
|
|
13
|
+
"-nostdin",
|
|
14
|
+
"-loglevel",
|
|
15
|
+
"error",
|
|
16
|
+
"-nostats",
|
|
17
|
+
"-y",
|
|
18
|
+
"-f",
|
|
19
|
+
"concat",
|
|
20
|
+
// The script names absolute paths this app built, never anything a user typed.
|
|
21
|
+
"-safe",
|
|
22
|
+
"0",
|
|
23
|
+
"-i",
|
|
24
|
+
listPath,
|
|
25
|
+
// Only the audio: a provider that ships cover art in its mp3 would otherwise put a
|
|
26
|
+
// video stream in the narration.
|
|
27
|
+
"-map",
|
|
28
|
+
"0:a:0",
|
|
29
|
+
// Re-encoded rather than stream-copied. A copy joins frame runs that each carry their
|
|
30
|
+
// own encoder delay and padding, which is the click and the drift at every join:
|
|
31
|
+
// three chunks of 1200, 700 and 450 ms copy to 2409 ms and decode to 2350 ms through
|
|
32
|
+
// here, exactly the sum. §Q68 hands that number to the video timeline, so it has to
|
|
33
|
+
// be the real one. No `-ar`: §Q65 keeps the provider's own sample rate.
|
|
34
|
+
// ceiling: one re-encode at the bitrate the adapters ask the providers for. Storing
|
|
35
|
+
// the chunks as wav and encoding once at the end would avoid even that generation,
|
|
36
|
+
// and costs about ten times the disk while a stage is running.
|
|
37
|
+
"-c:a",
|
|
38
|
+
"libmp3lame",
|
|
39
|
+
"-b:a",
|
|
40
|
+
"128k",
|
|
41
|
+
output,
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
// The body audio and its measured duration. §Q68: the duration is measured, never
|
|
45
|
+
// estimated, because `logic/11` builds the video timeline out of it.
|
|
46
|
+
export async function joinNarration(deps, input) {
|
|
47
|
+
const first = input.files[0];
|
|
48
|
+
if (first === undefined) {
|
|
49
|
+
// The stage refuses an empty narration before it gets here (§Q67), so this is a bug.
|
|
50
|
+
throw new Error("there are no audio chunks to join");
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(dirname(input.output), { recursive: true, mode: 0o700 });
|
|
53
|
+
if (input.files.length === 1) {
|
|
54
|
+
// Whole-text mode, and any run whose article is one paragraph: there is nothing to
|
|
55
|
+
// join, and re-encoding the only chunk would lose quality for no reason.
|
|
56
|
+
input.signal.throwIfAborted();
|
|
57
|
+
copyFileSync(first, input.output);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
mkdirSync(dirname(input.listPath), { recursive: true, mode: 0o700 });
|
|
61
|
+
writeFileSync(input.listPath, concatListText(input.files), { mode: 0o600 });
|
|
62
|
+
try {
|
|
63
|
+
await runFfmpeg({
|
|
64
|
+
bin: deps.bin,
|
|
65
|
+
args: concatArgs(input.listPath, input.output),
|
|
66
|
+
signal: input.signal,
|
|
67
|
+
log: deps.log,
|
|
68
|
+
onProgress: () => { },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(input.listPath, { force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Measured off a full decode of the file that was written, not off the parts: a
|
|
76
|
+
// container header can carry an estimate, and `logic/11` step 1 adds this number to the
|
|
77
|
+
// gaps to get the length of the video.
|
|
78
|
+
return probeDurationMs(deps.bin, input.output, input.signal, deps.log);
|
|
79
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
5
|
+
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
6
|
+
import { entryModes } from "../admission/model.js";
|
|
7
|
+
import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
|
|
8
|
+
import { entryCategories } from "../library/model.js";
|
|
9
|
+
import { outputFileName, outputPath } from "../storage/layout.js";
|
|
10
|
+
import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
11
|
+
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
12
|
+
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
13
|
+
import { joinNarration } from "./concat.js";
|
|
14
|
+
// §Q67, verbatim: an empty narration source is an "immediate stage failure 'nothing to
|
|
15
|
+
// narrate', no retries". Thrown from the slice rather than from a provider call, so the
|
|
16
|
+
// attempt wrapper never sees it and nothing is retried.
|
|
17
|
+
export const nothingToNarrate = "nothing to narrate";
|
|
18
|
+
// What a chunk piece carries between runs: the text that was sent, and the file it came
|
|
19
|
+
// back as once the provider answered. That is the whole of the resume of §Q66.
|
|
20
|
+
const chunkPayload = z.object({ text: z.string(), file: z.string().optional() });
|
|
21
|
+
// What the article stage left on its `segment` pieces (`logic/07` step 5).
|
|
22
|
+
const segmentPayload = z.object({
|
|
23
|
+
category: z.enum(entryCategories),
|
|
24
|
+
name: z.string(),
|
|
25
|
+
mode: z.enum(entryModes),
|
|
26
|
+
text: z.string(),
|
|
27
|
+
});
|
|
28
|
+
// The chunk audio sits in a folder of its own under the project. It is not an output:
|
|
29
|
+
// §Q65's invariant makes the concatenated body plus the picked intro and outro "the
|
|
30
|
+
// project's only audio outputs", so the pieces name these files and the boot reconcile
|
|
31
|
+
// keeps them by that name (`slices/storage/reconcile.ts`).
|
|
32
|
+
const chunkDir = "audio-chunks";
|
|
33
|
+
export async function runNarration(deps, context, providers) {
|
|
34
|
+
const { projectId } = context.stage;
|
|
35
|
+
const project = projectById(deps.db, projectId);
|
|
36
|
+
if (project === undefined) {
|
|
37
|
+
throw new Error(`project ${projectId} has no row`);
|
|
38
|
+
}
|
|
39
|
+
const choice = project.config.audio;
|
|
40
|
+
if (choice === undefined) {
|
|
41
|
+
// Admission refuses a run whose audio is Generate without a provider and a voice
|
|
42
|
+
// (`logic/04`), so reaching here is a bug in admission rather than something the user
|
|
43
|
+
// did.
|
|
44
|
+
throw new Error("the run has no TTS provider or voice");
|
|
45
|
+
}
|
|
46
|
+
// Read once, before anything is written: the segment steps below ask it what a previous
|
|
47
|
+
// run already stored, and the body step is the only thing that adds to it.
|
|
48
|
+
const outputs = outputsOf(deps.db, projectId);
|
|
49
|
+
// Step 2. The end matter is already out of this text: the article stage cut it into
|
|
50
|
+
// files of its own (§Q63, `logic/07` step 4), so nothing here can narrate a sources list.
|
|
51
|
+
const chunking = project.config.chunking ?? defaultChunking;
|
|
52
|
+
const texts = chunkNarration(narrationSource(deps, outputs, projectId), chunking);
|
|
53
|
+
if (texts.length === 0) {
|
|
54
|
+
throw new Error(nothingToNarrate);
|
|
55
|
+
}
|
|
56
|
+
const files = await speakChunks(deps, context, providers, choice, plan(deps, context, texts));
|
|
57
|
+
await storeBody(deps, context, choice, files, outputs);
|
|
58
|
+
await speakSegments(deps, context, providers, choice, outputs);
|
|
59
|
+
deps.log.write("info", "narration.done", {
|
|
60
|
+
projectId,
|
|
61
|
+
stage: "audio",
|
|
62
|
+
detail: `${String(texts.length)} chunks in ${chunking.mode} mode`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// The narration source of §Q37 and `logic/07` step 4: the plain-text article, written by
|
|
66
|
+
// the article stage or pasted in by a user whose article stage was set to Provide.
|
|
67
|
+
function narrationSource(deps, outputs, projectId) {
|
|
68
|
+
const article = outputs.find((output) => output.role === "article_txt");
|
|
69
|
+
if (article === undefined) {
|
|
70
|
+
throw new Error(nothingToNarrate);
|
|
71
|
+
}
|
|
72
|
+
return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
|
|
73
|
+
}
|
|
74
|
+
// The chunk list, planned once and kept. A retry after a failure finds the rows the first
|
|
75
|
+
// run wrote and narrates only the ones that did not finish (§Q66).
|
|
76
|
+
function plan(deps, context, texts) {
|
|
77
|
+
const existing = piecesOf(deps.db, context.stage.id, "chunk");
|
|
78
|
+
if (existing.length > 0) {
|
|
79
|
+
return existing;
|
|
80
|
+
}
|
|
81
|
+
const planned = texts.map((text, index) => ({
|
|
82
|
+
id: deps.ids.next(),
|
|
83
|
+
stageId: context.stage.id,
|
|
84
|
+
kind: "chunk",
|
|
85
|
+
idx: index + 1,
|
|
86
|
+
state: "pending",
|
|
87
|
+
payload: JSON.stringify({ text }),
|
|
88
|
+
}));
|
|
89
|
+
transact(deps.db, () => {
|
|
90
|
+
for (const piece of planned) {
|
|
91
|
+
insertPiece(deps.db, piece);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return planned;
|
|
95
|
+
}
|
|
96
|
+
// Step 3: "Synthesize every chunk in parallel". §Q66: a chunk a previous run finished is
|
|
97
|
+
// not spoken again, and one that fails fails the whole stage while its siblings finish
|
|
98
|
+
// and keep their audio for the next resume.
|
|
99
|
+
async function speakChunks(deps, context, providers, choice, pieces) {
|
|
100
|
+
const { projectId } = context.stage;
|
|
101
|
+
const total = pieces.length;
|
|
102
|
+
let done = pieces.filter((piece) => finished(deps, projectId, piece)).length;
|
|
103
|
+
report(deps, context, done, total);
|
|
104
|
+
const outcomes = await Promise.all(pieces.map(async (piece) => {
|
|
105
|
+
const carried = chunkOf(piece);
|
|
106
|
+
const kept = finished(deps, projectId, piece);
|
|
107
|
+
if (kept !== undefined) {
|
|
108
|
+
return { ok: true, file: kept };
|
|
109
|
+
}
|
|
110
|
+
const file = `${chunkDir}/${String(piece.idx).padStart(3, "0")}.mp3`;
|
|
111
|
+
setPiece(deps.db, piece.id, "running", piece.payload);
|
|
112
|
+
try {
|
|
113
|
+
const spoken = await providers.forPiece(piece.id).tts({
|
|
114
|
+
provider: choice.provider,
|
|
115
|
+
voiceId: choice.voice,
|
|
116
|
+
text: carried.text,
|
|
117
|
+
});
|
|
118
|
+
write(deps, projectId, file, spoken.bytes);
|
|
119
|
+
// The file is on disk before the row says so: a crash between the two leaves a
|
|
120
|
+
// file the reconcile collects, where the other order would leave a `done` chunk
|
|
121
|
+
// whose audio never existed and a concat that fails on every retry.
|
|
122
|
+
setPiece(deps.db, piece.id, "done", JSON.stringify({ text: carried.text, file }));
|
|
123
|
+
done += 1;
|
|
124
|
+
report(deps, context, done, total);
|
|
125
|
+
return { ok: true, file };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// A cancel is not this chunk failing: `logic/13` §Q112 counts an aborted call as
|
|
129
|
+
// nothing, and the resume runs a `pending` chunk exactly as a failed one.
|
|
130
|
+
setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
|
|
131
|
+
return { ok: false, error };
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
const files = [];
|
|
135
|
+
for (const outcome of outcomes) {
|
|
136
|
+
if (!outcome.ok) {
|
|
137
|
+
throw outcome.error;
|
|
138
|
+
}
|
|
139
|
+
files.push(outcome.file);
|
|
140
|
+
}
|
|
141
|
+
return files;
|
|
142
|
+
}
|
|
143
|
+
// Steps 4 and 6: one body file, in chunk order, with its measured duration on the row.
|
|
144
|
+
async function storeBody(deps, context, choice, files, outputs) {
|
|
145
|
+
const { projectId } = context.stage;
|
|
146
|
+
if (outputs.some((output) => output.role === "audio_body")) {
|
|
147
|
+
// A retry that failed on a segment last time: the body is already joined and measured,
|
|
148
|
+
// and joining it again would insert a second row for the same file.
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const name = outputFileName("audio_body", 1, ".mp3", "audio");
|
|
152
|
+
const durationMs = await joinNarration({ bin: deps.ffmpeg, log: deps.log }, {
|
|
153
|
+
files: files.map((file) => outputPath(deps.paths, projectId, file)),
|
|
154
|
+
listPath: outputPath(deps.paths, projectId, `${chunkDir}/concat.txt`),
|
|
155
|
+
output: outputPath(deps.paths, projectId, name),
|
|
156
|
+
signal: context.signal,
|
|
157
|
+
});
|
|
158
|
+
// `logic/13` §Q113 protects an output already stored, not one about to be: a cancel
|
|
159
|
+
// landing between ffmpeg exiting and the row below leaves nothing recorded.
|
|
160
|
+
context.signal.throwIfAborted();
|
|
161
|
+
store(deps, projectId, "audio_body", name, durationMs, choice);
|
|
162
|
+
counted(deps, "body", durationMs, choice);
|
|
163
|
+
}
|
|
164
|
+
// Step 5: "each picked segment's text is one TTS request with the same provider and
|
|
165
|
+
// voice, stored as its own audio file with its duration; body chunking does not apply to
|
|
166
|
+
// them" (§Q93). The text is whatever the article stage wrote onto the segment piece: an
|
|
167
|
+
// LLM-mode entry's answer, or a text-mode entry's rendered body, spoken verbatim.
|
|
168
|
+
async function speakSegments(deps, context, providers, choice, outputs) {
|
|
169
|
+
const { projectId } = context.stage;
|
|
170
|
+
for (const piece of segmentPieces(deps, projectId)) {
|
|
171
|
+
const segment = segmentOf(piece);
|
|
172
|
+
const role = segment.category === "intro" ? "audio_intro" : "audio_outro";
|
|
173
|
+
if (outputs.some((output) => output.role === role)) {
|
|
174
|
+
// Spoken by a previous run of this stage; §Q66's resume keeps it.
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const text = segment.text.trim();
|
|
178
|
+
if (text === "") {
|
|
179
|
+
// §Q67's rule, applied to a segment: there is nothing to say, and silently dropping
|
|
180
|
+
// an intro the user picked would lose it without telling anyone.
|
|
181
|
+
throw new Error(`the ${segment.category} segment has ${nothingToNarrate}`);
|
|
182
|
+
}
|
|
183
|
+
const spoken = await providers.forPiece(piece.id).tts({
|
|
184
|
+
provider: choice.provider,
|
|
185
|
+
voiceId: choice.voice,
|
|
186
|
+
text,
|
|
187
|
+
});
|
|
188
|
+
const name = outputFileName(role, 1, ".mp3", "audio");
|
|
189
|
+
write(deps, projectId, name, spoken.bytes);
|
|
190
|
+
// Measured the same way the body is, because `logic/11` step 1 adds all three and the
|
|
191
|
+
// gaps to get the length of the video (§Q68).
|
|
192
|
+
const durationMs = await probeDurationMs(deps.ffmpeg, outputPath(deps.paths, projectId, name), context.signal, deps.log);
|
|
193
|
+
context.signal.throwIfAborted();
|
|
194
|
+
store(deps, projectId, role, name, durationMs, choice);
|
|
195
|
+
counted(deps, segment.category, durationMs, choice);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// The segment pieces belong to the *article* stage, which is where `logic/07` step 5
|
|
199
|
+
// writes them, so they are read under its id and never under this stage's own. They stay
|
|
200
|
+
// there because `logic/12` is what depends on it: a Re-run audio (step 2) and the audio
|
|
201
|
+
// re-run an article edit cascades into (step 1) both clear the audio stage's pieces, and
|
|
202
|
+
// only the article stage ever writes an entry text - so an intro kept on this stage's row
|
|
203
|
+
// would be thrown away by the first re-narration and never written again.
|
|
204
|
+
function segmentPieces(deps, projectId) {
|
|
205
|
+
const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
|
|
206
|
+
if (article === undefined) {
|
|
207
|
+
// Admission writes all six stage rows with the project (`logic/04` step 6), so a
|
|
208
|
+
// project without an article stage is a bug rather than a run the user configured.
|
|
209
|
+
throw new Error(`project ${projectId} has no article stage`);
|
|
210
|
+
}
|
|
211
|
+
return piecesOf(deps.db, article.id, "segment");
|
|
212
|
+
}
|
|
213
|
+
// logic/16 step 3: "audio seconds from the measured duration per segment". The measured
|
|
214
|
+
// one, not the text's length: `logic/08` §Q68 puts the real duration on the row and
|
|
215
|
+
// `logic/11` builds the timeline from it. No model is named because the TTS port carries
|
|
216
|
+
// none - the same reason `store` above leaves it off the output row.
|
|
217
|
+
function counted(deps, segment, durationMs, choice) {
|
|
218
|
+
deps.count("stage.completed", {
|
|
219
|
+
stage: "audio",
|
|
220
|
+
segment,
|
|
221
|
+
provider: choice.provider,
|
|
222
|
+
audioSeconds: durationMs / 1000,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Step 5: "k of N chunks narrated". Written as well as emitted, so a page opened mid-stage
|
|
226
|
+
// reads the count off the row rather than waiting for the next chunk.
|
|
227
|
+
function report(deps, context, done, total) {
|
|
228
|
+
setStageProgress(deps.db, context.stage.id, done, total);
|
|
229
|
+
context.emit({
|
|
230
|
+
type: "stage.progress",
|
|
231
|
+
projectId: context.stage.projectId,
|
|
232
|
+
stage: "audio",
|
|
233
|
+
current: done,
|
|
234
|
+
total,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// A chunk counts as finished only when its audio is still on disk: the row and the file
|
|
238
|
+
// are written one after the other, and the boot reconcile can remove a file whose row
|
|
239
|
+
// survived. Answering "done" for a missing file would fail the concat instead.
|
|
240
|
+
function finished(deps, projectId, piece) {
|
|
241
|
+
const file = chunkOf(piece).file;
|
|
242
|
+
if (file === undefined) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
return existsSync(outputPath(deps.paths, projectId, file)) ? file : undefined;
|
|
246
|
+
}
|
|
247
|
+
function write(deps, projectId, path, bytes) {
|
|
248
|
+
const target = outputPath(deps.paths, projectId, path);
|
|
249
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
250
|
+
writeFileSync(target, bytes, { mode: 0o600 });
|
|
251
|
+
}
|
|
252
|
+
// §Q68: the provider and the voice are stored with the audio they made. The model is not:
|
|
253
|
+
// the TTS port carries no model, so every request went to the adapter's own, and writing
|
|
254
|
+
// the run's dropdown value here would record something that was never sent.
|
|
255
|
+
function store(deps, projectId, role, path, durationMs, choice) {
|
|
256
|
+
insertOutput(deps.db, {
|
|
257
|
+
id: deps.ids.next(),
|
|
258
|
+
projectId,
|
|
259
|
+
stageKind: "audio",
|
|
260
|
+
role,
|
|
261
|
+
path,
|
|
262
|
+
originalFilename: null,
|
|
263
|
+
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
264
|
+
durationMs,
|
|
265
|
+
meta: { provider: choice.provider, voice: choice.voice },
|
|
266
|
+
createdAt: deps.clock.now().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function chunkOf(piece) {
|
|
270
|
+
if (piece.payload === null) {
|
|
271
|
+
throw new Error(`chunk ${piece.id} has no payload`);
|
|
272
|
+
}
|
|
273
|
+
return chunkPayload.parse(JSON.parse(piece.payload));
|
|
274
|
+
}
|
|
275
|
+
function segmentOf(piece) {
|
|
276
|
+
if (piece.payload === null) {
|
|
277
|
+
throw new Error(`segment ${piece.id} has no payload`);
|
|
278
|
+
}
|
|
279
|
+
return segmentPayload.parse(JSON.parse(piece.payload));
|
|
280
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { deps as graph } from "../../kernel/runner/graph.js";
|
|
3
|
+
// The stages to redo, in pipeline order, each with how much of itself it throws away.
|
|
4
|
+
export function redoPlan(input) {
|
|
5
|
+
const stale = staleStages(input);
|
|
6
|
+
return stageKinds
|
|
7
|
+
.filter((kind) => stale.has(kind) && redoable(stateOf(input.stages, kind)))
|
|
8
|
+
.map((kind) => ({ stage: kind, clears: clearsOf(input.action, kind) }));
|
|
9
|
+
}
|
|
10
|
+
function staleStages(input) {
|
|
11
|
+
const { action } = input;
|
|
12
|
+
switch (action.kind) {
|
|
13
|
+
case "rerun":
|
|
14
|
+
return withDependents([action.stage]);
|
|
15
|
+
case "article-edit": {
|
|
16
|
+
// §Q101, in its own words: "audio, LLM-mode intro/outro text, LLM-written
|
|
17
|
+
// thumbnail, and video re-run; prompt-based images are untouched". The video comes
|
|
18
|
+
// along as a dependent of the audio, so it is never named here.
|
|
19
|
+
const roots = ["audio"];
|
|
20
|
+
if (input.thumbnailSource === "prompt_by_llm") {
|
|
21
|
+
roots.push("thumbnail");
|
|
22
|
+
}
|
|
23
|
+
return withDependents(roots);
|
|
24
|
+
}
|
|
25
|
+
case "image-deleted":
|
|
26
|
+
// Step 5: the image is "removed from the set; video re-renders". The remaining
|
|
27
|
+
// images stand, so the images stage itself is not redone.
|
|
28
|
+
return withDependents(["video"]);
|
|
29
|
+
case "image-regenerated":
|
|
30
|
+
return withDependents(["images"]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// `logic/01` forbids `provided` → `running` and `skipped` → `running`, so a stage whose
|
|
34
|
+
// output the user supplied or switched off is stepped over. The walk above still passes
|
|
35
|
+
// through it: re-running the article with a provided audio must reach the video.
|
|
36
|
+
function redoable(state) {
|
|
37
|
+
return state !== "provided" && state !== "skipped";
|
|
38
|
+
}
|
|
39
|
+
function clearsOf(action, kind) {
|
|
40
|
+
// §Q106: "the previous video stays downloadable until the new render finishes". Clearing
|
|
41
|
+
// the video's outputs here would take it away the moment the user pressed Re-render - or
|
|
42
|
+
// for the whole of a re-narration, when the video comes along as a dependent. It is kept
|
|
43
|
+
// instead, and `slices/video/run.ts` replaces the file and the rows in one swap once
|
|
44
|
+
// ffmpeg has exited cleanly. A render that fails or is canceled leaves the old one
|
|
45
|
+
// standing, which is exactly what §Q106 promises.
|
|
46
|
+
if (kind === "video") {
|
|
47
|
+
return "nothing";
|
|
48
|
+
}
|
|
49
|
+
return action.kind === "image-regenerated" && kind === "images" ? "nothing" : "all";
|
|
50
|
+
}
|
|
51
|
+
// The transitive closure of the dependents of `roots` over `kernel/runner/graph.ts`. The
|
|
52
|
+
// graph has six nodes, so one pass per node closes it whatever the edges are.
|
|
53
|
+
function withDependents(roots) {
|
|
54
|
+
const found = new Set(roots);
|
|
55
|
+
for (let pass = 0; pass < stageKinds.length; pass += 1) {
|
|
56
|
+
for (const kind of stageKinds) {
|
|
57
|
+
if (graph[kind].some((dependency) => found.has(dependency))) {
|
|
58
|
+
found.add(kind);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
function stateOf(stages, kind) {
|
|
65
|
+
return stages.find((stage) => stage.kind === kind)?.state ?? "pending";
|
|
66
|
+
}
|