@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
@@ -8,7 +8,7 @@ export function outputPath(paths, projectId, relativePath) {
8
8
  export function stagingPath(paths, stagedFileId) {
9
9
  return contained(paths.staging, stagedFileId);
10
10
  }
11
- // Every asset of a project lives under its own folder (logic/14 §Q115); an id or a
11
+ // Every asset of a project lives under its own folder; an id or a
12
12
  // stored path that resolves anywhere else is a bug in whatever produced it.
13
13
  function contained(root, path) {
14
14
  const target = resolve(root, path);
@@ -18,11 +18,10 @@ function contained(root, path) {
18
18
  }
19
19
  return target;
20
20
  }
21
- // logic/14 step 2 fixes the names inside a project folder, so a provided file is
22
- // stored under the name its role dictates and keeps only its extension. The stage is part
23
- // of the name for the one role more than one stage produces: research and the article each
24
- // store what they sent (logic/06 step 4, logic/07 step 4), and the project page offers
25
- // them per stage ("Show instructions", uiux/screens/08-project).
21
+ // The names inside a project folder are fixed, so a provided file is stored under the name
22
+ // its role dictates and keeps only its extension. The stage is part of the name for the one
23
+ // role more than one stage produces: research and the article each store what they sent,
24
+ // and the project page offers them per stage under "Show instructions".
26
25
  export function outputFileName(role, index, extension, stageKind) {
27
26
  switch (role) {
28
27
  case "notes":
@@ -1,6 +1,6 @@
1
1
  export { stageKinds } from "../../kernel/pipeline.js";
2
2
  // The stages whose content arrives as a file. Research and article take pasted text
3
- // instead (logic/05 §Q37), and video is always generated (logic/01 step 5).
3
+ // instead, and video is always generated.
4
4
  export const uploadableStageKinds = ["audio", "images", "thumbnail"];
5
5
  export const outputRoles = [
6
6
  "notes",
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, rmSync, unlinkSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
3
  // Files the database does not know about, and staged uploads that never reached a
4
- // project, are removed at start (logic/05 §Q44, logic/14 step 5).
4
+ // project, are removed at start.
5
5
  export function reconcileStorage(db, paths) {
6
6
  const projects = idsOf(db, "SELECT id FROM projects", "id");
7
7
  const kept = new Set();
@@ -12,12 +12,11 @@ export function reconcileStorage(db, paths) {
12
12
  kept.add(`${projectId}/${slashed(path)}`);
13
13
  }
14
14
  }
15
- // A stage's unfinished work is on the project too: `logic/08` §Q66 keeps the audio
16
- // chunks a previous run finished so a manual retry re-runs only the failed ones, and
17
- // `logic/13` step 2 keeps them through a cancel. A chunk is not an output - the
18
- // concatenated body is the project's only body audio (§Q65) - so the file it wrote is
19
- // named by its `stage_pieces` payload instead, and that is as much of a record as an
20
- // outputs row.
15
+ // A stage's unfinished work is on the project too: the audio chunks a previous run
16
+ // finished are kept so a manual retry re-runs only the failed ones, and a cancel keeps
17
+ // them as well. A chunk is not an output - the concatenated body is the project's only
18
+ // body audio - so the file it wrote is named by its `stage_pieces` payload instead, and
19
+ // that is as much of a record as an outputs row.
21
20
  for (const row of db
22
21
  .prepare("SELECT stages.project_id AS project_id, stage_pieces.payload AS payload FROM stage_pieces JOIN stages ON stages.id = stage_pieces.stage_id WHERE stage_pieces.payload IS NOT NULL")
23
22
  .all()) {
@@ -42,7 +42,7 @@ export function outputById(db, id) {
42
42
  const row = db.prepare("SELECT * FROM outputs WHERE id = ?").get(id);
43
43
  return row === undefined ? undefined : toOutput(outputRow.parse(row));
44
44
  }
45
- // `logic/12` §Q106: an action replaces an output rather than versioning it. The row goes
45
+ // An action replaces an output rather than versioning it. The row goes
46
46
  // here; the file it names is the caller's to unlink once this has committed.
47
47
  export function deleteOutput(db, id) {
48
48
  db.prepare("DELETE FROM outputs WHERE id = ?").run(id);
@@ -37,13 +37,13 @@ export async function stageUpload(deps, input) {
37
37
  createdAt: deps.clock.now().toISOString(),
38
38
  };
39
39
  // The row exists before the first byte, so a process that dies mid-copy leaves a
40
- // record reconcile collects rather than an untracked file (logic/05 §Q44).
40
+ // record reconcile collects rather than an untracked file.
41
41
  insertStagedFile(deps.db, file);
42
42
  let bytes = 0;
43
43
  let lastEmit = 0;
44
44
  const notify = (event) => {
45
45
  // Nothing about progress delivery may abort the write: a page that navigated away
46
- // is not a reason to lose the upload it started (logic/05 §Q43).
46
+ // is not a reason to lose the upload it started.
47
47
  try {
48
48
  deps.emit(event);
49
49
  }
@@ -107,7 +107,7 @@ export function attachStagedFile(deps, input) {
107
107
  if (staged === undefined) {
108
108
  return { ok: false, reason: "unknown-staged-file" };
109
109
  }
110
- // A run never starts with provided content that is still copying (logic/05 §Q44).
110
+ // A run never starts with provided content that is still copying.
111
111
  if (staged.state !== "staged") {
112
112
  return { ok: false, reason: "still-copying" };
113
113
  }
@@ -134,10 +134,9 @@ export function attachStagedFile(deps, input) {
134
134
  meta: input.role === "image" ? { index } : {},
135
135
  createdAt: deps.clock.now().toISOString(),
136
136
  };
137
- // The file already moved. If this fails the file is under projects/ with no row,
138
- // which is exactly what the boot reconcile collects.
139
- // A savepoint rather than BEGIN, so startRun can attach several files inside the one
140
- // transaction that creates the project.
137
+ // The file already moved. If this fails the file is under projects/ with no row, which is
138
+ // exactly what the boot reconcile collects. A savepoint rather than BEGIN, so startRun can
139
+ // attach several files inside the one transaction that creates the project.
141
140
  try {
142
141
  transact(deps.db, () => {
143
142
  insertOutput(deps.db, output);
@@ -164,14 +163,12 @@ export function dropStagedSource(deps, source) {
164
163
  deps.log.write("warn", "staging.source", { detail: `${source}: ${messageOf(error)}` });
165
164
  }
166
165
  }
167
- // logic/05 step 1: a stage set to Provide can carry pasted text instead of a file, and
168
- // the project holds it as an output like any other. Unlike an attached upload this needs
169
- // no two-phase dance: the text came in on the request, so a rollback loses nothing the
170
- // caller cannot write again, and the file left under a project id that was rolled back
171
- // is an orphan the boot reconcile removes.
172
- // ceiling: the paste is stored as typed. logic/05 §Q37 also asks for markdown syntax to
173
- // be stripped for the narration source; that reduction belongs to the narration slice
174
- // that reads this file, and lands with it.
166
+ // A stage set to Provide can carry pasted text instead of a file, and the project holds it as
167
+ // an output like any other. Unlike an attached upload this needs no two-phase dance: the text
168
+ // came in on the request, so a rollback loses nothing the caller cannot write again, and the
169
+ // file left under a project id that was rolled back is an orphan the boot reconcile removes.
170
+ // ceiling: the paste is stored as typed. Markdown syntax still has to be stripped for the
171
+ // narration source; that reduction belongs to the narration slice that reads this file.
175
172
  export function storeText(deps, input) {
176
173
  const name = outputFileName(input.role, 1, ".txt", input.stageKind);
177
174
  const target = outputPath(deps.paths, input.projectId, name);
@@ -1,4 +1,4 @@
1
- // 07-operations: the collector URL is built into the release and is not user
1
+ // The collector URL is built into the release and is not user
2
2
  // configurable. The environment variable below is a test seam, not a documented knob:
3
3
  // packages/app/test/telemetry-flush.test.ts points it at a fake collector.
4
4
  export const defaultCollectorUrl = "https://collector.slopify.stream";
@@ -41,7 +41,7 @@ export function httpPostEvents(endpoint, timeoutMs) {
41
41
  }
42
42
  catch (error) {
43
43
  // Offline is the ordinary case, not a fault: the events stay queued and the user is
44
- // told nothing (logic/16 step 5).
44
+ // told nothing.
45
45
  return { ok: false, retriable: true, reason: messageOf(error) };
46
46
  }
47
47
  };
@@ -2,16 +2,16 @@ import { machineOf, markDelivered, undeliveredEvents } from "./repo.js";
2
2
  // ceiling: 200 events per request against the collector's cap of 500, and at most 25
3
3
  // requests per flush. A queue longer than 5000 undelivered events drains over several
4
4
  // flushes rather than in one burst, which is the back-pressure an unbounded queue
5
- // (logic/16 §Q134) needs against a collector that has been away for months.
5
+ // needs against a collector that has been away for months.
6
6
  export const batchSize = 200;
7
7
  const maxBatchesPerFlush = 25;
8
- // logic/16 step 5: the queue is flushed at app start and after each new event. Nothing
8
+ // The queue is flushed at app start and after each new event. Nothing
9
9
  // here touches a pipeline, and an unreachable collector costs one refused socket.
10
10
  export async function flush(deps) {
11
11
  const machine = machineOf(deps.db);
12
12
  if (machine === undefined) {
13
13
  // Before the notice is dismissed there is no machine id to send events under, and
14
- // logic/16 step 1 says nothing leaves until then.
14
+ // nothing leaves the machine until then.
15
15
  return { delivered: 0, dropped: 0 };
16
16
  }
17
17
  let delivered = 0;
@@ -30,7 +30,7 @@ export async function flush(deps) {
30
30
  }
31
31
  if (outcome.retriable) {
32
32
  // Offline, or the collector is having a bad day. The events stay queued and the
33
- // user is told nothing (logic/16 step 5).
33
+ // user is told nothing.
34
34
  deps.log.write("info", "telemetry.deferred", {
35
35
  detail: `${ids.length} events stay queued: ${outcome.reason}`,
36
36
  });
@@ -47,9 +47,9 @@ export async function flush(deps) {
47
47
  }
48
48
  return { delivered, dropped };
49
49
  }
50
- // The schedule from logic/16 step 5, debounced: a fan-out that finishes four stages at
51
- // once sends one request, not four. Every timer is unref'd, so a queued flush never
52
- // keeps the process alive.
50
+ // The flush schedule, debounced: a fan-out that finishes four stages at once sends one
51
+ // request, not four. Every timer is unref'd, so a queued flush never keeps the process
52
+ // alive.
53
53
  export function createFlusher(deps, delayMs) {
54
54
  let timer;
55
55
  let running = false;
@@ -74,7 +74,7 @@ export function createFlusher(deps, delayMs) {
74
74
  flush(deps)
75
75
  .catch((error) => {
76
76
  // The timer has no caller to fail: a broken flush is logged and the next one
77
- // tries again (logic/16 step 5).
77
+ // tries again.
78
78
  deps.log.write("warn", "telemetry.flush", { detail: messageOf(error) });
79
79
  })
80
80
  .finally(() => {
@@ -2,14 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
3
  import { writeEvent } from "./record.js";
4
4
  import { insertMachine, machineOf } from "./repo.js";
5
- // logic/16 step 1: the notice is shown when no machine id exists.
5
+ // The notice is shown when no machine id exists.
6
6
  export function noticeSeen(db) {
7
7
  return machineOf(db) !== undefined;
8
8
  }
9
- // logic/16 step 1 and mockup/02-first-run-notice: the id is created when the user
10
- // dismisses the notice, never at boot, so nothing is collected before the promise about
11
- // what is collected has been made. Deleting the data directory makes a new install
12
- // (§Q127); there is no reset control and no opt-out.
9
+ // The id is created when the user dismisses the notice, never at boot, so nothing is collected
10
+ // before the promise about what is collected has been made. Deleting the data directory makes a
11
+ // new install; there is no reset control and no opt-out.
13
12
  export function dismissNotice(deps) {
14
13
  const existing = machineOf(deps.db);
15
14
  if (existing !== undefined) {
@@ -25,8 +24,8 @@ export function dismissNotice(deps) {
25
24
  };
26
25
  transact(deps.db, () => {
27
26
  insertMachine(deps.db, machine);
28
- // logic/16 step 2: exactly one install event per machine. Written in the same
29
- // transaction as the row that makes it unique, so a machine cannot exist without it.
27
+ // Exactly one install event per machine. Written in the same transaction as the row that
28
+ // makes it unique, so a machine cannot exist without it.
30
29
  writeEvent(deps, "install", {});
31
30
  });
32
31
  return machine;
@@ -1,13 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds } from "../../kernel/pipeline.js";
3
- // logic/16 step 2: one install event, one per project created, one per stage completing.
3
+ // One install event, one per project created, one per stage completing.
4
4
  export const telemetryEventTypes = ["install", "project.created", "stage.completed"];
5
- // logic/16 step 2 counts finer than a stage: "one event per stage completing: research,
6
- // article, each intro/outro text, audio per segment (body, intro, outro), images,
7
- // thumbnail, video". The three that are not whole stages are named by `segment` beside
8
- // the stage that produced them - the article stage writes the intro and outro texts
9
- // (logic/07 step 5) and the audio stage narrates all three (logic/08 step 5) - so the
10
- // event type stays `stage.completed` and the pair (stage, segment) says which unit it is.
5
+ // Counting is finer than a stage: one event per research, article, intro/outro text, audio
6
+ // segment (body, intro, outro), images, thumbnail and video. The three that are not whole
7
+ // stages are named by `segment` beside the stage that produced them - the article stage
8
+ // writes the intro and outro texts, the audio stage narrates all three - so the event type
9
+ // stays `stage.completed` and the pair (stage, segment) says which unit it is.
11
10
  export const audioSegments = ["body", "intro", "outro"];
12
11
  export const noTokens = { tokensIn: 0, tokensOut: 0 };
13
12
  export function plusUsage(tokens, usage) {
@@ -16,12 +15,11 @@ export function plusUsage(tokens, usage) {
16
15
  tokensOut: tokens.tokensOut + (usage?.outputTokens ?? 0),
17
16
  };
18
17
  }
19
- // The whole privacy surface. logic/16 step 4 bars API keys, prompt bodies, keyword
20
- // values, titles, article and research text, files, filenames, OS, locale and hardware
21
- // from a payload, and mockup/02-first-run-notice promises the user exactly that. The
22
- // schema below is strict, so a caller that hands `record` anything not named here is
23
- // rejected before the row is written; model.test.ts pins the set and record.test.ts
24
- // sweeps every event type against it.
18
+ // The whole privacy surface. API keys, prompt bodies, keyword values, titles, article and
19
+ // research text, files, filenames, OS, locale and hardware are all barred from a payload,
20
+ // which is exactly what the first-run notice promises. The schema below is strict, so a
21
+ // caller that hands `record` anything not named here is rejected before the row is written;
22
+ // model.test.ts pins the set and record.test.ts sweeps every event type against it.
25
23
  export const payloadSchema = z.strictObject({
26
24
  appVersion: z.string().max(40),
27
25
  stage: z.enum(stageKinds).optional(),
@@ -16,15 +16,15 @@ export function writeEvent(deps, type, counters) {
16
16
  insertTelemetryEvent(deps.db, event);
17
17
  return event;
18
18
  }
19
- // logic/16 step 5: telemetry never blocks and never fails a pipeline stage. This is the
20
- // one place in the codebase where an error is swallowed rather than propagated - a
21
- // failure to count a finished render must not undo the render. It is logged, so the
22
- // failure is still visible in <data-dir>/logs, and nothing else in this module catches.
19
+ // Telemetry never blocks and never fails a pipeline stage. This is the one place in the
20
+ // codebase where an error is swallowed rather than propagated - a failure to count a finished
21
+ // render must not undo the render. It is logged, so the failure is still visible in
22
+ // <data-dir>/logs, and nothing else in this module catches.
23
23
  export function record(deps, type, counters) {
24
24
  try {
25
- // logic/16 step 2 precondition: a machine id exists for every event except the
26
- // install event that creates it. Before the notice is dismissed nothing is written,
27
- // so nothing about a run the user has not been told about can ever be sent.
25
+ // A machine id exists for every event except the install event that creates it. Before
26
+ // the notice is dismissed nothing is written, so nothing about a run the user has not
27
+ // been told about can ever be sent.
28
28
  if (type !== "install" && machineOf(deps.db) === undefined) {
29
29
  return;
30
30
  }
@@ -19,15 +19,15 @@ export function insertTelemetryEvent(db, event) {
19
19
  // recorded in the same millisecond carry the same timestamp and their ULIDs are random
20
20
  // within it, so a timestamp sort would hand back the install after the project it
21
21
  // preceded. The collector deduplicates by id, so re-sending the head of the queue after
22
- // an ambiguous failure is safe (logic/16 §Q134).
22
+ // an ambiguous failure is safe.
23
23
  export function undeliveredEvents(db, limit) {
24
24
  return db
25
25
  .prepare("SELECT * FROM telemetry_events WHERE delivered_at IS NULL ORDER BY rowid LIMIT ?")
26
26
  .all(limit)
27
27
  .map((row) => toEvent(eventRow.parse(row)));
28
28
  }
29
- // logic/16 step 6 and §Q132: the Usage page's totals are the sum of the whole local log,
30
- // delivered or not, and §Q134 keeps that log forever.
29
+ // The Usage page's totals are the sum of the whole local log, delivered or not, and that
30
+ // log is kept forever.
31
31
  //
32
32
  // ceiling: every row is read and folded in memory. One event per stage of one run is a
33
33
  // few dozen bytes and a machine would need a hundred thousand runs to make this cost a
@@ -45,7 +45,7 @@ export function markDelivered(db, ids, at) {
45
45
  const holes = ids.map(() => "?").join(", ");
46
46
  db.prepare(`UPDATE telemetry_events SET delivered_at = ? WHERE id IN (${holes})`).run(at, ...ids);
47
47
  }
48
- // 02-models: a single row, so the first one is the machine.
48
+ // A single row, so the first one is the machine.
49
49
  export function machineOf(db) {
50
50
  const row = db.prepare("SELECT * FROM machine LIMIT 1").get();
51
51
  return row === undefined ? undefined : toMachine(machineRow.parse(row));
@@ -16,8 +16,8 @@ function counters(events) {
16
16
  if (event.type === "project.created") {
17
17
  projects += 1;
18
18
  }
19
- // One event per completed render (logic/16 step 3), which is the same rule the
20
- // collector counts `videos_made` by, so the local page and the public board agree.
19
+ // One event per completed render, which is the same rule the collector counts `videos_made`
20
+ // by, so the local page and the public board agree.
21
21
  if (event.payload.stage === "video") {
22
22
  videosMade += 1;
23
23
  }
@@ -27,9 +27,9 @@ function counters(events) {
27
27
  }
28
28
  return { videosMade, audioSeconds, imagesMade, tokensUsed, projects };
29
29
  }
30
- // §Q128 counts tokens "with provider and model names, per stage", so the table groups by
31
- // all three. Rows with no tokens are left out: the table's two number columns would both
32
- // read zero, and the render and the image calls that produce them report no usage at all.
30
+ // Tokens are counted with provider and model names per stage, so the table groups by all
31
+ // three. Rows with no tokens are left out: the two number columns would both read zero, and
32
+ // the render and image calls that produce them report no usage at all.
33
33
  //
34
34
  // ceiling: this is a per-stage table, not a per-model one. The user deferred the per-model
35
35
  // breakdown on the public counters on 2026-09-03; the payload already carries the provider
@@ -23,8 +23,8 @@ export function thumbnailMessages(brief) {
23
23
  },
24
24
  ];
25
25
  }
26
- // §Q82: "empty output is a failed attempt". Returned as the sentence the stage would show
27
- // rather than thrown, because the wrapper's `check` hook is what makes it a retry.
26
+ // Empty output is a failed attempt. Returned as the sentence the stage would show rather than
27
+ // thrown, because the wrapper's `check` hook is what makes it a retry.
28
28
  export function writtenPrompt(text) {
29
29
  return text.trim() === "" ? "the LLM wrote no thumbnail prompt" : undefined;
30
30
  }
@@ -8,9 +8,9 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
8
8
  import { storeText } from "../storage/staging.js";
9
9
  import { noTokens, plusUsage } from "../telemetry/model.js";
10
10
  import { thumbnailMessages, writtenPrompt } from "./by-llm.js";
11
- // The `prompt_written` sub-step of §Q82, persisted for resume: the prompt the LLM wrote
12
- // and the messages that asked for it, so a resumed run reproduces the record without
13
- // asking again.
11
+ // The `prompt_written` sub-step, persisted for resume: the prompt the LLM wrote and the
12
+ // messages that asked for it, so a resumed run reproduces the record without asking
13
+ // again.
14
14
  const writtenPayload = z.object({ prompt: z.string(), sent: z.string() });
15
15
  export async function runThumbnail(deps, context, providers) {
16
16
  const { projectId } = context.stage;
@@ -20,25 +20,24 @@ export async function runThumbnail(deps, context, providers) {
20
20
  }
21
21
  const source = project.config.sources.thumbnail;
22
22
  if (source !== "from_prompt" && source !== "prompt_by_llm") {
23
- // Off is `skipped` and Provide is `provided` at project creation (`logic/01` step 1),
23
+ // Off is `skipped` and Provide is `provided` at project creation,
24
24
  // so the runner never starts this stage for either.
25
25
  throw new Error(`the thumbnail stage cannot run with its source set to ${source}`);
26
26
  }
27
27
  const choice = project.config.images;
28
28
  if (choice === undefined) {
29
- // Admission requires the image provider whenever the thumbnail is generated
30
- // (`logic/04` step 2), so reaching here is a bug in admission rather than the user's.
29
+ // Admission requires the image provider whenever the thumbnail is generated, so reaching
30
+ // 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 written = source === "from_prompt" ? undefined : await byLlm(deps, context, providers, project);
34
34
  const prompt = written?.prompt ?? fromTemplate(project);
35
35
  await make(deps, context, providers, project, choice, prompt);
36
- // logic/16 step 3 counts tokens "with provider and model names, per stage", and a
37
- // payload names one provider. This stage can use two - the LLM that writes the prompt
38
- // (`logic/10`) and the image model that draws it - so the event names the one whose
39
- // usage it reports, because provider and model are shown beside the token columns on
40
- // the Usage page. A thumbnail from a picked template makes no LLM call, so it names the
41
- // image model that made it and reports the zero of step 3.
36
+ // Tokens are counted with a provider and model name per stage, and a payload names one
37
+ // provider. This stage can use two - the LLM that writes the prompt and the image model
38
+ // that draws it - so the event names whichever one's usage it reports, because both are
39
+ // shown beside the token columns on the Usage page. A thumbnail from a picked template
40
+ // makes no LLM call, so it names the image model and reports zero tokens.
42
41
  deps.count("stage.completed", {
43
42
  stage: "thumbnail",
44
43
  provider: written?.provider ?? choice.provider,
@@ -52,7 +51,7 @@ export async function runThumbnail(deps, context, providers) {
52
51
  detail: source === "from_prompt" ? "from the picked prompt" : "from the prompt the LLM wrote",
53
52
  });
54
53
  }
55
- // `logic/09` step 4: the rendered thumbnail template goes to the image provider as it is.
54
+ // The rendered thumbnail template goes to the image provider as it is.
56
55
  function fromTemplate(project) {
57
56
  const rendered = project.config.rendered.thumbnailPrompt;
58
57
  if (rendered === undefined) {
@@ -60,14 +59,13 @@ function fromTemplate(project) {
60
59
  }
61
60
  return rendered;
62
61
  }
63
- // `logic/10` steps 1 to 3. §Q82: a written prompt already on the project is reused, so a
64
- // manual retry redoes only the image call and the wording the user is looking at does not
65
- // change under them.
62
+ // A written prompt already on the project is reused, so a manual retry redoes only the
63
+ // image call and the wording the user is looking at does not change under them.
66
64
  async function byLlm(deps, context, providers, project) {
67
65
  const kept = piecesOf(deps.db, context.stage.id, "prompt_written")[0];
68
66
  const llm = project.config.llm;
69
67
  if (kept !== undefined && kept.state === "done") {
70
- // §Q82: the wording the user is looking at does not change under them. No call was
68
+ // The wording the user is looking at does not change under them. No call was
71
69
  // made, so this run counts none - the run that wrote the prompt already did.
72
70
  return {
73
71
  prompt: payloadOf(kept).prompt,
@@ -76,7 +74,7 @@ async function byLlm(deps, context, providers, project) {
76
74
  };
77
75
  }
78
76
  if (llm === undefined) {
79
- // §Q81 makes the LLM row required when the thumbnail source is Prompt by LLM.
77
+ // Admission requires the LLM row when the thumbnail source is Prompt by LLM.
80
78
  throw new Error("the run has no LLM provider or model");
81
79
  }
82
80
  const messages = thumbnailMessages(brief(deps, project));
@@ -84,13 +82,13 @@ async function byLlm(deps, context, providers, project) {
84
82
  provider: llm.provider,
85
83
  model: llm.model,
86
84
  messages,
87
- // §Q82: an empty answer is a failed attempt, and the wrapper is what retries it.
85
+ // An empty answer is a failed attempt, and the wrapper is what retries it.
88
86
  check: (given) => writtenPrompt(given.text),
89
87
  });
90
- // Step 3 and §Q83: the image prompt is exactly the LLM's output, never edited by the app.
88
+ // The image prompt is exactly the LLM's output, never edited by the app.
91
89
  const prompt = answer.text.trim();
92
90
  keepWritten(deps, context, kept?.id, prompt, messages);
93
- // The messages sent are stored beside the article stage's, per stage (`logic/14` step 2).
91
+ // The messages sent are stored beside the article stage's, per stage.
94
92
  storeText(deps, {
95
93
  projectId: context.stage.projectId,
96
94
  stageKind: "thumbnail",
@@ -115,9 +113,9 @@ function brief(deps, project) {
115
113
  article: articleText(deps, project.id),
116
114
  };
117
115
  }
118
- // §Q79's invariant: the stage never starts without an article on the project. The runner's
119
- // graph holds the other half - the thumbnail waits on the article stage - so a project
120
- // with no article row here is a stage that started against a run that never wrote one.
116
+ // The stage never starts without an article on the project. The runner's graph holds the other
117
+ // half - the thumbnail waits on the article stage - so a project with no article row here is a
118
+ // stage that started against a run that never wrote one.
121
119
  function articleText(deps, projectId) {
122
120
  const article = outputsOf(deps.db, projectId).find((output) => output.role === "article_txt");
123
121
  if (article === undefined) {
@@ -142,12 +140,12 @@ function keepWritten(deps, context, existing, prompt, messages) {
142
140
  }
143
141
  setPiece(deps.db, existing, "done", payload);
144
142
  }
145
- // `logic/09` step 4: one call, the same aspect rule as the slideshow, stored apart from it
143
+ // One call, the same aspect rule as the slideshow, stored apart from it
146
144
  // with the prompt text, the provider and the model.
147
145
  async function make(deps, context, providers, project, choice, prompt) {
148
146
  const { projectId } = context.stage;
149
147
  if (outputsOf(deps.db, projectId).some((output) => output.role === "thumbnail")) {
150
- // §Q82's `image-done` sub-step: a retry that failed after the image landed keeps it.
148
+ // The `image-done` sub-step: a retry that failed after the image landed keeps it.
151
149
  return;
152
150
  }
153
151
  const made = await providers.image({
@@ -158,8 +156,8 @@ async function make(deps, context, providers, project, choice, prompt) {
158
156
  });
159
157
  const name = outputFileName("thumbnail", 1, made.mime === "image/png" ? ".png" : ".jpg", "thumbnail");
160
158
  write(deps, projectId, name, made);
161
- // `logic/13` §Q113 protects an output already stored, not one about to be: a cancel
162
- // landing between the write and the row leaves a file the boot reconcile collects.
159
+ // A cancel protects an output already stored, not one about to be: one landing between
160
+ // the write and the row leaves a file the boot reconcile collects.
163
161
  context.signal.throwIfAborted();
164
162
  const output = {
165
163
  id: deps.ids.next(),
@@ -170,8 +168,8 @@ async function make(deps, context, providers, project, choice, prompt) {
170
168
  originalFilename: null,
171
169
  bytes: made.bytes.byteLength,
172
170
  durationMs: null,
173
- // §Q76 and §Q80. There is no `index`: §Q72 keeps the thumbnail out of the slideshow,
174
- // and the render reads that list by role.
171
+ // There is no `index`: the thumbnail stays out of the slideshow, and the render reads
172
+ // that list by role.
175
173
  meta: {
176
174
  promptName: project.config.thumbnailPrompt ?? "",
177
175
  prompt,
@@ -180,8 +178,8 @@ async function make(deps, context, providers, project, choice, prompt) {
180
178
  },
181
179
  createdAt: deps.clock.now().toISOString(),
182
180
  };
183
- // No `image.landed`: §Q72 keeps the thumbnail out of the slideshow, and that event's
184
- // `index` is a place in it. The stage reaching `done` is what tells the page.
181
+ // No `image.landed`: the thumbnail stays out of the slideshow, and that event's `index`
182
+ // is a place in it. The stage reaching `done` is what tells the page.
185
183
  insertOutput(deps.db, output);
186
184
  }
187
185
  function write(deps, projectId, name, made) {
@@ -4,8 +4,8 @@ import { zoomBy, zoomFrom, zoomTo } from "./plan.js";
4
4
  // and a wrapper would hide it. Every value goes into an argument array, never a shell
5
5
  // string, so a title or a filename cannot become a command.
6
6
  // zoompan works on a still that has been pre-scaled, because it steps the zoom in
7
- // sub-pixel increments and a source at output resolution visibly jitters (logic/11 §Q85
8
- // asks for a smooth linear zoom). Four times the frame is enough at 115%.
7
+ // sub-pixel increments and a source at output resolution visibly jitters where the zoom is
8
+ // meant to be smooth and linear. Four times the frame is enough at 115%.
9
9
  const prescale = 4;
10
10
  const sampleRate = 44100;
11
11
  const channelLayout = "stereo";
@@ -74,7 +74,7 @@ function filterGraph(plan, audioAt) {
74
74
  const tall = plan.height * prescale;
75
75
  plan.images.forEach((slot, at) => {
76
76
  chains.push(`[${at}:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
77
- // logic/11 step 4: cover the frame and centre-crop, never letterbox.
77
+ // Cover the frame and centre-crop, never letterbox.
78
78
  `scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
79
79
  `zoompan=z='${zoomExpression(slot)}':d=${slot.frames}:` +
80
80
  `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
@@ -89,7 +89,7 @@ function filterGraph(plan, audioAt) {
89
89
  chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
90
90
  return chains.join(";");
91
91
  }
92
- // §Q85: odd images 100% → 115%, even images 115% → 100%, linear over the slot. `on` is
92
+ // Odd images 100% → 115%, even images 115% → 100%, linear over the slot. `on` is
93
93
  // zoompan's output frame counter, 0 to d-1, and a one-frame slot has no span to divide
94
94
  // by, so it holds the zoom it starts at.
95
95
  function zoomExpression(slot) {
@@ -111,8 +111,8 @@ export function progressMsOf(line) {
111
111
  const microseconds = matched?.[1];
112
112
  return microseconds === undefined ? undefined : Math.floor(Number(microseconds) / 1000);
113
113
  }
114
- // ceiling: the last 20 lines of stderr are kept. logic/11 §Q89 shows the renderer's error
115
- // verbatim, and ffmpeg's useful complaint is always at the end of what it wrote.
114
+ // ceiling: the last 20 lines of stderr are kept. The renderer's error is shown verbatim,
115
+ // and ffmpeg's useful complaint is always at the end of what it wrote.
116
116
  const stderrLines = 20;
117
117
  export function runFfmpeg(run) {
118
118
  return new Promise((resolve, reject) => {
@@ -1,10 +1,10 @@
1
- // logic/11 step 5: 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
1
+ // 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
2
2
  export const fps = 30;
3
3
  const frames = {
4
4
  "16:9": { width: 1920, height: 1080 },
5
5
  "9:16": { width: 1080, height: 1920 },
6
6
  };
7
- // §Q85: 100% → 115%, linear, centred, alternating per image. The three are written out
7
+ // 100% → 115%, linear, centred, alternating per image. The three are written out
8
8
  // rather than derived from one another, because they are formatted into an ffmpeg
9
9
  // expression and 1.15 - 1 is not 0.15 in binary floating point.
10
10
  export const zoomFrom = 1;
@@ -12,7 +12,7 @@ export const zoomTo = 1.15;
12
12
  export const zoomBy = 0.15;
13
13
  export function planRender(input) {
14
14
  if (input.images.length === 0) {
15
- // logic/04 §Q31 makes an image source mandatory, so an empty set is a bug upstream.
15
+ // Admission makes an image source mandatory, so an empty set is a bug upstream.
16
16
  throw new Error("a render needs at least one image");
17
17
  }
18
18
  const frame = frames[input.format];
@@ -31,7 +31,7 @@ export function planRender(input) {
31
31
  output: input.output,
32
32
  };
33
33
  }
34
- // logic/11 step 1: intro, gap, body, gap, outro, with a gap only where the segment on
34
+ // Intro, gap, body, gap, outro, with a gap only where the segment on
35
35
  // the other side of it exists.
36
36
  function timeline(input) {
37
37
  const segments = [];
@@ -55,17 +55,17 @@ function timeline(input) {
55
55
  }
56
56
  return segments;
57
57
  }
58
- // logic/11 step 2: the slot is the total divided by the image count, and the last image
59
- // absorbs the frame rounding. §Q100: one image fills the whole length.
60
- // ceiling: every slot holds at least one frame, so a run with more images than the
61
- // timeline has frames renders slightly longer than its audio rather than failing.
58
+ // The slot is the total divided by the image count, and the last image absorbs the frame
59
+ // rounding. One image fills the whole length. ceiling: every slot holds at least one frame, so
60
+ // a run with more images than the timeline has frames renders slightly longer than its audio
61
+ // rather than failing.
62
62
  function slots(paths, totalFrames) {
63
63
  const each = Math.max(1, Math.floor(totalFrames / paths.length));
64
64
  return paths.map((path, at) => ({
65
65
  path,
66
66
  index: at + 1,
67
67
  frames: at === paths.length - 1 ? Math.max(1, totalFrames - each * at) : each,
68
- // §Q85: odd images zoom in, even images zoom out.
68
+ // Odd images zoom in, even images zoom out.
69
69
  zoom: at % 2 === 0 ? "in" : "out",
70
70
  }));
71
71
  }