@gentbajko/slopify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/adapter-registry.js +68 -0
  4. package/dist/adapters/image/bytes.js +64 -0
  5. package/dist/adapters/image/fal.js +164 -0
  6. package/dist/adapters/image/openai.js +155 -0
  7. package/dist/adapters/image/replicate.js +183 -0
  8. package/dist/adapters/llm/claude-code.js +158 -0
  9. package/dist/adapters/llm/codex.js +126 -0
  10. package/dist/adapters/llm/openrouter.js +193 -0
  11. package/dist/adapters/llm/run-cli.js +134 -0
  12. package/dist/adapters/llm/sse-lines.js +46 -0
  13. package/dist/adapters/retry-after.js +21 -0
  14. package/dist/adapters/tts/cartesia.js +105 -0
  15. package/dist/adapters/tts/elevenlabs.js +107 -0
  16. package/dist/adapters/tts/openai.js +95 -0
  17. package/dist/edge/cli.js +36 -0
  18. package/dist/edge/events/hub.js +76 -0
  19. package/dist/edge/http/actions.js +125 -0
  20. package/dist/edge/http/app.js +72 -0
  21. package/dist/edge/http/entries.js +41 -0
  22. package/dist/edge/http/files.js +67 -0
  23. package/dist/edge/http/problem.js +65 -0
  24. package/dist/edge/http/projects.js +128 -0
  25. package/dist/edge/http/prompts.js +73 -0
  26. package/dist/edge/http/providers.js +62 -0
  27. package/dist/edge/http/settings.js +90 -0
  28. package/dist/edge/http/staging.js +104 -0
  29. package/dist/edge/http/telemetry.js +29 -0
  30. package/dist/edge/http/usage.js +19 -0
  31. package/dist/edge/open-browser.js +20 -0
  32. package/dist/kernel/clock.js +18 -0
  33. package/dist/kernel/config/index.js +32 -0
  34. package/dist/kernel/db/index.js +24 -0
  35. package/dist/kernel/db/migrate.js +59 -0
  36. package/dist/kernel/db/migrations/0001-init.sql +17 -0
  37. package/dist/kernel/db/tx.js +53 -0
  38. package/dist/kernel/events.js +1 -0
  39. package/dist/kernel/ids.js +4 -0
  40. package/dist/kernel/lock.js +73 -0
  41. package/dist/kernel/log.js +34 -0
  42. package/dist/kernel/paths.js +21 -0
  43. package/dist/kernel/pipeline.js +22 -0
  44. package/dist/kernel/ports/image.js +1 -0
  45. package/dist/kernel/ports/llm.js +1 -0
  46. package/dist/kernel/ports/model.js +34 -0
  47. package/dist/kernel/ports/registry.js +1 -0
  48. package/dist/kernel/ports/tts.js +1 -0
  49. package/dist/kernel/runner/attempt-repo.js +50 -0
  50. package/dist/kernel/runner/attempt.js +146 -0
  51. package/dist/kernel/runner/graph.js +82 -0
  52. package/dist/kernel/runner/index.js +205 -0
  53. package/dist/kernel/runner/piece-repo.js +58 -0
  54. package/dist/kernel/runner/providers.js +92 -0
  55. package/dist/kernel/version.js +14 -0
  56. package/dist/main.js +212 -0
  57. package/dist/slices/admission/model.js +6 -0
  58. package/dist/slices/admission/repo.js +178 -0
  59. package/dist/slices/admission/rules.js +201 -0
  60. package/dist/slices/admission/start.js +103 -0
  61. package/dist/slices/admission/substitute.js +80 -0
  62. package/dist/slices/article/continuation.js +81 -0
  63. package/dist/slices/article/plain.js +14 -0
  64. package/dist/slices/article/run.js +166 -0
  65. package/dist/slices/article/split.js +51 -0
  66. package/dist/slices/article/store.js +21 -0
  67. package/dist/slices/cancel/index.js +61 -0
  68. package/dist/slices/images/run.js +221 -0
  69. package/dist/slices/library/lint.js +53 -0
  70. package/dist/slices/library/model.js +13 -0
  71. package/dist/slices/library/repo.js +111 -0
  72. package/dist/slices/library/save.js +87 -0
  73. package/dist/slices/library/slots.js +86 -0
  74. package/dist/slices/narration/chunk.js +71 -0
  75. package/dist/slices/narration/concat.js +79 -0
  76. package/dist/slices/narration/run.js +280 -0
  77. package/dist/slices/reruns/cascade.js +66 -0
  78. package/dist/slices/reruns/index.js +269 -0
  79. package/dist/slices/research/planner.js +81 -0
  80. package/dist/slices/research/run.js +192 -0
  81. package/dist/slices/research/synthesis.js +48 -0
  82. package/dist/slices/settings/cli-status.js +37 -0
  83. package/dist/slices/settings/keys.js +55 -0
  84. package/dist/slices/settings/model.js +54 -0
  85. package/dist/slices/settings/playback.js +60 -0
  86. package/dist/slices/settings/readiness.js +21 -0
  87. package/dist/slices/settings/repo.js +59 -0
  88. package/dist/slices/settings/voices.js +48 -0
  89. package/dist/slices/storage/asset-name.js +16 -0
  90. package/dist/slices/storage/delete-project.js +35 -0
  91. package/dist/slices/storage/downloads.js +100 -0
  92. package/dist/slices/storage/layout.js +59 -0
  93. package/dist/slices/storage/model.js +20 -0
  94. package/dist/slices/storage/reconcile.js +102 -0
  95. package/dist/slices/storage/repo.js +104 -0
  96. package/dist/slices/storage/staging.js +224 -0
  97. package/dist/slices/telemetry/collector-client.js +51 -0
  98. package/dist/slices/telemetry/flush.js +111 -0
  99. package/dist/slices/telemetry/machine.js +33 -0
  100. package/dist/slices/telemetry/model.js +39 -0
  101. package/dist/slices/telemetry/record.js +41 -0
  102. package/dist/slices/telemetry/repo.js +73 -0
  103. package/dist/slices/telemetry/usage.js +82 -0
  104. package/dist/slices/thumbnail/by-llm.js +30 -0
  105. package/dist/slices/thumbnail/run.js +200 -0
  106. package/dist/slices/video/ffmpeg.js +230 -0
  107. package/dist/slices/video/plan.js +71 -0
  108. package/dist/slices/video/run.js +179 -0
  109. package/dist/web/app-icon.svg +1 -0
  110. package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  111. package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  112. package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
  113. package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
  114. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  115. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  116. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
  117. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
  118. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  119. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  120. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
  121. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
  122. package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
  123. package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
  124. package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
  125. package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
  126. package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
  127. package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
  128. package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
  129. package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
  130. package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
  131. package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
  132. package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
  133. package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
  134. package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
  135. package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
  136. package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
  137. package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
  138. package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
  139. package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
  140. package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
  141. package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
  142. package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
  143. package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
  144. package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
  145. package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
  146. package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
  147. package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
  148. package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
  149. package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
  150. package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
  151. package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
  152. package/dist/web/assets/index-BPqQnrpy.css +1 -0
  153. package/dist/web/assets/index-D_sWbKQi.js +81 -0
  154. package/dist/web/favicon.svg +1 -0
  155. package/dist/web/index.html +15 -0
  156. package/package.json +41 -0
@@ -0,0 +1,82 @@
1
+ export function usageOf(input) {
2
+ return {
3
+ machineId: input.machineId,
4
+ appVersion: input.appVersion,
5
+ counters: counters(input.events),
6
+ byStage: byStage(input.events),
7
+ };
8
+ }
9
+ function counters(events) {
10
+ let videosMade = 0;
11
+ let audioSeconds = 0;
12
+ let imagesMade = 0;
13
+ let tokensUsed = 0;
14
+ let projects = 0;
15
+ for (const event of events) {
16
+ if (event.type === "project.created") {
17
+ projects += 1;
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.
21
+ if (event.payload.stage === "video") {
22
+ videosMade += 1;
23
+ }
24
+ audioSeconds += event.payload.audioSeconds ?? 0;
25
+ imagesMade += event.payload.images ?? 0;
26
+ tokensUsed += (event.payload.tokensIn ?? 0) + (event.payload.tokensOut ?? 0);
27
+ }
28
+ return { videosMade, audioSeconds, imagesMade, tokensUsed, projects };
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.
33
+ //
34
+ // ceiling: this is a per-stage table, not a per-model one. The user deferred the per-model
35
+ // breakdown on the public counters on 2026-09-03; the payload already carries the provider
36
+ // and the model, so it stays a grouping change when it is asked for.
37
+ function byStage(events) {
38
+ const rows = new Map();
39
+ for (const event of events) {
40
+ const row = tokensOf(event.payload);
41
+ if (row === undefined) {
42
+ continue;
43
+ }
44
+ // JSON rather than a joined string: a model id is the provider's own and may hold
45
+ // any character, and two different rows must never collide into one.
46
+ const key = JSON.stringify([row.stage, row.provider, row.model]);
47
+ const seen = rows.get(key);
48
+ rows.set(key, seen === undefined
49
+ ? row
50
+ : {
51
+ ...seen,
52
+ tokensIn: seen.tokensIn + row.tokensIn,
53
+ tokensOut: seen.tokensOut + row.tokensOut,
54
+ });
55
+ }
56
+ // Sorted by tokens out, as the screen asks. The tiebreaks below it are there so the same
57
+ // log always produces the same table: a rundown that reshuffles between reads is
58
+ // unreadable, and Map insertion order would follow whichever stage happened to finish
59
+ // first in a fan-out.
60
+ return [...rows.values()].toSorted((left, right) => right.tokensOut - left.tokensOut ||
61
+ right.tokensIn - left.tokensIn ||
62
+ left.stage.localeCompare(right.stage) ||
63
+ left.provider.localeCompare(right.provider) ||
64
+ (left.model ?? "").localeCompare(right.model ?? ""));
65
+ }
66
+ function tokensOf(payload) {
67
+ const tokensIn = payload.tokensIn ?? 0;
68
+ const tokensOut = payload.tokensOut ?? 0;
69
+ if (payload.stage === undefined || payload.provider === undefined) {
70
+ return undefined;
71
+ }
72
+ if (tokensIn + tokensOut === 0) {
73
+ return undefined;
74
+ }
75
+ return {
76
+ stage: payload.stage,
77
+ provider: payload.provider,
78
+ model: payload.model ?? null,
79
+ tokensIn,
80
+ tokensOut,
81
+ };
82
+ }
@@ -0,0 +1,30 @@
1
+ export function thumbnailMessages(brief) {
2
+ const values = Object.entries(brief.values);
3
+ return [
4
+ {
5
+ role: "user",
6
+ content: [
7
+ brief.instruction,
8
+ "",
9
+ `Video title: ${brief.title}`,
10
+ "",
11
+ "Keyword values for this run:",
12
+ "",
13
+ values.length === 0
14
+ ? "(none)"
15
+ : values.map(([name, value]) => `${name}: ${value}`).join("\n"),
16
+ "",
17
+ `Aspect ratio of the thumbnail: ${brief.format}`,
18
+ "",
19
+ "The article this video narrates:",
20
+ "",
21
+ brief.article,
22
+ ].join("\n"),
23
+ },
24
+ ];
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.
28
+ export function writtenPrompt(text) {
29
+ return text.trim() === "" ? "the LLM wrote no thumbnail prompt" : undefined;
30
+ }
@@ -0,0 +1,200 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
5
+ import { projectById } from "../admission/repo.js";
6
+ import { outputFileName, outputPath } from "../storage/layout.js";
7
+ import { insertOutput, outputsOf } from "../storage/repo.js";
8
+ import { storeText } from "../storage/staging.js";
9
+ import { noTokens, plusUsage } from "../telemetry/model.js";
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.
14
+ const writtenPayload = z.object({ prompt: z.string(), sent: z.string() });
15
+ export async function runThumbnail(deps, context, providers) {
16
+ const { projectId } = context.stage;
17
+ const project = projectById(deps.db, projectId);
18
+ if (project === undefined) {
19
+ throw new Error(`project ${projectId} has no row`);
20
+ }
21
+ const source = project.config.sources.thumbnail;
22
+ if (source !== "from_prompt" && source !== "prompt_by_llm") {
23
+ // Off is `skipped` and Provide is `provided` at project creation (`logic/01` step 1),
24
+ // so the runner never starts this stage for either.
25
+ throw new Error(`the thumbnail stage cannot run with its source set to ${source}`);
26
+ }
27
+ const choice = project.config.images;
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.
31
+ throw new Error("the run has no image provider or model");
32
+ }
33
+ const written = source === "from_prompt" ? undefined : await byLlm(deps, context, providers, project);
34
+ const prompt = written?.prompt ?? fromTemplate(project);
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.
42
+ deps.count("stage.completed", {
43
+ stage: "thumbnail",
44
+ provider: written?.provider ?? choice.provider,
45
+ model: written?.model ?? choice.model,
46
+ ...(written?.tokens ?? noTokens),
47
+ thumbnails: 1,
48
+ });
49
+ deps.log.write("info", "thumbnail.done", {
50
+ projectId,
51
+ stage: "thumbnail",
52
+ detail: source === "from_prompt" ? "from the picked prompt" : "from the prompt the LLM wrote",
53
+ });
54
+ }
55
+ // `logic/09` step 4: the rendered thumbnail template goes to the image provider as it is.
56
+ function fromTemplate(project) {
57
+ const rendered = project.config.rendered.thumbnailPrompt;
58
+ if (rendered === undefined) {
59
+ throw new Error("the run has no rendered thumbnail prompt");
60
+ }
61
+ return rendered;
62
+ }
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.
66
+ async function byLlm(deps, context, providers, project) {
67
+ const kept = piecesOf(deps.db, context.stage.id, "prompt_written")[0];
68
+ const llm = project.config.llm;
69
+ if (kept !== undefined && kept.state === "done") {
70
+ // §Q82: the wording the user is looking at does not change under them. No call was
71
+ // made, so this run counts none - the run that wrote the prompt already did.
72
+ return {
73
+ prompt: payloadOf(kept).prompt,
74
+ ...(llm === undefined ? {} : named(llm)),
75
+ tokens: noTokens,
76
+ };
77
+ }
78
+ if (llm === undefined) {
79
+ // §Q81 makes the LLM row required when the thumbnail source is Prompt by LLM.
80
+ throw new Error("the run has no LLM provider or model");
81
+ }
82
+ const messages = thumbnailMessages(brief(deps, project));
83
+ const answer = await providers.llm({
84
+ provider: llm.provider,
85
+ model: llm.model,
86
+ messages,
87
+ // §Q82: an empty answer is a failed attempt, and the wrapper is what retries it.
88
+ check: (given) => writtenPrompt(given.text),
89
+ });
90
+ // Step 3 and §Q83: the image prompt is exactly the LLM's output, never edited by the app.
91
+ const prompt = answer.text.trim();
92
+ keepWritten(deps, context, kept?.id, prompt, messages);
93
+ // The messages sent are stored beside the article stage's, per stage (`logic/14` step 2).
94
+ storeText(deps, {
95
+ projectId: context.stage.projectId,
96
+ stageKind: "thumbnail",
97
+ role: "instructions",
98
+ text: instructionsText(messages),
99
+ });
100
+ return { prompt, ...named(llm), tokens: plusUsage(noTokens, answer.usage) };
101
+ }
102
+ function named(choice) {
103
+ return { provider: choice.provider, model: choice.model };
104
+ }
105
+ function brief(deps, project) {
106
+ const instruction = project.config.rendered.thumbnailPrompt;
107
+ if (instruction === undefined) {
108
+ throw new Error("the run has no rendered thumbnail prompt");
109
+ }
110
+ return {
111
+ instruction,
112
+ title: project.title,
113
+ values: project.config.values,
114
+ format: project.format,
115
+ article: articleText(deps, project.id),
116
+ };
117
+ }
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.
121
+ function articleText(deps, projectId) {
122
+ const article = outputsOf(deps.db, projectId).find((output) => output.role === "article_txt");
123
+ if (article === undefined) {
124
+ throw new Error("the project has no article for the thumbnail prompt to be written from");
125
+ }
126
+ return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
127
+ }
128
+ // The one row is keyed by `idx` within the stage, so a stage that runs again replaces what
129
+ // it wrote rather than colliding with it.
130
+ function keepWritten(deps, context, existing, prompt, messages) {
131
+ const payload = JSON.stringify({ prompt, sent: instructionsText(messages) });
132
+ if (existing === undefined) {
133
+ insertPiece(deps.db, {
134
+ id: deps.ids.next(),
135
+ stageId: context.stage.id,
136
+ kind: "prompt_written",
137
+ idx: 1,
138
+ state: "done",
139
+ payload,
140
+ });
141
+ return;
142
+ }
143
+ setPiece(deps.db, existing, "done", payload);
144
+ }
145
+ // `logic/09` step 4: one call, the same aspect rule as the slideshow, stored apart from it
146
+ // with the prompt text, the provider and the model.
147
+ async function make(deps, context, providers, project, choice, prompt) {
148
+ const { projectId } = context.stage;
149
+ 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.
151
+ return;
152
+ }
153
+ const made = await providers.image({
154
+ provider: choice.provider,
155
+ model: choice.model,
156
+ prompt,
157
+ aspect: project.format,
158
+ });
159
+ const name = outputFileName("thumbnail", 1, made.mime === "image/png" ? ".png" : ".jpg", "thumbnail");
160
+ 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.
163
+ context.signal.throwIfAborted();
164
+ const output = {
165
+ id: deps.ids.next(),
166
+ projectId,
167
+ stageKind: "thumbnail",
168
+ role: "thumbnail",
169
+ path: name,
170
+ originalFilename: null,
171
+ bytes: made.bytes.byteLength,
172
+ 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.
175
+ meta: {
176
+ promptName: project.config.thumbnailPrompt ?? "",
177
+ prompt,
178
+ provider: choice.provider,
179
+ model: choice.model,
180
+ },
181
+ createdAt: deps.clock.now().toISOString(),
182
+ };
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.
185
+ insertOutput(deps.db, output);
186
+ }
187
+ function write(deps, projectId, name, made) {
188
+ const target = outputPath(deps.paths, projectId, name);
189
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
190
+ writeFileSync(target, made.bytes, { mode: 0o600 });
191
+ }
192
+ function instructionsText(messages) {
193
+ return `=== Thumbnail prompt ===\n\n${messages.map((message) => message.content).join("\n\n")}\n`;
194
+ }
195
+ function payloadOf(piece) {
196
+ if (piece.payload === null) {
197
+ throw new Error(`the written thumbnail prompt ${piece.id} has no payload`);
198
+ }
199
+ return writtenPayload.parse(JSON.parse(piece.payload));
200
+ }
@@ -0,0 +1,230 @@
1
+ import { spawn } from "node:child_process";
2
+ import { zoomBy, zoomFrom, zoomTo } from "./plan.js";
3
+ // Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
4
+ // and a wrapper would hide it. Every value goes into an argument array, never a shell
5
+ // string, so a title or a filename cannot become a command.
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%.
9
+ const prescale = 4;
10
+ const sampleRate = 44100;
11
+ const channelLayout = "stereo";
12
+ // `bundled` is ffmpeg-static's export. It is typed as unknown because the package is
13
+ // CommonJS with `module.exports = <path or null>` while its shipped .d.ts declares an ES
14
+ // default, which node16 resolution cannot map onto one another; narrowing here is
15
+ // cheaper and more honest than overriding someone else's types.
16
+ export function resolveFfmpeg(env, bundled) {
17
+ const override = env.SLOPIFY_FFMPEG?.trim();
18
+ if (override !== undefined && override !== "") {
19
+ return override;
20
+ }
21
+ if (typeof bundled === "string" && bundled !== "") {
22
+ return bundled;
23
+ }
24
+ // Never PATH: a Slopify that quietly rendered with whichever ffmpeg the machine
25
+ // happens to carry would not be testing the binary it ships with.
26
+ throw new Error("No ffmpeg binary is available. Slopify ships one through ffmpeg-static; if this " +
27
+ "platform has no build, point SLOPIFY_FFMPEG at an ffmpeg executable.");
28
+ }
29
+ export function renderArgs(plan) {
30
+ const inputs = [];
31
+ for (const slot of plan.images) {
32
+ inputs.push("-i", slot.path);
33
+ }
34
+ const audioAt = [];
35
+ for (const segment of plan.audio) {
36
+ audioAt.push(plan.images.length + audioAt.length);
37
+ if (segment.path === null) {
38
+ inputs.push("-f", "lavfi", "-t", seconds(segment.seconds), "-i", `anullsrc=r=${sampleRate}:cl=${channelLayout}`);
39
+ continue;
40
+ }
41
+ inputs.push("-i", segment.path);
42
+ }
43
+ return [
44
+ "-hide_banner",
45
+ "-nostdin",
46
+ "-loglevel",
47
+ "error",
48
+ // Progress on stdout keeps stderr free to carry only what went wrong.
49
+ "-progress",
50
+ "pipe:1",
51
+ "-nostats",
52
+ "-y",
53
+ ...inputs,
54
+ "-filter_complex",
55
+ filterGraph(plan, audioAt),
56
+ "-map",
57
+ "[v]",
58
+ "-map",
59
+ "[a]",
60
+ "-c:v",
61
+ "libx264",
62
+ "-pix_fmt",
63
+ "yuv420p",
64
+ "-c:a",
65
+ "aac",
66
+ "-movflags",
67
+ "+faststart",
68
+ plan.output,
69
+ ];
70
+ }
71
+ function filterGraph(plan, audioAt) {
72
+ const chains = [];
73
+ const wide = plan.width * prescale;
74
+ const tall = plan.height * prescale;
75
+ plan.images.forEach((slot, at) => {
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.
78
+ `scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
79
+ `zoompan=z='${zoomExpression(slot)}':d=${slot.frames}:` +
80
+ `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
81
+ `s=${plan.width}x${plan.height}:fps=${plan.fps},setsar=1[v${at}]`);
82
+ });
83
+ chains.push(`${plan.images.map((_slot, at) => `[v${at}]`).join("")}concat=n=${plan.images.length}:v=1:a=0[v]`);
84
+ audioAt.forEach((input, at) => {
85
+ // The segments come from different files and the silence from lavfi, so they are
86
+ // brought to one format before concat, which refuses to join mismatched streams.
87
+ chains.push(`[${input}:a]aformat=sample_fmts=fltp:sample_rates=${sampleRate}:channel_layouts=${channelLayout}[a${at}]`);
88
+ });
89
+ chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
90
+ return chains.join(";");
91
+ }
92
+ // §Q85: odd images 100% → 115%, even images 115% → 100%, linear over the slot. `on` is
93
+ // zoompan's output frame counter, 0 to d-1, and a one-frame slot has no span to divide
94
+ // by, so it holds the zoom it starts at.
95
+ function zoomExpression(slot) {
96
+ const span = slot.frames - 1;
97
+ if (slot.zoom === "in") {
98
+ return span < 1 ? String(zoomFrom) : `${zoomFrom}+${zoomBy}*on/${span}`;
99
+ }
100
+ return span < 1 ? String(zoomTo) : `${zoomTo}-${zoomBy}*on/${span}`;
101
+ }
102
+ function seconds(value) {
103
+ return value.toFixed(3);
104
+ }
105
+ // ffmpeg's `-progress` writes `out_time_ms` in microseconds, and has since long before
106
+ // it added the correctly named `out_time_us`; both keys carry the same number. Either
107
+ // reads `N/A` until the first frame is muxed.
108
+ const progressKey = /^out_time_(?:us|ms)=(\d+)$/;
109
+ export function progressMsOf(line) {
110
+ const matched = progressKey.exec(line.trim());
111
+ const microseconds = matched?.[1];
112
+ return microseconds === undefined ? undefined : Math.floor(Number(microseconds) / 1000);
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.
116
+ const stderrLines = 20;
117
+ export function runFfmpeg(run) {
118
+ return new Promise((resolve, reject) => {
119
+ if (run.signal.aborted) {
120
+ reject(new Error("the render was canceled before it started"));
121
+ return;
122
+ }
123
+ const child = spawn(run.bin, [...run.args], { stdio: ["ignore", "pipe", "pipe"] });
124
+ const errors = [];
125
+ let pending = "";
126
+ // A throw out of a stream listener is an uncaughtException, not a rejection: it would
127
+ // leave this promise unsettled forever while the child kept writing the output file.
128
+ // Reporting progress is advisory, so a caller that throws is logged and ignored.
129
+ const guarded = (what, work) => {
130
+ try {
131
+ work();
132
+ }
133
+ catch (error) {
134
+ run.log.write("warn", what, { detail: messageOf(error) });
135
+ }
136
+ };
137
+ const report = (line) => {
138
+ const elapsed = progressMsOf(line);
139
+ if (elapsed !== undefined) {
140
+ guarded("video.progress", () => {
141
+ run.onProgress(elapsed);
142
+ });
143
+ }
144
+ };
145
+ child.stdout.setEncoding("utf8");
146
+ child.stdout.on("data", (chunk) => {
147
+ guarded("video.progress.read", () => {
148
+ pending += chunk;
149
+ const lines = pending.split("\n");
150
+ pending = lines.pop() ?? "";
151
+ for (const line of lines) {
152
+ report(line);
153
+ }
154
+ });
155
+ });
156
+ child.stderr.setEncoding("utf8");
157
+ child.stderr.on("data", (chunk) => {
158
+ guarded("video.stderr.read", () => {
159
+ for (const line of chunk.split("\n")) {
160
+ if (line.trim() !== "") {
161
+ errors.push(line.trim());
162
+ }
163
+ }
164
+ errors.splice(0, Math.max(0, errors.length - stderrLines));
165
+ });
166
+ });
167
+ const stop = () => {
168
+ child.kill("SIGKILL");
169
+ };
170
+ run.signal.addEventListener("abort", stop, { once: true });
171
+ child.on("error", (error) => {
172
+ run.signal.removeEventListener("abort", stop);
173
+ reject(new Error(`ffmpeg could not be started: ${error.message}`));
174
+ });
175
+ child.on("close", (code, signal) => {
176
+ run.signal.removeEventListener("abort", stop);
177
+ // ffmpeg's last progress block need not end in a newline, and probeDurationMs
178
+ // derives its whole answer from these lines, so the remainder is read before the
179
+ // promise settles.
180
+ report(pending);
181
+ if (run.signal.aborted) {
182
+ reject(new Error("the render was canceled"));
183
+ return;
184
+ }
185
+ if (code === 0) {
186
+ resolve();
187
+ return;
188
+ }
189
+ reject(new Error(failure(code, signal, errors)));
190
+ });
191
+ });
192
+ }
193
+ // The duration the plan is built from, and the one a test reads back off the finished
194
+ // mp4. Taken from a full decode rather than the container header, because a provided
195
+ // variable-bitrate mp3 can carry a header duration that is only an estimate.
196
+ export async function probeDurationMs(bin, file, signal, log) {
197
+ let last = 0;
198
+ await runFfmpeg({
199
+ bin,
200
+ args: [
201
+ "-hide_banner",
202
+ "-nostdin",
203
+ "-loglevel",
204
+ "error",
205
+ "-progress",
206
+ "pipe:1",
207
+ "-nostats",
208
+ "-i",
209
+ file,
210
+ "-f",
211
+ "null",
212
+ "-",
213
+ ],
214
+ signal,
215
+ log,
216
+ onProgress: (elapsedMs) => {
217
+ last = Math.max(last, elapsedMs);
218
+ },
219
+ });
220
+ return last;
221
+ }
222
+ function messageOf(error) {
223
+ return error instanceof Error ? error.message : String(error);
224
+ }
225
+ function failure(code, signal, errors) {
226
+ const ended = code === null ? `killed by ${signal ?? "a signal"}` : `exited with code ${code}`;
227
+ return errors.length === 0
228
+ ? `ffmpeg ${ended} and wrote nothing to its error stream`
229
+ : `ffmpeg ${ended}: ${errors.join(" / ")}`;
230
+ }
@@ -0,0 +1,71 @@
1
+ // logic/11 step 5: 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
2
+ export const fps = 30;
3
+ const frames = {
4
+ "16:9": { width: 1920, height: 1080 },
5
+ "9:16": { width: 1080, height: 1920 },
6
+ };
7
+ // §Q85: 100% → 115%, linear, centred, alternating per image. The three are written out
8
+ // rather than derived from one another, because they are formatted into an ffmpeg
9
+ // expression and 1.15 - 1 is not 0.15 in binary floating point.
10
+ export const zoomFrom = 1;
11
+ export const zoomTo = 1.15;
12
+ export const zoomBy = 0.15;
13
+ export function planRender(input) {
14
+ if (input.images.length === 0) {
15
+ // logic/04 §Q31 makes an image source mandatory, so an empty set is a bug upstream.
16
+ throw new Error("a render needs at least one image");
17
+ }
18
+ const frame = frames[input.format];
19
+ const audio = timeline(input);
20
+ const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
21
+ const totalFrames = Math.max(input.images.length, Math.round(totalSeconds * fps));
22
+ return {
23
+ width: frame.width,
24
+ height: frame.height,
25
+ fps,
26
+ gapSeconds: input.gapSeconds,
27
+ audio,
28
+ images: slots(input.images, totalFrames),
29
+ totalFrames,
30
+ totalSeconds,
31
+ output: input.output,
32
+ };
33
+ }
34
+ // logic/11 step 1: intro, gap, body, gap, outro, with a gap only where the segment on
35
+ // the other side of it exists.
36
+ function timeline(input) {
37
+ const segments = [];
38
+ const gap = { kind: "gap", path: null, seconds: input.gapSeconds };
39
+ // A gap shorter than one frame is no gap. The renderer formats a segment's length to
40
+ // milliseconds, so anything under that would become `-t 0.000`: an anullsrc input with
41
+ // no samples that still had to be counted in the audio concat.
42
+ const audible = input.gapSeconds >= 1 / fps;
43
+ if (input.intro !== undefined) {
44
+ segments.push({ kind: "intro", path: input.intro.path, seconds: input.intro.seconds });
45
+ if (audible) {
46
+ segments.push(gap);
47
+ }
48
+ }
49
+ segments.push({ kind: "body", path: input.body.path, seconds: input.body.seconds });
50
+ if (input.outro !== undefined) {
51
+ if (audible) {
52
+ segments.push(gap);
53
+ }
54
+ segments.push({ kind: "outro", path: input.outro.path, seconds: input.outro.seconds });
55
+ }
56
+ return segments;
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.
62
+ function slots(paths, totalFrames) {
63
+ const each = Math.max(1, Math.floor(totalFrames / paths.length));
64
+ return paths.map((path, at) => ({
65
+ path,
66
+ index: at + 1,
67
+ frames: at === paths.length - 1 ? Math.max(1, totalFrames - each * at) : each,
68
+ // §Q85: odd images zoom in, even images zoom out.
69
+ zoom: at % 2 === 0 ? "in" : "out",
70
+ }));
71
+ }