@gentbajko/slopify 0.1.0 → 0.2.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/README.md +2 -2
- package/dist/adapter-registry.js +11 -12
- package/dist/adapters/image/bytes.js +1 -1
- package/dist/adapters/image/fal.js +28 -41
- package/dist/adapters/image/openai.js +19 -24
- package/dist/adapters/image/replicate.js +33 -43
- package/dist/adapters/llm/claude-code.js +16 -19
- package/dist/adapters/llm/codex.js +14 -15
- package/dist/adapters/llm/openrouter.js +13 -16
- package/dist/adapters/llm/run-cli.js +9 -11
- package/dist/adapters/llm/sse-lines.js +1 -2
- package/dist/adapters/retry-after.js +6 -6
- package/dist/adapters/tts/cartesia.js +5 -5
- package/dist/adapters/tts/elevenlabs.js +14 -19
- package/dist/adapters/tts/openai.js +9 -10
- package/dist/edge/cli.js +0 -0
- package/dist/edge/http/actions.js +8 -9
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/entries.js +1 -1
- package/dist/edge/http/projects.js +7 -7
- package/dist/edge/http/prompts.js +6 -6
- package/dist/edge/http/providers.js +4 -4
- package/dist/edge/http/settings.js +2 -2
- package/dist/edge/http/staging.js +1 -1
- package/dist/edge/http/telemetry.js +6 -7
- package/dist/edge/http/usage.js +5 -5
- package/dist/kernel/config/index.js +1 -1
- package/dist/kernel/db/tx.js +9 -12
- package/dist/kernel/log.js +5 -5
- package/dist/kernel/pipeline.js +7 -9
- package/dist/kernel/ports/model.js +3 -4
- package/dist/kernel/runner/attempt-repo.js +5 -6
- package/dist/kernel/runner/attempt.js +16 -23
- package/dist/kernel/runner/graph.js +18 -29
- package/dist/kernel/runner/index.js +27 -33
- package/dist/kernel/runner/piece-repo.js +9 -11
- package/dist/kernel/runner/providers.js +5 -7
- package/dist/kernel/version.js +3 -3
- package/dist/main.js +10 -11
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +6 -7
- package/dist/slices/admission/rules.js +12 -15
- package/dist/slices/admission/start.js +7 -8
- package/dist/slices/admission/substitute.js +11 -11
- package/dist/slices/article/continuation.js +9 -9
- package/dist/slices/article/plain.js +5 -5
- package/dist/slices/article/run.js +25 -25
- package/dist/slices/article/split.js +1 -1
- package/dist/slices/article/store.js +5 -5
- package/dist/slices/cancel/index.js +8 -8
- package/dist/slices/images/run.js +32 -34
- package/dist/slices/library/lint.js +3 -3
- package/dist/slices/library/model.js +7 -7
- package/dist/slices/library/repo.js +3 -3
- package/dist/slices/library/save.js +7 -7
- package/dist/slices/library/slots.js +5 -5
- package/dist/slices/narration/chunk.js +12 -12
- package/dist/slices/narration/concat.js +12 -13
- package/dist/slices/narration/run.js +46 -49
- package/dist/slices/reruns/cascade.js +10 -11
- package/dist/slices/reruns/index.js +36 -37
- package/dist/slices/research/planner.js +4 -5
- package/dist/slices/research/run.js +22 -23
- package/dist/slices/research/synthesis.js +5 -5
- package/dist/slices/settings/cli-status.js +9 -10
- package/dist/slices/settings/keys.js +8 -9
- package/dist/slices/settings/model.js +5 -5
- package/dist/slices/settings/readiness.js +3 -4
- package/dist/slices/settings/repo.js +1 -1
- package/dist/slices/settings/voices.js +3 -4
- package/dist/slices/storage/asset-name.js +2 -2
- package/dist/slices/storage/delete-project.js +3 -5
- package/dist/slices/storage/downloads.js +4 -4
- package/dist/slices/storage/layout.js +5 -6
- package/dist/slices/storage/model.js +1 -1
- package/dist/slices/storage/reconcile.js +6 -7
- package/dist/slices/storage/repo.js +1 -1
- package/dist/slices/storage/staging.js +12 -15
- package/dist/slices/telemetry/collector-client.js +2 -2
- package/dist/slices/telemetry/flush.js +8 -8
- package/dist/slices/telemetry/machine.js +6 -7
- package/dist/slices/telemetry/model.js +11 -13
- package/dist/slices/telemetry/record.js +7 -7
- package/dist/slices/telemetry/repo.js +4 -4
- package/dist/slices/telemetry/usage.js +5 -5
- package/dist/slices/thumbnail/by-llm.js +2 -2
- package/dist/slices/thumbnail/run.js +30 -32
- package/dist/slices/video/ffmpeg.js +6 -6
- package/dist/slices/video/plan.js +9 -9
- package/dist/slices/video/run.js +19 -20
- package/dist/web/assets/index-77hb2Mr0.js +81 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-D_sWbKQi.js +0 -81
|
@@ -7,13 +7,13 @@ import { projectById, setStageProgress } from "../admission/repo.js";
|
|
|
7
7
|
import { outputFileName, outputPath } from "../storage/layout.js";
|
|
8
8
|
import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
9
9
|
// What an image piece carries between runs: what was asked for, and the file it came back
|
|
10
|
-
// as once the provider answered. That is the whole of the resume
|
|
10
|
+
// as once the provider answered. That is the whole of the resume.
|
|
11
11
|
const imagePayload = z.object({
|
|
12
12
|
promptName: z.string(),
|
|
13
13
|
prompt: z.string(),
|
|
14
14
|
// Which ticked prompt this send belongs to, and which send of that prompt it is. Both
|
|
15
|
-
// are stored rather than derived, because
|
|
16
|
-
//
|
|
15
|
+
// are stored rather than derived, because the user can delete an image and the pieces
|
|
16
|
+
// that are left must still say where each one came from.
|
|
17
17
|
promptIndex: z.number(),
|
|
18
18
|
indexInPrompt: z.number(),
|
|
19
19
|
file: z.string().optional(),
|
|
@@ -26,27 +26,25 @@ export async function runImages(deps, context, providers) {
|
|
|
26
26
|
}
|
|
27
27
|
const choice = project.config.images;
|
|
28
28
|
if (choice === undefined) {
|
|
29
|
-
// Admission refuses a run whose images are Generate without a provider and a model
|
|
30
|
-
//
|
|
29
|
+
// Admission refuses a run whose images are Generate without a provider and a model, so
|
|
30
|
+
// reaching here is a bug in admission rather than the user's.
|
|
31
31
|
throw new Error("the run has no image provider or model");
|
|
32
32
|
}
|
|
33
33
|
const pieces = plan(deps, context, project.config);
|
|
34
34
|
if (pieces.length === 0) {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// Admission makes an image source mandatory and puts the Number at one or more, so an
|
|
36
|
+
// empty plan is a bug upstream rather than a run with no pictures.
|
|
37
37
|
throw new Error("the run ticked no image prompt");
|
|
38
38
|
}
|
|
39
|
-
//
|
|
40
|
-
//
|
|
39
|
+
// The run's own frame. The adapter turns it into whatever its provider spells the
|
|
40
|
+
// closest supported size, and the render crops whatever is left over.
|
|
41
41
|
const made = await sendAll(deps, context, providers, choice, pieces, project.format);
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// image counts as one (§Q103, scenario 12: "regenerations count again").
|
|
42
|
+
// Images made means images stored, and only the ones this run stored: a resume does not
|
|
43
|
+
// count an image the previous run made, and a regenerated image counts again.
|
|
45
44
|
//
|
|
46
45
|
// ceiling: a run that fails partway records nothing, so the images it did store are
|
|
47
|
-
// counted by no event and the resume counts only its own.
|
|
48
|
-
//
|
|
49
|
-
// per image.
|
|
46
|
+
// counted by no event and the resume counts only its own. One event is written when the
|
|
47
|
+
// stage completes; the upgrade, if the drift ever matters, is an event per image.
|
|
50
48
|
deps.count("stage.completed", {
|
|
51
49
|
stage: "images",
|
|
52
50
|
provider: choice.provider,
|
|
@@ -59,12 +57,12 @@ export async function runImages(deps, context, providers) {
|
|
|
59
57
|
detail: `${String(pieces.length)} images from ${String(project.config.imagePrompts.length)} prompts`,
|
|
60
58
|
});
|
|
61
59
|
}
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
60
|
+
// For each ticked image prompt, its rendered text is sent Number times as independent
|
|
61
|
+
// parallel calls. The plan is made once and kept, so a retry finds the rows the first run
|
|
62
|
+
// wrote and sends only what did not land.
|
|
65
63
|
//
|
|
66
|
-
// `idx` is the slideshow order
|
|
67
|
-
//
|
|
64
|
+
// `idx` is the slideshow order - prompts in selection order, then the index within each
|
|
65
|
+
// prompt - so the order is fixed here and never depends on which image arrives first.
|
|
68
66
|
function plan(deps, context, config) {
|
|
69
67
|
const existing = piecesOf(deps.db, context.stage.id, "image");
|
|
70
68
|
if (existing.length > 0) {
|
|
@@ -73,7 +71,7 @@ function plan(deps, context, config) {
|
|
|
73
71
|
const planned = [];
|
|
74
72
|
for (const [at, picked] of config.imagePrompts.entries()) {
|
|
75
73
|
// `slices/library/slots.ts` names the rendered body after the draft field that picked
|
|
76
|
-
// it, so the run carries the substituted text under this key
|
|
74
|
+
// it, so the run carries the substituted text under this key.
|
|
77
75
|
const prompt = config.rendered[`imagePrompts.${String(at)}`];
|
|
78
76
|
if (prompt === undefined) {
|
|
79
77
|
throw new Error(`the run has no rendered text for the image prompt ${picked.name}`);
|
|
@@ -101,9 +99,9 @@ function plan(deps, context, config) {
|
|
|
101
99
|
});
|
|
102
100
|
return planned;
|
|
103
101
|
}
|
|
104
|
-
//
|
|
102
|
+
// An image a previous run stored is not made again, and one that fails takes down the
|
|
105
103
|
// whole stage while its siblings finish and keep their images for the next resume. A
|
|
106
|
-
// refusal is the wrapper's to make terminal
|
|
104
|
+
// refusal is the wrapper's to make terminal; nothing here counts attempts.
|
|
107
105
|
async function sendAll(deps, context, providers, choice, pieces, format) {
|
|
108
106
|
const { projectId } = context.stage;
|
|
109
107
|
// Read once, before anything is written: a piece counts as landed only when its row and
|
|
@@ -126,21 +124,21 @@ async function sendAll(deps, context, providers, choice, pieces, format) {
|
|
|
126
124
|
aspect: format,
|
|
127
125
|
});
|
|
128
126
|
const file = keep(deps, projectId, piece.idx, made);
|
|
129
|
-
//
|
|
127
|
+
// The prompt text, the prompt name, the index within the prompt, the
|
|
130
128
|
// provider and the model travel with the image.
|
|
131
129
|
const output = record(deps, projectId, piece, asked, file, made, choice);
|
|
132
130
|
// The row and the file are both there before the piece says so: a crash between
|
|
133
131
|
// them leaves a piece that is not `done`, which the next run simply sends again.
|
|
134
132
|
setPiece(deps.db, piece.id, "done", JSON.stringify({ ...asked, file }));
|
|
135
133
|
done += 1;
|
|
136
|
-
//
|
|
134
|
+
// The project page fills in as each one arrives.
|
|
137
135
|
context.emit({ type: "image.landed", projectId, outputId: output.id, index: piece.idx });
|
|
138
136
|
report(deps, context, done, total);
|
|
139
137
|
return { ok: true, made: true };
|
|
140
138
|
}
|
|
141
139
|
catch (error) {
|
|
142
|
-
// A cancel is not this image failing:
|
|
143
|
-
//
|
|
140
|
+
// A cancel is not this image failing: an aborted call counts as nothing, and the
|
|
141
|
+
// resume runs a `pending` image exactly as a failed one.
|
|
144
142
|
setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
|
|
145
143
|
return { ok: false, error };
|
|
146
144
|
}
|
|
@@ -156,8 +154,8 @@ async function sendAll(deps, context, providers, choice, pieces, format) {
|
|
|
156
154
|
}
|
|
157
155
|
// An image counts as landed only when its row and its file are both still there: they are
|
|
158
156
|
// written one after the other, the boot reconcile can remove a file whose row survived,
|
|
159
|
-
// and
|
|
160
|
-
//
|
|
157
|
+
// and the user can delete one on purpose. Answering "done" for a missing file would hand
|
|
158
|
+
// the render a path to nothing.
|
|
161
159
|
function landed(deps, projectId, piece, stored) {
|
|
162
160
|
const file = payloadOf(piece).file;
|
|
163
161
|
if (file === undefined) {
|
|
@@ -169,7 +167,7 @@ function landed(deps, projectId, piece, stored) {
|
|
|
169
167
|
return existsSync(outputPath(deps.paths, projectId, file));
|
|
170
168
|
}
|
|
171
169
|
function keep(deps, projectId, idx, made) {
|
|
172
|
-
//
|
|
170
|
+
// Stored as received, png or jpg. The adapter read the mime off the bytes,
|
|
173
171
|
// so the extension is what the file actually is rather than what a header claimed.
|
|
174
172
|
const name = outputFileName("image", idx, made.mime === "image/png" ? ".png" : ".jpg", "images");
|
|
175
173
|
const target = outputPath(deps.paths, projectId, name);
|
|
@@ -187,8 +185,8 @@ function record(deps, projectId, piece, asked, file, made, choice) {
|
|
|
187
185
|
originalFilename: null,
|
|
188
186
|
bytes: made.bytes.byteLength,
|
|
189
187
|
durationMs: null,
|
|
190
|
-
// `index` is the slideshow place
|
|
191
|
-
// rather than the order the images came back in
|
|
188
|
+
// `index` is the slideshow place the render sorts on, and it is the piece's own `idx`
|
|
189
|
+
// rather than the order the images came back in.
|
|
192
190
|
meta: {
|
|
193
191
|
index: piece.idx,
|
|
194
192
|
promptName: asked.promptName,
|
|
@@ -201,8 +199,8 @@ function record(deps, projectId, piece, asked, file, made, choice) {
|
|
|
201
199
|
insertOutput(deps.db, output);
|
|
202
200
|
return output;
|
|
203
201
|
}
|
|
204
|
-
//
|
|
205
|
-
//
|
|
202
|
+
// K of N images made. Written as well as emitted, so a page opened mid-stage reads the count
|
|
203
|
+
// off the row rather than waiting for the next image.
|
|
206
204
|
function report(deps, context, done, total) {
|
|
207
205
|
setStageProgress(deps.db, context.stage.id, done, total);
|
|
208
206
|
context.emit({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// What
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// What a save has to carry: a name, a body and no lint error. The slot grammar is not
|
|
2
|
+
// restated here - it is admission/substitute.ts's, imported so the editor and the run see
|
|
3
|
+
// the same parse of the same body.
|
|
4
4
|
import { detectSlots } from "../admission/substitute.js";
|
|
5
5
|
import { bodyMax, nameMax } from "./model.js";
|
|
6
6
|
export function lintPrompt(draft) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// The two template libraries
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The two template libraries: prompts of three kinds, and the intro/outro entries. One rule
|
|
2
|
+
// set covers both, which is why the drafts, the lint and the save path below are shared
|
|
3
|
+
// rather than mirrored.
|
|
4
4
|
export { entryModes } from "../admission/model.js";
|
|
5
|
-
//
|
|
5
|
+
// The CHECK constraint on `prompts.kind` holds the same three.
|
|
6
6
|
export const promptKinds = ["article", "image", "thumbnail"];
|
|
7
7
|
export const entryCategories = ["intro", "outro"];
|
|
8
8
|
// The name is what the Play pickers show, so it shares admission's title bound.
|
|
9
9
|
export const nameMax = 200;
|
|
10
|
-
// ceiling:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// ceiling: nothing sets a length for a body, and the sample article prompt is already a
|
|
11
|
+
// page long. This is a sanity bound on a trust boundary, not a product rule; raising it is
|
|
12
|
+
// a one-line change with no schema consequence.
|
|
13
13
|
export const bodyMax = 100_000;
|
|
@@ -23,12 +23,12 @@ const slotsColumn = z.array(z.string());
|
|
|
23
23
|
// `lower(name)` and not `name COLLATE NOCASE`: it is the expression the unique indexes
|
|
24
24
|
// are built on, so a lookup by name reads the index instead of the table.
|
|
25
25
|
const byName = "lower(name) = lower(?)";
|
|
26
|
-
//
|
|
26
|
+
// The lists on 04 Prompts and the pickers on Play sort by name.
|
|
27
27
|
const byNameOrder = "ORDER BY lower(name)";
|
|
28
28
|
export function insertPrompt(db, prompt) {
|
|
29
29
|
db.prepare("INSERT INTO prompts (id, kind, name, body, slots, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(prompt.id, prompt.kind, prompt.name, prompt.body, JSON.stringify(prompt.slots), prompt.updatedAt);
|
|
30
30
|
}
|
|
31
|
-
//
|
|
31
|
+
// The kind may change after creation, so the update writes it like any other
|
|
32
32
|
// column. Returns false when no row has that id.
|
|
33
33
|
export function replacePrompt(db, prompt) {
|
|
34
34
|
const result = db
|
|
@@ -36,7 +36,7 @@ export function replacePrompt(db, prompt) {
|
|
|
36
36
|
.run(prompt.kind, prompt.name, prompt.body, JSON.stringify(prompt.slots), prompt.updatedAt, prompt.id);
|
|
37
37
|
return Number(result.changes) > 0;
|
|
38
38
|
}
|
|
39
|
-
//
|
|
39
|
+
// No foreign key points at this row, so a project that used the template keeps
|
|
40
40
|
// its own rendered text and nothing cascades.
|
|
41
41
|
export function deletePrompt(db, id) {
|
|
42
42
|
return Number(db.prepare("DELETE FROM prompts WHERE id = ?").run(id).changes) > 0;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Creating, editing and deleting a template
|
|
2
|
-
// and entries
|
|
1
|
+
// Creating, editing and deleting a template. One rule set covers prompts
|
|
2
|
+
// and entries, so both go through the same three outcomes.
|
|
3
3
|
import { isUniqueConstraint } from "../../kernel/db/index.js";
|
|
4
4
|
import { detectSlots } from "../admission/substitute.js";
|
|
5
5
|
import { lintEntry, lintPrompt } from "./lint.js";
|
|
@@ -15,7 +15,7 @@ export function createPrompt(deps, draft) {
|
|
|
15
15
|
return true;
|
|
16
16
|
}, prompt);
|
|
17
17
|
}
|
|
18
|
-
//
|
|
18
|
+
// A save overwrites; there is no version history, so the row is replaced whole.
|
|
19
19
|
export function updatePrompt(deps, id, draft) {
|
|
20
20
|
const fields = lintPrompt(draft);
|
|
21
21
|
if (fields.length > 0) {
|
|
@@ -70,10 +70,10 @@ function entryOf(deps, draft) {
|
|
|
70
70
|
updatedAt: deps.clock.now().toISOString(),
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
73
|
+
// Uniqueness is the schema's: `prompts(kind, lower(name))` and `entries(category,
|
|
74
|
+
// lower(name))`. A read-then-write check would answer from a row a second writer could
|
|
75
|
+
// delete between the two statements, so the index decides and the raw SQLite error never
|
|
76
|
+
// leaves this module.
|
|
77
77
|
function written(write, value) {
|
|
78
78
|
try {
|
|
79
79
|
return write() ? { ok: true, value } : { ok: false, reason: "not-found" };
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// What a run needs from the library at the moment Play is pressed: the saved bodies of
|
|
2
2
|
// the picked templates, the slot names they ask for, and the rendered text stored on the
|
|
3
|
-
// project.
|
|
4
|
-
//
|
|
3
|
+
// project. The bodies are read at the click, not at selection, so an edit made in between
|
|
4
|
+
// is the one that runs.
|
|
5
5
|
import { collectFields, render } from "../admission/substitute.js";
|
|
6
6
|
import { entryByName, promptByName } from "./repo.js";
|
|
7
7
|
export function pickTemplates(db, draft) {
|
|
8
8
|
const { sources } = draft;
|
|
9
9
|
const missing = [];
|
|
10
|
-
//
|
|
10
|
+
// Only the prompts of stages set to Generate, plus the picked
|
|
11
11
|
// entries. A prompt left selected on a stage set to Provide asks for no field.
|
|
12
12
|
const text = [];
|
|
13
13
|
const image = [];
|
|
@@ -25,7 +25,7 @@ export function pickTemplates(db, draft) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
// Both Generate modes read the same thumbnail template; Prompt by LLM sends it as the
|
|
28
|
-
// instruction rather than to the image provider
|
|
28
|
+
// instruction rather than to the image provider.
|
|
29
29
|
if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
|
|
30
30
|
body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt");
|
|
31
31
|
}
|
|
@@ -37,7 +37,7 @@ export function pickTemplates(db, draft) {
|
|
|
37
37
|
missing,
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
//
|
|
40
|
+
// Every picked body rendered once, with the trimmed values
|
|
41
41
|
// admission has already accepted, and stored on the project.
|
|
42
42
|
export function renderPicked(picked, values) {
|
|
43
43
|
const rendered = {};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The run's chunking choice: whole text is one request, per paragraph is one request per
|
|
2
|
+
// paragraph, and every ~N words is consecutive chunks each ending at the last sentence
|
|
3
|
+
// boundary at or before N words, N defaulting to 500.
|
|
4
4
|
//
|
|
5
|
-
// Pure, and the only place the rule lives
|
|
6
|
-
// and the render plan
|
|
7
|
-
//
|
|
5
|
+
// Pure, and the only place the rule lives. Chunking sits in the functional core beside
|
|
6
|
+
// substitution and the render plan, so it is testable with no I/O and `run.ts` does nothing
|
|
7
|
+
// but call it.
|
|
8
8
|
export const chunkModes = ["whole", "paragraph", "words"];
|
|
9
9
|
export const defaultChunkWords = 500;
|
|
10
10
|
// A run created before Play carried the control sends the whole text as one request,
|
|
11
|
-
// which is
|
|
11
|
+
// which is the first case and the one that adds nothing the user did not ask for.
|
|
12
12
|
export const defaultChunking = { mode: "whole" };
|
|
13
13
|
// A run of blank lines is one paragraph break, not several, and a line of nothing but
|
|
14
14
|
// spaces between two paragraphs is still a break: strip-markdown output is full of both.
|
|
@@ -37,8 +37,8 @@ function wordRuns(text, budget) {
|
|
|
37
37
|
let count = 0;
|
|
38
38
|
for (const sentence of sentences(text)) {
|
|
39
39
|
const words = wordsIn(sentence);
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// The last sentence boundary at or before N words: the sentence that would push the count
|
|
41
|
+
// past the budget starts the next chunk instead of being split.
|
|
42
42
|
if (count > 0 && count + words > limit) {
|
|
43
43
|
chunks.push(current);
|
|
44
44
|
current = "";
|
|
@@ -48,9 +48,9 @@ function wordRuns(text, budget) {
|
|
|
48
48
|
count += words;
|
|
49
49
|
}
|
|
50
50
|
chunks.push(current);
|
|
51
|
-
// A single sentence longer than the budget lands here whole, on its own:
|
|
52
|
-
//
|
|
53
|
-
//
|
|
51
|
+
// A single sentence longer than the budget lands here whole, on its own: a cut inside a
|
|
52
|
+
// clause would be an audible pause the writer never wrote. The provider's own per-request
|
|
53
|
+
// limit is what refuses it, as an error on the stage.
|
|
54
54
|
return nonEmpty(chunks);
|
|
55
55
|
}
|
|
56
56
|
// ceiling: segmented as English. `Intl.Segmenter` is the platform's own sentence breaker
|
|
@@ -26,14 +26,13 @@ export function concatArgs(listPath, output) {
|
|
|
26
26
|
// video stream in the narration.
|
|
27
27
|
"-map",
|
|
28
28
|
"0:a:0",
|
|
29
|
-
// Re-encoded rather than stream-copied. A copy joins frame runs that each carry their
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// and costs about ten times the disk while a stage is running.
|
|
29
|
+
// Re-encoded rather than stream-copied. A copy joins frame runs that each carry their own
|
|
30
|
+
// encoder delay and padding, which is the click and the drift at every join: three chunks
|
|
31
|
+
// of 1200, 700 and 450 ms copy to 2409 ms and decode to 2350 ms through here, exactly the
|
|
32
|
+
// sum. That number goes to the video timeline, so it has to be the real one. No `-ar`: the
|
|
33
|
+
// provider's own sample rate is kept. ceiling: one re-encode at the bitrate the adapters
|
|
34
|
+
// ask the providers for. Storing the chunks as wav and encoding once at the end would avoid
|
|
35
|
+
// even that generation, and costs about ten times the disk while a stage is running.
|
|
37
36
|
"-c:a",
|
|
38
37
|
"libmp3lame",
|
|
39
38
|
"-b:a",
|
|
@@ -41,12 +40,12 @@ export function concatArgs(listPath, output) {
|
|
|
41
40
|
output,
|
|
42
41
|
];
|
|
43
42
|
}
|
|
44
|
-
// The body audio and its measured duration
|
|
45
|
-
//
|
|
43
|
+
// The body audio and its measured duration - measured, never estimated, because the video
|
|
44
|
+
// timeline is built out of it.
|
|
46
45
|
export async function joinNarration(deps, input) {
|
|
47
46
|
const first = input.files[0];
|
|
48
47
|
if (first === undefined) {
|
|
49
|
-
// The stage refuses an empty narration before it gets here
|
|
48
|
+
// The stage refuses an empty narration before it gets here, so this is a bug.
|
|
50
49
|
throw new Error("there are no audio chunks to join");
|
|
51
50
|
}
|
|
52
51
|
mkdirSync(dirname(input.output), { recursive: true, mode: 0o700 });
|
|
@@ -73,7 +72,7 @@ export async function joinNarration(deps, input) {
|
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
74
|
// Measured off a full decode of the file that was written, not off the parts: a
|
|
76
|
-
// container header can carry an estimate, and
|
|
77
|
-
//
|
|
75
|
+
// container header can carry an estimate, and the render adds this number to the gaps to
|
|
76
|
+
// get the length of the video.
|
|
78
77
|
return probeDurationMs(deps.bin, input.output, input.signal, deps.log);
|
|
79
78
|
}
|
|
@@ -11,24 +11,23 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
|
11
11
|
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
12
12
|
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
13
13
|
import { joinNarration } from "./concat.js";
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// attempt wrapper never sees it and nothing is retried.
|
|
14
|
+
// An empty narration source fails the stage immediately with no retries. Thrown from the
|
|
15
|
+
// slice rather than from a provider call, so the attempt wrapper never sees it.
|
|
17
16
|
export const nothingToNarrate = "nothing to narrate";
|
|
18
17
|
// 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
|
|
18
|
+
// back as once the provider answered. That is the whole of the resume.
|
|
20
19
|
const chunkPayload = z.object({ text: z.string(), file: z.string().optional() });
|
|
21
|
-
// What the article stage left on its `segment` pieces
|
|
20
|
+
// What the article stage left on its `segment` pieces.
|
|
22
21
|
const segmentPayload = z.object({
|
|
23
22
|
category: z.enum(entryCategories),
|
|
24
23
|
name: z.string(),
|
|
25
24
|
mode: z.enum(entryModes),
|
|
26
25
|
text: z.string(),
|
|
27
26
|
});
|
|
28
|
-
// The chunk audio sits in a folder of its own under the project. It is not an output:
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
27
|
+
// The chunk audio sits in a folder of its own under the project. It is not an output: the
|
|
28
|
+
// concatenated body plus the picked intro and outro are the project's only audio outputs,
|
|
29
|
+
// so the pieces name these files and the boot reconcile keeps them by that name
|
|
30
|
+
// (`slices/storage/reconcile.ts`).
|
|
32
31
|
const chunkDir = "audio-chunks";
|
|
33
32
|
export async function runNarration(deps, context, providers) {
|
|
34
33
|
const { projectId } = context.stage;
|
|
@@ -38,16 +37,15 @@ export async function runNarration(deps, context, providers) {
|
|
|
38
37
|
}
|
|
39
38
|
const choice = project.config.audio;
|
|
40
39
|
if (choice === undefined) {
|
|
41
|
-
// Admission refuses a run whose audio is Generate without a provider and a voice
|
|
42
|
-
//
|
|
43
|
-
// did.
|
|
40
|
+
// Admission refuses a run whose audio is Generate without a provider and a voice, so
|
|
41
|
+
// reaching here is a bug in admission rather than something the user did.
|
|
44
42
|
throw new Error("the run has no TTS provider or voice");
|
|
45
43
|
}
|
|
46
44
|
// Read once, before anything is written: the segment steps below ask it what a previous
|
|
47
45
|
// run already stored, and the body step is the only thing that adds to it.
|
|
48
46
|
const outputs = outputsOf(deps.db, projectId);
|
|
49
|
-
//
|
|
50
|
-
// files of its own
|
|
47
|
+
// The end matter is already out of this text: the article stage cut it into
|
|
48
|
+
// files of its own, so nothing here can narrate a sources list.
|
|
51
49
|
const chunking = project.config.chunking ?? defaultChunking;
|
|
52
50
|
const texts = chunkNarration(narrationSource(deps, outputs, projectId), chunking);
|
|
53
51
|
if (texts.length === 0) {
|
|
@@ -62,8 +60,8 @@ export async function runNarration(deps, context, providers) {
|
|
|
62
60
|
detail: `${String(texts.length)} chunks in ${chunking.mode} mode`,
|
|
63
61
|
});
|
|
64
62
|
}
|
|
65
|
-
// The narration source
|
|
66
|
-
//
|
|
63
|
+
// The narration source: the plain-text article, written by the article stage or pasted in
|
|
64
|
+
// by a user whose article stage was set to Provide.
|
|
67
65
|
function narrationSource(deps, outputs, projectId) {
|
|
68
66
|
const article = outputs.find((output) => output.role === "article_txt");
|
|
69
67
|
if (article === undefined) {
|
|
@@ -72,7 +70,7 @@ function narrationSource(deps, outputs, projectId) {
|
|
|
72
70
|
return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
|
|
73
71
|
}
|
|
74
72
|
// 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
|
|
73
|
+
// run wrote and narrates only the ones that did not finish.
|
|
76
74
|
function plan(deps, context, texts) {
|
|
77
75
|
const existing = piecesOf(deps.db, context.stage.id, "chunk");
|
|
78
76
|
if (existing.length > 0) {
|
|
@@ -93,9 +91,9 @@ function plan(deps, context, texts) {
|
|
|
93
91
|
});
|
|
94
92
|
return planned;
|
|
95
93
|
}
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
94
|
+
// Every chunk is synthesized in parallel. A chunk a previous run finished is not spoken
|
|
95
|
+
// again, and one that fails takes down the whole stage while its siblings finish and keep their
|
|
96
|
+
// audio for the next resume.
|
|
99
97
|
async function speakChunks(deps, context, providers, choice, pieces) {
|
|
100
98
|
const { projectId } = context.stage;
|
|
101
99
|
const total = pieces.length;
|
|
@@ -125,8 +123,8 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
125
123
|
return { ok: true, file };
|
|
126
124
|
}
|
|
127
125
|
catch (error) {
|
|
128
|
-
// A cancel is not this chunk failing:
|
|
129
|
-
//
|
|
126
|
+
// A cancel is not this chunk failing: an aborted call counts as nothing, and the
|
|
127
|
+
// resume runs a `pending` chunk exactly as a failed one.
|
|
130
128
|
setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
|
|
131
129
|
return { ok: false, error };
|
|
132
130
|
}
|
|
@@ -140,7 +138,7 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
140
138
|
}
|
|
141
139
|
return files;
|
|
142
140
|
}
|
|
143
|
-
//
|
|
141
|
+
// One body file, in chunk order, with its measured duration on the row.
|
|
144
142
|
async function storeBody(deps, context, choice, files, outputs) {
|
|
145
143
|
const { projectId } = context.stage;
|
|
146
144
|
if (outputs.some((output) => output.role === "audio_body")) {
|
|
@@ -155,29 +153,29 @@ async function storeBody(deps, context, choice, files, outputs) {
|
|
|
155
153
|
output: outputPath(deps.paths, projectId, name),
|
|
156
154
|
signal: context.signal,
|
|
157
155
|
});
|
|
158
|
-
//
|
|
159
|
-
//
|
|
156
|
+
// A cancel protects an output already stored, not one about to be: one landing between
|
|
157
|
+
// ffmpeg exiting and the row below leaves nothing recorded.
|
|
160
158
|
context.signal.throwIfAborted();
|
|
161
159
|
store(deps, projectId, "audio_body", name, durationMs, choice);
|
|
162
160
|
counted(deps, "body", durationMs, choice);
|
|
163
161
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
162
|
+
// Each picked segment's text is one TTS request with the same provider and voice, stored as
|
|
163
|
+
// its own audio file with its duration; body chunking does not apply to them. The text is
|
|
164
|
+
// whatever the article stage wrote onto the segment piece: an LLM-mode entry's answer, or a
|
|
165
|
+
// text-mode entry's rendered body, spoken verbatim.
|
|
168
166
|
async function speakSegments(deps, context, providers, choice, outputs) {
|
|
169
167
|
const { projectId } = context.stage;
|
|
170
168
|
for (const piece of segmentPieces(deps, projectId)) {
|
|
171
169
|
const segment = segmentOf(piece);
|
|
172
170
|
const role = segment.category === "intro" ? "audio_intro" : "audio_outro";
|
|
173
171
|
if (outputs.some((output) => output.role === role)) {
|
|
174
|
-
// Spoken by a previous run of this stage;
|
|
172
|
+
// Spoken by a previous run of this stage; the resume keeps it.
|
|
175
173
|
continue;
|
|
176
174
|
}
|
|
177
175
|
const text = segment.text.trim();
|
|
178
176
|
if (text === "") {
|
|
179
|
-
//
|
|
180
|
-
// an intro the user picked would lose it without telling anyone.
|
|
177
|
+
// The same rule, applied to a segment: there is nothing to say, and silently
|
|
178
|
+
// dropping an intro the user picked would lose it without telling anyone.
|
|
181
179
|
throw new Error(`the ${segment.category} segment has ${nothingToNarrate}`);
|
|
182
180
|
}
|
|
183
181
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
@@ -187,33 +185,32 @@ async function speakSegments(deps, context, providers, choice, outputs) {
|
|
|
187
185
|
});
|
|
188
186
|
const name = outputFileName(role, 1, ".mp3", "audio");
|
|
189
187
|
write(deps, projectId, name, spoken.bytes);
|
|
190
|
-
// Measured the same way the body is, because
|
|
191
|
-
//
|
|
188
|
+
// Measured the same way the body is, because the render adds all three and the gaps to
|
|
189
|
+
// get the length of the video.
|
|
192
190
|
const durationMs = await probeDurationMs(deps.ffmpeg, outputPath(deps.paths, projectId, name), context.signal, deps.log);
|
|
193
191
|
context.signal.throwIfAborted();
|
|
194
192
|
store(deps, projectId, role, name, durationMs, choice);
|
|
195
193
|
counted(deps, segment.category, durationMs, choice);
|
|
196
194
|
}
|
|
197
195
|
}
|
|
198
|
-
// The segment pieces belong to the *article* stage, which is where
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
196
|
+
// The segment pieces belong to the *article* stage, which is where they are written, so
|
|
197
|
+
// they are read under its id and never under this stage's own. They stay there because the
|
|
198
|
+
// re-run rules depend on it: a Re-run audio, and the audio re-run an article edit cascades
|
|
199
|
+
// into, both clear the audio stage's pieces, and only the article stage ever writes an
|
|
200
|
+
// entry text - so an intro kept on this stage's row would be thrown away by the first
|
|
201
|
+
// re-narration and never written again.
|
|
204
202
|
function segmentPieces(deps, projectId) {
|
|
205
203
|
const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
|
|
206
204
|
if (article === undefined) {
|
|
207
|
-
// Admission writes all six stage rows with the project
|
|
208
|
-
//
|
|
205
|
+
// Admission writes all six stage rows with the project, so a project without an article
|
|
206
|
+
// stage is a bug rather than a run the user configured.
|
|
209
207
|
throw new Error(`project ${projectId} has no article stage`);
|
|
210
208
|
}
|
|
211
209
|
return piecesOf(deps.db, article.id, "segment");
|
|
212
210
|
}
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
// none - the same reason `store` above leaves it off the output row.
|
|
211
|
+
// Audio seconds come from the measured duration per segment, never from the text's length:
|
|
212
|
+
// the real duration goes on the row and the video timeline is built from it. No model is
|
|
213
|
+
// named because the TTS port carries none - the same reason `store` above leaves it off.
|
|
217
214
|
function counted(deps, segment, durationMs, choice) {
|
|
218
215
|
deps.count("stage.completed", {
|
|
219
216
|
stage: "audio",
|
|
@@ -222,8 +219,8 @@ function counted(deps, segment, durationMs, choice) {
|
|
|
222
219
|
audioSeconds: durationMs / 1000,
|
|
223
220
|
});
|
|
224
221
|
}
|
|
225
|
-
//
|
|
226
|
-
//
|
|
222
|
+
// K of N chunks narrated, written as well as emitted, so a page opened mid-stage reads the
|
|
223
|
+
// count off the row rather than waiting for the next chunk.
|
|
227
224
|
function report(deps, context, done, total) {
|
|
228
225
|
setStageProgress(deps.db, context.stage.id, done, total);
|
|
229
226
|
context.emit({
|
|
@@ -249,7 +246,7 @@ function write(deps, projectId, path, bytes) {
|
|
|
249
246
|
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
250
247
|
writeFileSync(target, bytes, { mode: 0o600 });
|
|
251
248
|
}
|
|
252
|
-
//
|
|
249
|
+
// The provider and the voice are stored with the audio they made. The model is not:
|
|
253
250
|
// the TTS port carries no model, so every request went to the adapter's own, and writing
|
|
254
251
|
// the run's dropdown value here would record something that was never sent.
|
|
255
252
|
function store(deps, projectId, role, path, durationMs, choice) {
|