@gentbajko/slopify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,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
|
+
}
|