@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.
Files changed (94) hide show
  1. package/README.md +2 -2
  2. package/dist/adapter-registry.js +11 -12
  3. package/dist/adapters/image/bytes.js +1 -1
  4. package/dist/adapters/image/fal.js +28 -41
  5. package/dist/adapters/image/openai.js +19 -24
  6. package/dist/adapters/image/replicate.js +33 -43
  7. package/dist/adapters/llm/claude-code.js +16 -19
  8. package/dist/adapters/llm/codex.js +14 -15
  9. package/dist/adapters/llm/openrouter.js +13 -16
  10. package/dist/adapters/llm/run-cli.js +9 -11
  11. package/dist/adapters/llm/sse-lines.js +1 -2
  12. package/dist/adapters/retry-after.js +6 -6
  13. package/dist/adapters/tts/cartesia.js +5 -5
  14. package/dist/adapters/tts/elevenlabs.js +14 -19
  15. package/dist/adapters/tts/openai.js +9 -10
  16. package/dist/edge/cli.js +0 -0
  17. package/dist/edge/http/actions.js +8 -9
  18. package/dist/edge/http/app.js +3 -3
  19. package/dist/edge/http/entries.js +1 -1
  20. package/dist/edge/http/projects.js +7 -7
  21. package/dist/edge/http/prompts.js +6 -6
  22. package/dist/edge/http/providers.js +4 -4
  23. package/dist/edge/http/settings.js +2 -2
  24. package/dist/edge/http/staging.js +1 -1
  25. package/dist/edge/http/telemetry.js +6 -7
  26. package/dist/edge/http/usage.js +5 -5
  27. package/dist/kernel/config/index.js +1 -1
  28. package/dist/kernel/db/tx.js +9 -12
  29. package/dist/kernel/log.js +5 -5
  30. package/dist/kernel/pipeline.js +7 -9
  31. package/dist/kernel/ports/model.js +3 -4
  32. package/dist/kernel/runner/attempt-repo.js +5 -6
  33. package/dist/kernel/runner/attempt.js +16 -23
  34. package/dist/kernel/runner/graph.js +18 -29
  35. package/dist/kernel/runner/index.js +27 -33
  36. package/dist/kernel/runner/piece-repo.js +9 -11
  37. package/dist/kernel/runner/providers.js +5 -7
  38. package/dist/kernel/version.js +3 -3
  39. package/dist/main.js +10 -11
  40. package/dist/slices/admission/model.js +2 -2
  41. package/dist/slices/admission/repo.js +6 -7
  42. package/dist/slices/admission/rules.js +12 -15
  43. package/dist/slices/admission/start.js +7 -8
  44. package/dist/slices/admission/substitute.js +11 -11
  45. package/dist/slices/article/continuation.js +9 -9
  46. package/dist/slices/article/plain.js +5 -5
  47. package/dist/slices/article/run.js +25 -25
  48. package/dist/slices/article/split.js +1 -1
  49. package/dist/slices/article/store.js +5 -5
  50. package/dist/slices/cancel/index.js +8 -8
  51. package/dist/slices/images/run.js +32 -34
  52. package/dist/slices/library/lint.js +3 -3
  53. package/dist/slices/library/model.js +7 -7
  54. package/dist/slices/library/repo.js +3 -3
  55. package/dist/slices/library/save.js +7 -7
  56. package/dist/slices/library/slots.js +5 -5
  57. package/dist/slices/narration/chunk.js +12 -12
  58. package/dist/slices/narration/concat.js +12 -13
  59. package/dist/slices/narration/run.js +46 -49
  60. package/dist/slices/reruns/cascade.js +10 -11
  61. package/dist/slices/reruns/index.js +36 -37
  62. package/dist/slices/research/planner.js +4 -5
  63. package/dist/slices/research/run.js +22 -23
  64. package/dist/slices/research/synthesis.js +5 -5
  65. package/dist/slices/settings/cli-status.js +9 -10
  66. package/dist/slices/settings/keys.js +8 -9
  67. package/dist/slices/settings/model.js +5 -5
  68. package/dist/slices/settings/readiness.js +3 -4
  69. package/dist/slices/settings/repo.js +1 -1
  70. package/dist/slices/settings/voices.js +3 -4
  71. package/dist/slices/storage/asset-name.js +2 -2
  72. package/dist/slices/storage/delete-project.js +3 -5
  73. package/dist/slices/storage/downloads.js +4 -4
  74. package/dist/slices/storage/layout.js +5 -6
  75. package/dist/slices/storage/model.js +1 -1
  76. package/dist/slices/storage/reconcile.js +6 -7
  77. package/dist/slices/storage/repo.js +1 -1
  78. package/dist/slices/storage/staging.js +12 -15
  79. package/dist/slices/telemetry/collector-client.js +2 -2
  80. package/dist/slices/telemetry/flush.js +8 -8
  81. package/dist/slices/telemetry/machine.js +6 -7
  82. package/dist/slices/telemetry/model.js +11 -13
  83. package/dist/slices/telemetry/record.js +7 -7
  84. package/dist/slices/telemetry/repo.js +4 -4
  85. package/dist/slices/telemetry/usage.js +5 -5
  86. package/dist/slices/thumbnail/by-llm.js +2 -2
  87. package/dist/slices/thumbnail/run.js +30 -32
  88. package/dist/slices/video/ffmpeg.js +6 -6
  89. package/dist/slices/video/plan.js +9 -9
  90. package/dist/slices/video/run.js +19 -20
  91. package/dist/web/assets/index-77hb2Mr0.js +81 -0
  92. package/dist/web/index.html +1 -1
  93. package/package.json +1 -1
  94. 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 of §Q73.
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 §Q75 lets the user delete an image and the
16
- // pieces that are left must still say where each one came from.
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
- // (`logic/04` step 2), so reaching here is a bug in admission rather than the user's.
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
- // `logic/04` §Q31 makes an image source mandatory and step 3 puts the Number at one
36
- // or more, so an empty plan is a bug upstream rather than a run with no pictures.
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
- // Step 1: the run's own frame. The adapter turns it into whatever its provider spells
40
- // the closest supported size, and `logic/11` step 4 crops whatever is left over.
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
- // logic/16 step 3: "images made (stored images)". Only the ones this run stored, so a
43
- // resume does not count an image the previous run made (§Q73) and a single regenerated
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. logic/16 step 2 puts one
48
- // event on the stage completing; the upgrade, if the drift ever matters, is an event
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
- // Step 2 with `logic/04` §Q30: "for each ticked image prompt, send its rendered text
63
- // Number times as independent parallel calls". The plan is made once and kept, so a retry
64
- // finds the rows the first run wrote and sends only what did not land (§Q73).
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 of §Q72 - prompts in selection order, then the index within
67
- // each prompt - so the order is fixed here and never depends on which image arrives first.
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 (`logic/03`).
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
- // §Q73: an image a previous run stored is not made again, and one that fails fails the
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 (§Q74); nothing here counts attempts.
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
- // §Q76: the prompt text, the prompt name, the index within the prompt, the
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
- // Step 3: the project page fills in as each one arrives.
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: `logic/13` §Q112 counts an aborted call as
143
- // nothing, and the resume runs a `pending` image exactly as a failed one.
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 §Q75 lets the user delete one on purpose. Answering "done" for a missing file would
160
- // hand the render a path to nothing.
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
- // Step 3: "stored as received (png or jpg)". The adapter read the mime off the bytes,
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 `logic/11` sorts on, and it is the piece's own `idx`
191
- // rather than the order the images came back in (§Q72).
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
- // `logic/01` §Q6: "k of N images made". Written as well as emitted, so a page opened
205
- // mid-stage reads the count off the row rather than waiting for the next image.
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 `logic/15` step 2 asks of a save: a name, a body, and no lint error. The slot
2
- // grammar is not restated here - it is admission/substitute.ts's, imported so the editor
3
- // and the run see the same parse of the same body.
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 `logic/15` governs: prompts of three kinds, and the
2
- // intro/outro entries. §Q121 puts one rule set over both, which is why the drafts, the
3
- // lint and the save path below are shared rather than mirrored.
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
- // mockup §Q6 and §Q13; the CHECK constraint on `prompts.kind` holds the same three.
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: `logic/15` sets no length for a body, and the §Q6 sample article prompt is
11
- // already a page long. This is a sanity bound on a trust boundary, not a product rule;
12
- // raising it is a one-line change with no schema consequence.
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
- // `logic/15` step 5: the lists on 04 Prompts and the pickers on Play sort by name.
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
- // §Q122: the kind may change after creation, so the update writes it like any other
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
- // §Q123: no foreign key points at this row, so a project that used the template keeps
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 (`logic/15`). One rule set covers prompts
2
- // and entries (§Q121), so both go through the same three outcomes.
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
- // §Q125: a save overwrites; there is no version history, so the row is replaced whole.
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
- // §Q122's uniqueness is the schema's: `prompts(kind, lower(name))` and
74
- // `entries(category, lower(name))`. A read-then-write check would answer from a row a
75
- // second writer could delete between the two statements, so the index decides and the
76
- // raw SQLite error never leaves this module.
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. `logic/04` §Q34 fixes the timing - the bodies are read at the click, not at
4
- // selection, so an edit made in between is the one that runs.
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
- // `logic/03` step 3: only the prompts of stages set to Generate, plus the picked
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 (`logic/10` step 1).
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
- // `logic/03` step 5 and step 6: every picked body rendered once, with the trimmed values
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
- // `logic/08` step 2, the run's chunking choice: "Whole text = one request; Per paragraph
2
- // = one request per paragraph; Every ~N words = consecutive chunks, each ending at the
3
- // last sentence boundary at or before N words, default N = 500 (§Q69)."
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: 03-conventions puts chunking with substitution
6
- // and the render plan in the functional core, so it is testable with no I/O at all and
7
- // `run.ts` does nothing but call it.
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 step 2's first case and the one that adds nothing the user did not ask for.
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
- // "the last sentence boundary at or before N words": the sentence that would push the
41
- // count past the budget starts the next chunk instead of being split.
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: §Q69 caps
52
- // nothing, and a cut inside a clause would be an audible pause the writer never wrote.
53
- // The provider's own per-request limit is what refuses it, as an error on the stage.
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
- // 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.
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. §Q68: the duration is measured, never
45
- // estimated, because `logic/11` builds the video timeline out of it.
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 (§Q67), so this is a bug.
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 `logic/11` step 1 adds this number to the
77
- // gaps to get the length of the video.
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
- // §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.
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 of §Q66.
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 (`logic/07` step 5).
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
- // §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`).
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
- // (`logic/04`), so reaching here is a bug in admission rather than something the user
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
- // 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.
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 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.
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 (§Q66).
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
- // 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.
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: `logic/13` §Q112 counts an aborted call as
129
- // nothing, and the resume runs a `pending` chunk exactly as a failed one.
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
- // Steps 4 and 6: one body file, in chunk order, with its measured duration on the row.
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
- // `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.
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
- // 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.
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; §Q66's resume keeps it.
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
- // §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.
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 `logic/11` step 1 adds all three and the
191
- // gaps to get the length of the video (§Q68).
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 `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.
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 (`logic/04` step 6), so a
208
- // project without an article stage is a bug rather than a run the user configured.
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
- // 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.
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
- // 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.
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
- // §Q68: the provider and the voice are stored with the audio they made. The model is not:
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) {