goosetools-worker 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -6
- package/package.json +3 -2
- package/src/fonts.js +102 -0
- package/src/normalize-photos.js +53 -0
- package/src/render.js +251 -22
- package/src/video.js +205 -0
- package/styles/graph-paper/templates/closer.html +47 -0
- package/styles/graph-paper/templates/cover.html +53 -0
- package/styles/graph-paper/templates/detail.html +56 -0
- package/worker/agent-cli.js +275 -0
- package/worker/cli.js +58 -2
- package/worker/index.js +542 -30
- package/worker/service.js +395 -0
- package/worker/skill.js +26 -0
- package/worker/style.js +250 -0
package/worker/index.js
CHANGED
|
@@ -4,19 +4,26 @@
|
|
|
4
4
|
// HTTPS with its worker token.
|
|
5
5
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { runAgent } from "./agent-cli.js";
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
8
10
|
import { join, dirname, resolve, extname } from "node:path";
|
|
9
11
|
import { fileURLToPath } from "node:url";
|
|
10
12
|
import { promisify } from "node:util";
|
|
11
13
|
import yaml from "js-yaml";
|
|
12
14
|
import { upload } from "@vercel/blob/client";
|
|
15
|
+
import { skillSection } from "./skill.js";
|
|
16
|
+
import { extFromUrl } from "./style.js";
|
|
17
|
+
import { FONT_SPECS, sanitizeFonts } from "../src/fonts.js";
|
|
18
|
+
import { assertFfmpeg, isVideoPath, posterFrame } from "../src/video.js";
|
|
13
19
|
|
|
14
20
|
const execFileAsync = promisify(execFile);
|
|
15
21
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
22
|
|
|
17
|
-
// ── Config: flags > env > worker/.env
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
// ── Config: flags > env > worker/.env > ~/.goosetools/env ───────────────────
|
|
24
|
+
// The last one is written by `goosetools-worker install` (the background
|
|
25
|
+
// service) so the daemon needs no flags at all.
|
|
26
|
+
function parseEnvFile(p) {
|
|
20
27
|
if (!existsSync(p)) return {};
|
|
21
28
|
return Object.fromEntries(
|
|
22
29
|
readFileSync(p, "utf8")
|
|
@@ -25,7 +32,10 @@ function loadDotEnv() {
|
|
|
25
32
|
.map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
|
|
26
33
|
);
|
|
27
34
|
}
|
|
28
|
-
const dotenv =
|
|
35
|
+
const dotenv = {
|
|
36
|
+
...parseEnvFile(join(homedir(), ".goosetools", "env")),
|
|
37
|
+
...parseEnvFile(join(ROOT, "worker", ".env")),
|
|
38
|
+
};
|
|
29
39
|
function flag(name) {
|
|
30
40
|
const i = process.argv.indexOf(name);
|
|
31
41
|
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
@@ -33,7 +43,15 @@ function flag(name) {
|
|
|
33
43
|
const BASE_URL = (flag("--url") ?? process.env.GOOSETOOLS_URL ?? dotenv.GOOSETOOLS_URL ?? "https://goosetools.com").replace(/\/$/, "");
|
|
34
44
|
const TOKEN = flag("--token") ?? process.env.WORKER_TOKEN ?? dotenv.WORKER_TOKEN;
|
|
35
45
|
const VOICE_DIR = process.env.VOICE_DIR ?? dotenv.VOICE_DIR; // optional
|
|
36
|
-
|
|
46
|
+
// Fast for a stretch after the last job (someone is actively working), 60s
|
|
47
|
+
// once things go quiet — a flat 30s poll across four workers kept the prod
|
|
48
|
+
// database awake 24/7 and burned its whole compute quota.
|
|
49
|
+
const POLL_FAST_MS = 5_000;
|
|
50
|
+
const POLL_IDLE_MS = 60_000;
|
|
51
|
+
const STAY_FAST_MS = 2 * 60 * 1000;
|
|
52
|
+
let lastJobAt = 0;
|
|
53
|
+
const pollDelay = () =>
|
|
54
|
+
Date.now() - lastJobAt < STAY_FAST_MS ? POLL_FAST_MS : POLL_IDLE_MS;
|
|
37
55
|
|
|
38
56
|
if (!TOKEN) {
|
|
39
57
|
console.error("Missing worker token. Run with --token gt_... (get one at " + BASE_URL + "/dashboard/carousel)");
|
|
@@ -59,6 +77,85 @@ async function api(path, body) {
|
|
|
59
77
|
return res.json();
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
// ── Per-user brand ───────────────────────────────────────────────────────────
|
|
81
|
+
// The server sends the user's saved brand with each job; merge it over the
|
|
82
|
+
// repo brand.yml (so unset keys keep working) and drop it in the carousel dir
|
|
83
|
+
// where render.js prefers it.
|
|
84
|
+
// `apply` carries the per-carousel "use my brand font / color" checkboxes.
|
|
85
|
+
// They ride along in brand.yml because render.js reads that file to decide
|
|
86
|
+
// whether the brand or the style wins each key. Style jobs pass nothing and
|
|
87
|
+
// get the historical behavior (style wins).
|
|
88
|
+
export function writeBrandFile(dir, jobBrand, apply) {
|
|
89
|
+
const rootBrand = yaml.load(readFileSync(join(ROOT, "brand.yml"), "utf8"));
|
|
90
|
+
const merged = {
|
|
91
|
+
...rootBrand,
|
|
92
|
+
...(jobBrand ?? {}),
|
|
93
|
+
colors: { ...rootBrand.colors, ...(jobBrand?.colors ?? {}) },
|
|
94
|
+
fonts: { ...rootBrand.fonts, ...(jobBrand?.fonts ?? {}) },
|
|
95
|
+
style: { ...rootBrand.style, ...(jobBrand?.style ?? {}) },
|
|
96
|
+
...(apply
|
|
97
|
+
? { applyBrandFonts: apply.fonts === true, applyBrandColors: apply.colors === true }
|
|
98
|
+
: {}),
|
|
99
|
+
};
|
|
100
|
+
writeFileSync(join(dir, "brand.yml"), yaml.dump(merged));
|
|
101
|
+
return merged;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Style reference images ───────────────────────────────────────────────────
|
|
105
|
+
// A generated style keeps its reference images in styles/<slug>/refs/ with a
|
|
106
|
+
// manifest of the source URLs. The server sends the style's current refUrls
|
|
107
|
+
// with each carousel job; if the local cache is missing or stale (refs were
|
|
108
|
+
// added/removed/reordered on the web) we re-download. Built-in and hand-made
|
|
109
|
+
// styles send no refUrls and skip all ref-guided behavior.
|
|
110
|
+
const MAX_PROMPT_REFS = 6;
|
|
111
|
+
|
|
112
|
+
async function ensureStyleRefs(styleSlug, styleRefUrls) {
|
|
113
|
+
const urls = (styleRefUrls ?? []).slice(0, 20);
|
|
114
|
+
if (urls.length === 0) return [];
|
|
115
|
+
const refsDir = join(ROOT, "styles", styleSlug, "refs");
|
|
116
|
+
const manifestPath = join(refsDir, "manifest.json");
|
|
117
|
+
|
|
118
|
+
const cachedPaths = () => {
|
|
119
|
+
const files = readdirSync(refsDir)
|
|
120
|
+
.filter((f) => /^ref-\d+\./.test(f))
|
|
121
|
+
.sort();
|
|
122
|
+
return files.map((f) => join(refsDir, f));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const cached = JSON.parse(readFileSync(manifestPath, "utf8"))?.urls ?? [];
|
|
127
|
+
if (cached.length === urls.length && cached.every((u, i) => u === urls[i])) {
|
|
128
|
+
const paths = cachedPaths();
|
|
129
|
+
if (paths.length === urls.length) return paths.slice(0, MAX_PROMPT_REFS);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// no/unreadable manifest → fall through to download
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
rmSync(refsDir, { recursive: true, force: true });
|
|
137
|
+
mkdirSync(refsDir, { recursive: true });
|
|
138
|
+
const paths = [];
|
|
139
|
+
for (const [i, url] of urls.entries()) {
|
|
140
|
+
const res = await fetch(url);
|
|
141
|
+
if (!res.ok) throw new Error(`download failed (${res.status}): ${url}`);
|
|
142
|
+
const p = join(
|
|
143
|
+
refsDir,
|
|
144
|
+
`ref-${String(i + 1).padStart(2, "0")}${extFromUrl(url, res.headers.get("content-type"))}`,
|
|
145
|
+
);
|
|
146
|
+
writeFileSync(p, Buffer.from(await res.arrayBuffer()));
|
|
147
|
+
paths.push(p);
|
|
148
|
+
}
|
|
149
|
+
writeFileSync(manifestPath, JSON.stringify({ urls }, null, 2));
|
|
150
|
+
console.log(` refreshed ${paths.length} style ref(s) for ${styleSlug}`);
|
|
151
|
+
return paths.slice(0, MAX_PROMPT_REFS);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
// Refs are guidance, not a hard dependency — never fail the job over them.
|
|
154
|
+
console.error(` style refs unavailable (${e.message}) — proceeding without refs`);
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
62
159
|
// ── Drafting via local Claude Code ──────────────────────────────────────────
|
|
63
160
|
function readIfExists(p) {
|
|
64
161
|
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
@@ -82,12 +179,73 @@ function voiceContext() {
|
|
|
82
179
|
return `\n## Voice rules (follow strictly — later files win on conflict)\n\n${parts.join("\n\n---\n\n")}\n`;
|
|
83
180
|
}
|
|
84
181
|
|
|
85
|
-
|
|
182
|
+
// A video's poster frame sits next to the clip under the same stem — the
|
|
183
|
+
// render engine derives the identical path, so whatever Claude studies here is
|
|
184
|
+
// exactly the still the slide gets built on.
|
|
185
|
+
function posterName(videoFile) {
|
|
186
|
+
return videoFile.replace(/\.[^.]+$/, "") + ".poster.jpg";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function photosContext(photoFiles, photosDir, hasVideos) {
|
|
190
|
+
if (photoFiles.length === 0) {
|
|
191
|
+
return hasVideos
|
|
192
|
+
? `- No photos were attached — only the video(s) below. Every "photo" template slide you use must carry a video:.`
|
|
193
|
+
: `- No photos were attached — do not use the "photo" template.`;
|
|
194
|
+
}
|
|
195
|
+
return `- The user attached ${photoFiles.length} photo(s). FIRST, read each photo file to SEE what it shows:
|
|
196
|
+
${photoFiles.map((f) => ` ${join(photosDir, f)}`).join("\n")}
|
|
197
|
+
- Then place them deliberately with the "photo" template (photo: photos/<filename>): match each photo to the slide whose message it illustrates — a close-up photo belongs on the slide calling out that detail, a wide shot on a setup/mood slide.
|
|
198
|
+
- If the user's request says to show or highlight something specific, the slides about it MUST use the photo that actually shows that thing. Never leave requested photos unused.
|
|
199
|
+
- Photos get cropped to the slide shape from the CENTER at natural scale by default. Two per-slide controls, chosen from what you SAW in each photo:
|
|
200
|
+
- focus: the CENTER of the subject, as "X% Y%" coordinates on the original photo from the top-left — e.g. a monitor whose middle sits low-center-right is roughly focus: "65% 70%". (Keywords like top/bottom/left/right also work for gentle nudges without zoom.)
|
|
201
|
+
- zoom: how tight to go. 1 = whole photo (default). With zoom > 1 the renderer cuts a window out of the photo CENTERED on the focus point — the subject lands dead center of the slide. Use zoom: 1.6-2.5 when the subject is SMALL in the frame and the slide is ABOUT that subject — a feature close-up should fill the slide with the feature, not the whole room. Always pair zoom with precise "X% Y%" focus coordinates aimed at the subject's center.`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Videos ride the same "photo" template as stills — the clip simply plays
|
|
205
|
+
// where the photo would sit. Claude can't watch an MP4, so it judges each clip
|
|
206
|
+
// (and its focus/zoom) from the poster frame the worker pulled out of it.
|
|
207
|
+
function videosContext(videoFiles, videosDir) {
|
|
208
|
+
if (videoFiles.length === 0) return "";
|
|
209
|
+
return `- The user attached ${videoFiles.length} video(s). A video slide MOVES: the clip plays full-bleed behind the slide's text, exactly where a photo would sit.
|
|
210
|
+
- To use one, put \`video: videos/<filename>\` on a "photo" template slide — in place of \`photo:\`, never alongside it.
|
|
211
|
+
- You cannot watch a video, but one frame was pulled out of each. READ these to SEE what each clip shows:
|
|
212
|
+
${videoFiles.map((f) => ` ${join(videosDir, posterName(f))} (a frame from videos/${f})`).join("\n")}
|
|
213
|
+
- Spend the motion where it earns its place: give a video the slide whose message its movement actually sells (a process, a reveal, a thing in use), not a slide that a still would have carried just as well.
|
|
214
|
+
- focus and zoom work exactly as they do for photos, judged from the poster frame above — the clip is cropped to the slide the same way.
|
|
215
|
+
- Never leave an attached video unused.`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const PALETTE_KEYS = [
|
|
219
|
+
"primary", "primaryLight", "primaryDark", "accent", "dark",
|
|
220
|
+
"lightBg", "lightBorder", "text", "textOnLight",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
// The ref-guidance block shared by drafting and revision prompts. Empty when
|
|
224
|
+
// the style has no reference images (built-in styles) — the prompts then read
|
|
225
|
+
// exactly as they did before refs existed.
|
|
226
|
+
function refsContext(refPaths) {
|
|
227
|
+
if (!refPaths || refPaths.length === 0) return "";
|
|
228
|
+
return `
|
|
229
|
+
## Reference slides for this style — THE SOURCE OF TRUTH for look and format
|
|
230
|
+
Read each of these images FIRST, before drafting anything (they are in order; the first is the style's pinned cover reference):
|
|
231
|
+
${refPaths.map((p) => `- ${p}`).join("\n")}
|
|
232
|
+
|
|
233
|
+
${skillSection("analyze")}
|
|
234
|
+
|
|
235
|
+
${skillSection("map")}
|
|
236
|
+
|
|
237
|
+
Two extra controls available because references exist:
|
|
238
|
+
- Per-slide \`variant: flip\` mirrors a template's movable overlay (swatch stack, badge, caption block) to the opposite side of the slide.
|
|
239
|
+
- A top-level \`colors:\` mapping recolors the whole deck — use it ONLY when the user's photos share a coherent palette worth building the deck from. Keys limited to: ${PALETTE_KEYS.join(", ")}. Values must be #hex sampled from the user's photos. Omit \`colors:\` entirely to keep the reference palette.
|
|
240
|
+
`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function draftingPrompt(job, templates, media, refPaths) {
|
|
86
244
|
const exemplar = readFileSync(join(ROOT, "carousels", "demo", "slides.yml"), "utf8");
|
|
87
245
|
return `You are drafting the slide copy for an Instagram carousel. Return ONLY a YAML document (no markdown fences, no commentary) in exactly this format:
|
|
88
246
|
|
|
89
247
|
${exemplar}
|
|
90
|
-
|
|
248
|
+
${refsContext(refPaths)}
|
|
91
249
|
## Rules
|
|
92
250
|
- Exactly ${job.slideCount} slides total.
|
|
93
251
|
- Allowed "template" values (this style only): ${templates.join(", ")}.
|
|
@@ -95,7 +253,9 @@ ${exemplar}
|
|
|
95
253
|
- Text markup: *word* renders in the accent color, _word_ renders italic. No other markup. No emoji. No em dashes. No ellipsis characters.
|
|
96
254
|
- Keep each slide's text short — it is display type, max ~12 words per slide, use line breaks (YAML |- blocks).
|
|
97
255
|
- Resource-style slides (template "resource") need: name, url (a real site — only include real, well-known URLs you are certain exist), and a short text description.
|
|
98
|
-
|
|
256
|
+
- If a template is built for color swatches (e.g. "palette"), give those slides swatches: ["#hex", "#hex", ...] (3-6 real, deliberate colors) plus a short text label. Pick colors that genuinely work together — they are the content.
|
|
257
|
+
${photosContext(media.photoFiles, media.photosDir, media.videoFiles.length > 0)}
|
|
258
|
+
${videosContext(media.videoFiles, media.videosDir)}
|
|
99
259
|
- Never invent statistics, follower counts, or claims. Plain honest copy.
|
|
100
260
|
${voiceContext()}
|
|
101
261
|
## The carousel to draft
|
|
@@ -104,19 +264,158 @@ ${job.prompt ?? "(no prompt — build the carousel around the attached photos)"}
|
|
|
104
264
|
`;
|
|
105
265
|
}
|
|
106
266
|
|
|
107
|
-
|
|
267
|
+
// The deck as the user counts it: "# slide N" markers before each slide so
|
|
268
|
+
// notes like "slide 3" and the YAML agree on numbering (slide 1 = cover).
|
|
269
|
+
function numberedDeck(previousYml) {
|
|
270
|
+
try {
|
|
271
|
+
const spec = yaml.load(previousYml);
|
|
272
|
+
if (!Array.isArray(spec?.slides) || spec.slides.length === 0) return previousYml;
|
|
273
|
+
const total = spec.slides.length;
|
|
274
|
+
const role = (i) => (i === 0 ? "cover" : i === total - 1 ? "closer/CTA" : "middle");
|
|
275
|
+
const head = { ...spec };
|
|
276
|
+
delete head.slides;
|
|
277
|
+
const headYml = Object.keys(head).length > 0 ? yaml.dump(head, { lineWidth: 100 }) : "";
|
|
278
|
+
const body = spec.slides
|
|
279
|
+
.map(
|
|
280
|
+
(s, i) =>
|
|
281
|
+
` # ── slide ${i + 1} of ${total} (${role(i)}) ──\n` +
|
|
282
|
+
yaml.dump([s], { lineWidth: 100 }).replace(/^(?=.)/gm, " "),
|
|
283
|
+
)
|
|
284
|
+
.join("");
|
|
285
|
+
return `${headYml}slides:\n${body}`;
|
|
286
|
+
} catch {
|
|
287
|
+
return previousYml; // unparseable? show it as-is rather than fail the job
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Revision jobs get a fundamentally different prompt: the task is EDITING an
|
|
292
|
+
// existing YAML, not drafting a new one. Untouched slides must survive
|
|
293
|
+
// byte-for-byte — that's what makes per-slide notes trustworthy.
|
|
294
|
+
function revisionPrompt(job, templates, media, refPaths, brand) {
|
|
295
|
+
return `You are revising an existing Instagram carousel. Below is the CURRENT
|
|
296
|
+
slide deck (YAML, with "# slide N" markers showing how the user counts slides —
|
|
297
|
+
slide 1 is the cover) and the user's notes. Apply the notes faithfully.
|
|
298
|
+
|
|
299
|
+
## The current deck
|
|
300
|
+
|
|
301
|
+
${numberedDeck(job.previousSlidesYml)}
|
|
302
|
+
|
|
303
|
+
## The user's notes
|
|
304
|
+
|
|
305
|
+
${job.feedback ? `"${job.feedback}"` : `(No written notes — the only request is to work the newly added photos and videos below into the deck.)`}
|
|
306
|
+
${refsContext(refPaths)}
|
|
307
|
+
## Attached photos and videos
|
|
308
|
+
${photosContext(media.photoFiles, media.photosDir, media.videoFiles.length > 0)}
|
|
309
|
+
${videosContext(media.videoFiles, media.videosDir)}
|
|
310
|
+
${
|
|
311
|
+
job.newPhotoFiles?.length || job.newVideoFiles?.length
|
|
312
|
+
? `
|
|
313
|
+
## Media added WITH this revision
|
|
314
|
+
These file(s) were uploaded alongside the notes — they did not exist when the
|
|
315
|
+
current deck was drafted, so the deck cannot be using them yet:
|
|
316
|
+
${job.newPhotoFiles.map((f) => ` photos/${f}`).join("\n")}
|
|
317
|
+
${job.newVideoFiles.map((f) => ` videos/${f} (read ${posterName(f)} in that folder to see it)`).join("\n")}
|
|
318
|
+
Read each one to SEE it, then place it where the notes say. If a note names a
|
|
319
|
+
new file but no slide, put it on the slide whose message it clearly
|
|
320
|
+
illustrates (switching that slide to the "photo" template if needed) — never
|
|
321
|
+
leave newly added media unused.
|
|
322
|
+
`
|
|
323
|
+
: ""
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
## How to read the notes
|
|
327
|
+
- Notes may point at a slide by number ("slide 3" = the "# slide 3" marker above) or by quoting text that appears on it — resolve either against the deck above.
|
|
328
|
+
- Notes may be PER-SLIDE (surgical: change only what that note describes, on that slide) or GLOBAL ("punchier throughout", "less salesy", "use my brand font"). A global note applies to EVERY slide it concerns — applying it to only one or two slides is a failure, not caution.
|
|
329
|
+
|
|
330
|
+
## Global look controls
|
|
331
|
+
Use these ONLY when a note asks for a deck-wide look change (they live at the top level of the YAML, next to "style:"):
|
|
332
|
+
- \`fonts:\` mapping — keys: heading, body, mono (plus headingWeight, a number). Allowed families: ${[...Object.keys(FONT_SPECS), ...(brand?.customFonts ?? []).map((f) => f.family)].join(", ")}. The user's saved brand fonts are heading "${brand?.fonts?.heading ?? ""}", body "${brand?.fonts?.body ?? ""}", mono "${brand?.fonts?.mono ?? ""}" — a note like "use my brand font" means setting exactly those. A note like "use the original font" / "the style's font" means REMOVING any top-level fonts: mapping so the style's own typography returns.
|
|
333
|
+
- \`colors:\` mapping — keys limited to: ${PALETTE_KEYS.join(", ")}, values #hex.
|
|
334
|
+
Fonts and colors exist ONLY at the top level — never answer a look note by rewriting slide text or switching templates.
|
|
335
|
+
|
|
336
|
+
## Hard rules
|
|
337
|
+
- Output ONLY the full revised YAML document (no fences, no commentary, and WITHOUT the "# slide N" markers), same format as the current deck.
|
|
338
|
+
- A slide that no per-slide note mentions and no global note affects — or that the notes call good ("perfect", "great", "ok", "keep", a checkmark) — MUST be copied into your output COMPLETELY UNCHANGED: identical template, identical text, character for character.
|
|
339
|
+
- Where a note asks for a change, make that change everywhere the note applies — and change nothing else.
|
|
340
|
+
- If a note says a slide's content type is wrong (e.g. "no resource here, just advice"), switch that slide's template (allowed: ${templates.join(", ")}) and rewrite that slide only.
|
|
341
|
+
- Keep the slide count at ${job.slideCount} unless a note explicitly says to add or remove a slide.
|
|
342
|
+
- Same text markup rules as always: *word* = accent/headline voice, _word_ = italic. No emoji, no em dashes.
|
|
343
|
+
${voiceContext()}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Post-revision check: did the revised deck actually honor the notes? Same
|
|
347
|
+
// philosophy as the rendered-slide self-check — inspect your own output
|
|
348
|
+
// against the ground truth (there: reference images, here: the user's notes).
|
|
349
|
+
// Returns [] when everything applied; misses feed one corrective retry.
|
|
350
|
+
async function verifyNotesApplied(job, revisedYml) {
|
|
351
|
+
const prompt = `A user asked for revisions to an Instagram carousel. Compare the decks and judge whether every request in the notes was honored.
|
|
352
|
+
|
|
353
|
+
## Original deck
|
|
354
|
+
${numberedDeck(job.previousSlidesYml)}
|
|
355
|
+
|
|
356
|
+
## The user's notes
|
|
357
|
+
"${job.feedback}"
|
|
358
|
+
|
|
359
|
+
## Revised deck
|
|
360
|
+
${revisedYml}
|
|
361
|
+
|
|
362
|
+
Check each distinct request in the notes (notes reference slides by number — see the markers — or by quoting their text; global notes like "punchier throughout" or "use my brand font" must be applied everywhere they concern, font/color notes via the top-level fonts:/colors: keys). Also flag slides that changed although no note asked for it.
|
|
363
|
+
|
|
364
|
+
Output ONLY YAML, no commentary. If every request was honored and nothing changed unasked:
|
|
365
|
+
applied: true
|
|
366
|
+
Otherwise:
|
|
367
|
+
missed:
|
|
368
|
+
- "<the request, and what the revised deck failed to do>"`;
|
|
369
|
+
const { text: stdout } = await runAgent({
|
|
370
|
+
agent: job.agentCli,
|
|
371
|
+
prompt,
|
|
372
|
+
mode: "text",
|
|
373
|
+
timeoutMs: 5 * 60 * 1000,
|
|
374
|
+
});
|
|
375
|
+
const verdict = yaml.load(stdout.replace(/^```(?:yaml)?\n?/m, "").replace(/\n?```\s*$/m, "").trim());
|
|
376
|
+
return Array.isArray(verdict?.missed) ? verdict.missed.map(String) : [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
|
|
380
|
+
|
|
381
|
+
// Per-carousel color overrides may only touch known palette keys with real
|
|
382
|
+
// hex values — anything else is silently dropped rather than failing the job.
|
|
383
|
+
function sanitizeColors(colors) {
|
|
384
|
+
if (!colors || typeof colors !== "object" || Array.isArray(colors)) return null;
|
|
385
|
+
const out = {};
|
|
386
|
+
for (const [k, v] of Object.entries(colors)) {
|
|
387
|
+
if (PALETTE_KEYS.includes(k) && HEX_RE.test(String(v))) out[k] = String(v);
|
|
388
|
+
}
|
|
389
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function draftSlides(job, styleDir, media, refPaths, brand) {
|
|
108
393
|
const templates = readdirSync(join(styleDir, "templates"))
|
|
109
394
|
.filter((f) => f.endsWith(".html"))
|
|
110
395
|
.map((f) => f.replace(/\.html$/, ""));
|
|
111
396
|
|
|
112
|
-
|
|
397
|
+
// A revision can carry a note, newly added photos, or both.
|
|
398
|
+
const isRevision = Boolean(
|
|
399
|
+
job.previousSlidesYml && (job.feedback || (job.newPhotoUrls ?? []).length),
|
|
400
|
+
);
|
|
401
|
+
const basePrompt = isRevision
|
|
402
|
+
? revisionPrompt(job, templates, media, refPaths, brand)
|
|
403
|
+
: draftingPrompt(job, templates, media, refPaths);
|
|
113
404
|
|
|
405
|
+
// Misses from the notes-applied check feed the retry, so attempt 2 knows
|
|
406
|
+
// exactly what attempt 1 got wrong instead of rolling the dice again.
|
|
407
|
+
let missNote = "";
|
|
114
408
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
409
|
+
// mode "read" lets the agent actually look at the attached photos
|
|
410
|
+
// (--allowedTools Read on Claude, a read-only sandbox rooted at ROOT on
|
|
411
|
+
// Codex — job dirs live under ROOT, so relative and absolute paths work).
|
|
412
|
+
const { text: stdout } = await runAgent({
|
|
413
|
+
agent: job.agentCli,
|
|
414
|
+
prompt: basePrompt + missNote,
|
|
415
|
+
mode: "read",
|
|
416
|
+
cwd: ROOT,
|
|
417
|
+
timeoutMs: 10 * 60 * 1000,
|
|
418
|
+
});
|
|
120
419
|
const raw = stdout.replace(/^```(?:yaml)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
121
420
|
try {
|
|
122
421
|
const spec = yaml.load(raw);
|
|
@@ -124,11 +423,49 @@ async function draftSlides(job, styleDir, photoFiles) {
|
|
|
124
423
|
if (!Array.isArray(slides) || slides.length === 0) throw new Error("no slides array");
|
|
125
424
|
for (const slide of slides) {
|
|
126
425
|
if (!templates.includes(slide.template)) throw new Error(`unknown template ${slide.template}`);
|
|
127
|
-
if (slide.photo && !photoFiles.includes(slide.photo.replace(/^photos\//, ""))) {
|
|
426
|
+
if (slide.photo && !media.photoFiles.includes(slide.photo.replace(/^photos\//, ""))) {
|
|
128
427
|
throw new Error(`unknown photo ${slide.photo}`);
|
|
129
428
|
}
|
|
429
|
+
if (slide.video && !media.videoFiles.includes(slide.video.replace(/^videos\//, ""))) {
|
|
430
|
+
throw new Error(`unknown video ${slide.video}`);
|
|
431
|
+
}
|
|
432
|
+
// A slide has one background. Asked for both, the moving one wins —
|
|
433
|
+
// that's the one the user went out of their way to attach.
|
|
434
|
+
if (slide.video && slide.photo) delete slide.photo;
|
|
435
|
+
if (slide.variant !== undefined && slide.variant !== "flip") delete slide.variant;
|
|
130
436
|
}
|
|
131
|
-
|
|
437
|
+
const colors = sanitizeColors(spec.colors);
|
|
438
|
+
// Uploaded families count too — see sanitizeFonts.
|
|
439
|
+
const fonts = sanitizeFonts(
|
|
440
|
+
spec.fonts,
|
|
441
|
+
(brand?.customFonts ?? []).map((f) => f.family),
|
|
442
|
+
);
|
|
443
|
+
const result = {
|
|
444
|
+
spec: {
|
|
445
|
+
style: job.styleSlug,
|
|
446
|
+
...(colors ? { colors } : {}),
|
|
447
|
+
...(fonts ? { fonts } : {}),
|
|
448
|
+
slides,
|
|
449
|
+
},
|
|
450
|
+
raw,
|
|
451
|
+
};
|
|
452
|
+
// Revisions get one more gate: were the notes actually applied? A miss
|
|
453
|
+
// on attempt 1 retries with the misses named; on attempt 2 we keep the
|
|
454
|
+
// deck anyway — like refs, this check guides, it never fails the job.
|
|
455
|
+
// (Photos-only revisions have no notes to verify against.)
|
|
456
|
+
if (isRevision && job.feedback && attempt === 1) {
|
|
457
|
+
try {
|
|
458
|
+
const missed = await verifyNotesApplied(job, yaml.dump(result.spec, { lineWidth: 100 }));
|
|
459
|
+
if (missed.length > 0) {
|
|
460
|
+
console.log(` notes check: ${missed.length} request(s) not applied — retrying`);
|
|
461
|
+
missNote = `\n\n## Your previous attempt failed review\nIt missed these requests — apply them this time (everything else above still holds):\n${missed.map((m) => `- ${m}`).join("\n")}\n\nYour previous attempt, for reference:\n\n${raw}\n`;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
} catch (e) {
|
|
465
|
+
console.error(` notes check skipped (${e.message})`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
132
469
|
} catch (e) {
|
|
133
470
|
console.error(`Draft attempt ${attempt} invalid: ${e.message}`);
|
|
134
471
|
if (attempt === 2) throw new Error(`Drafting failed: ${e.message}`);
|
|
@@ -145,21 +482,77 @@ async function runJob(job) {
|
|
|
145
482
|
|
|
146
483
|
const dir = join(ROOT, "carousels", `web-${job.id.slice(0, 8)}`);
|
|
147
484
|
mkdirSync(join(dir, "photos"), { recursive: true });
|
|
485
|
+
mkdirSync(join(dir, "videos"), { recursive: true });
|
|
486
|
+
// Older servers don't send these — default to applying the brand, matching
|
|
487
|
+
// the checkboxes' default on the web.
|
|
488
|
+
const brand = writeBrandFile(dir, job.brand, {
|
|
489
|
+
fonts: job.applyBrandFonts !== false,
|
|
490
|
+
colors: job.applyBrandColors !== false,
|
|
491
|
+
});
|
|
148
492
|
|
|
149
|
-
// Download attached
|
|
493
|
+
// Download attached media. Photos and videos arrive in one list (the server
|
|
494
|
+
// has always called it photoUrls); the file extension decides which is
|
|
495
|
+
// which, and each lands in its own folder. On revisions the server also
|
|
496
|
+
// names which URLs arrived with the feedback note — those get called out in
|
|
497
|
+
// the prompt.
|
|
150
498
|
const photoFiles = [];
|
|
499
|
+
const videoFiles = [];
|
|
500
|
+
const newUrls = new Set(job.newPhotoUrls ?? []);
|
|
501
|
+
job.newPhotoFiles = [];
|
|
502
|
+
job.newVideoFiles = [];
|
|
151
503
|
for (const url of job.photoUrls ?? []) {
|
|
152
504
|
const res = await fetch(url);
|
|
153
|
-
if (!res.ok) throw new Error(`
|
|
505
|
+
if (!res.ok) throw new Error(`Media download failed: ${url}`);
|
|
154
506
|
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "photo.jpg");
|
|
155
507
|
const safe = name.replace(/[^\w.-]/g, "_");
|
|
156
|
-
|
|
157
|
-
|
|
508
|
+
const video = isVideoPath(safe);
|
|
509
|
+
writeFileSync(join(dir, video ? "videos" : "photos", safe), Buffer.from(await res.arrayBuffer()));
|
|
510
|
+
(video ? videoFiles : photoFiles).push(safe);
|
|
511
|
+
if (newUrls.has(url)) (video ? job.newVideoFiles : job.newPhotoFiles).push(safe);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Bake EXIF rotation into the pixels — otherwise Claude reads sideways
|
|
515
|
+
// iPhone photos and its focus coordinates land in the wrong place.
|
|
516
|
+
if (photoFiles.length > 0) {
|
|
517
|
+
await execFileAsync(
|
|
518
|
+
"node",
|
|
519
|
+
[join(ROOT, "src", "normalize-photos.js"), join(dir, "photos")],
|
|
520
|
+
{ maxBuffer: 10 * 1024 * 1024, timeout: 5 * 60 * 1000 },
|
|
521
|
+
);
|
|
158
522
|
}
|
|
159
523
|
|
|
524
|
+
// Pull a still out of each clip so the drafting pass can SEE it — Claude
|
|
525
|
+
// reads images, not MP4s.
|
|
526
|
+
//
|
|
527
|
+
// This is also the gate: if the user attached a clip and this computer can't
|
|
528
|
+
// do video, fail NOW — before drafting spends Claude tokens on a deck we
|
|
529
|
+
// can't deliver. Falling back to stills would hand back a carousel that
|
|
530
|
+
// looks finished and quietly isn't the one they asked for.
|
|
531
|
+
if (videoFiles.length > 0) {
|
|
532
|
+
await assertFfmpeg();
|
|
533
|
+
for (const f of videoFiles) {
|
|
534
|
+
try {
|
|
535
|
+
await posterFrame(join(dir, "videos", f), join(dir, "videos", posterName(f)));
|
|
536
|
+
} catch (e) {
|
|
537
|
+
throw new Error(`Couldn't read the video "${f}" — is it a valid MP4, MOV or WebM? (${e.message})`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const media = {
|
|
543
|
+
photoFiles,
|
|
544
|
+
photosDir: join(dir, "photos"),
|
|
545
|
+
videoFiles,
|
|
546
|
+
videosDir: join(dir, "videos"),
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Reference images for generated styles — drafting and the self-check
|
|
550
|
+
// study these alongside the user's photos.
|
|
551
|
+
const refPaths = await ensureStyleRefs(job.styleSlug, job.styleRefUrls);
|
|
552
|
+
|
|
160
553
|
// Draft (Claude writes YAML to stdout only; we validate + write files).
|
|
161
|
-
const { spec } = await draftSlides(job, styleDir,
|
|
162
|
-
|
|
554
|
+
const { spec } = await draftSlides(job, styleDir, media, refPaths, brand);
|
|
555
|
+
let slidesYml = yaml.dump(spec, { lineWidth: 100 });
|
|
163
556
|
writeFileSync(join(dir, "slides.yml"), slidesYml);
|
|
164
557
|
|
|
165
558
|
// Render.
|
|
@@ -170,22 +563,131 @@ async function runJob(job) {
|
|
|
170
563
|
{ maxBuffer: 10 * 1024 * 1024, timeout: 15 * 60 * 1000 },
|
|
171
564
|
);
|
|
172
565
|
|
|
566
|
+
// Self-check: LOOK at the rendered slides and fix what's off. For photo
|
|
567
|
+
// slides that's framing (focus/zoom are estimates until you see the
|
|
568
|
+
// output); when the style has reference images it's also a per-slide
|
|
569
|
+
// comparison against them — role to role, cover vs cover, closer vs
|
|
570
|
+
// closer — with overlay flips and swatch corrections as fixes.
|
|
571
|
+
// Video slides render a poster-frame still alongside their MP4, framed by
|
|
572
|
+
// the same focus/zoom — so the framing check works on them unchanged.
|
|
573
|
+
const photoSlideIdx = spec.slides
|
|
574
|
+
.map((s, i) => (s.photo || s.video ? i : -1))
|
|
575
|
+
.filter((i) => i !== -1);
|
|
576
|
+
if (photoSlideIdx.length > 0 || refPaths.length > 0) {
|
|
577
|
+
try {
|
|
578
|
+
const total = spec.slides.length;
|
|
579
|
+
const roleOf = (i) => (i === 0 ? "cover" : i === total - 1 ? "closer/CTA" : "middle");
|
|
580
|
+
const checkIdx = refPaths.length > 0 ? spec.slides.map((_, i) => i) : photoSlideIdx;
|
|
581
|
+
const slideDesc = (i) => {
|
|
582
|
+
const s = spec.slides[i];
|
|
583
|
+
const bits = [`template ${s.template}`];
|
|
584
|
+
if (s.photo) bits.push(`photo ${s.photo}, focus ${s.focus ?? "center"}, zoom ${s.zoom ?? 1}`);
|
|
585
|
+
if (s.video) bits.push(`video ${s.video} (this PNG is a frame of it), focus ${s.focus ?? "center"}, zoom ${s.zoom ?? 1}`);
|
|
586
|
+
if (s.variant) bits.push(`variant ${s.variant}`);
|
|
587
|
+
if (Array.isArray(s.swatches)) bits.push(`swatches [${s.swatches.join(", ")}]`);
|
|
588
|
+
return `- slide ${i + 1} of ${total} (${roleOf(i)}): ${join(outDir, `slide-${String(i + 1).padStart(2, "0")}.png`)} — ${bits.join("; ")}`;
|
|
589
|
+
};
|
|
590
|
+
const checkPrompt = `You built an Instagram carousel; now inspect your own output.
|
|
591
|
+
|
|
592
|
+
The user's request: "${job.prompt ?? ""}"${job.feedback ? `\nThe user's revision notes: "${job.feedback}"` : ""}
|
|
593
|
+
|
|
594
|
+
Read these RENDERED slides (what followers will actually see), with their current values:
|
|
595
|
+
${checkIdx.map(slideDesc).join("\n")}
|
|
596
|
+
${refPaths.length > 0 ? `
|
|
597
|
+
Then read the style's REFERENCE images — the originals this carousel must look like it belongs with (in order; the first is the cover reference):
|
|
598
|
+
${refPaths.map((p) => `- ${p}`).join("\n")}
|
|
599
|
+
|
|
600
|
+
${skillSection("compare")}
|
|
601
|
+
` : ""}${photoFiles.length > 0 || videoFiles.length > 0 ? `
|
|
602
|
+
The ORIGINAL frames the photo/video slides were cropped from:
|
|
603
|
+
${photoFiles.map((f) => `- ${join(dir, "photos", f)}`).join("\n")}
|
|
604
|
+
${videoFiles.map((f) => `- ${join(dir, "videos", posterName(f))} (from videos/${f})`).join("\n")}
|
|
605
|
+
|
|
606
|
+
For each photo or video slide ask: is the subject the user cares about actually prominent and well-framed in THIS crop — or did the crop land on the wrong thing (background, another object)? Remember focus is "X% Y%" coordinates ON THE ORIGINAL photo, and with zoom the crop is a window CENTERED on that point — so focus must be the subject's center.
|
|
607
|
+
` : ""}
|
|
608
|
+
Output ONLY YAML, no commentary. If every slide passes:
|
|
609
|
+
ok: true
|
|
610
|
+
Otherwise list corrections — only slides needing change, only keys you are changing:
|
|
611
|
+
fixes:
|
|
612
|
+
- slide: 4
|
|
613
|
+
focus: "78% 62%"
|
|
614
|
+
zoom: 2.0
|
|
615
|
+
variant: flip # mirror the overlay side ("" to un-flip)
|
|
616
|
+
swatches: ["#a1b2c3"] # only for slides that already have swatches
|
|
617
|
+
Do not rewrite any slide's text and do not switch templates.`;
|
|
618
|
+
const { text: stdout } = await runAgent({
|
|
619
|
+
agent: job.agentCli,
|
|
620
|
+
prompt: checkPrompt,
|
|
621
|
+
mode: "read",
|
|
622
|
+
cwd: ROOT,
|
|
623
|
+
timeoutMs: 10 * 60 * 1000,
|
|
624
|
+
});
|
|
625
|
+
const verdict = yaml.load(stdout.replace(/^```(?:yaml)?\n?/m, "").replace(/\n?```\s*$/m, "").trim());
|
|
626
|
+
if (verdict?.fixes?.length) {
|
|
627
|
+
for (const fix of verdict.fixes) {
|
|
628
|
+
const i = Number(fix.slide) - 1;
|
|
629
|
+
const s = spec.slides[i];
|
|
630
|
+
if (!s) continue;
|
|
631
|
+
if (s.photo || s.video) {
|
|
632
|
+
if (fix.focus) s.focus = String(fix.focus);
|
|
633
|
+
if (fix.zoom) s.zoom = Number(fix.zoom);
|
|
634
|
+
}
|
|
635
|
+
if (fix.variant === "flip") s.variant = "flip";
|
|
636
|
+
else if (fix.variant === "") delete s.variant;
|
|
637
|
+
if (Array.isArray(fix.swatches) && Array.isArray(s.swatches)) {
|
|
638
|
+
const clean = fix.swatches.map(String).filter((c) => HEX_RE.test(c));
|
|
639
|
+
if (clean.length >= 1 && clean.length <= 10) s.swatches = clean;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
slidesYml = yaml.dump(spec, { lineWidth: 100 });
|
|
643
|
+
writeFileSync(join(dir, "slides.yml"), slidesYml);
|
|
644
|
+
console.log(` self-check: reframing ${verdict.fixes.length} slide(s), re-rendering`);
|
|
645
|
+
await execFileAsync(
|
|
646
|
+
"node",
|
|
647
|
+
[join(ROOT, "src", "render.js"), dir, "--aspect", job.aspect, "--out", outDir],
|
|
648
|
+
{ maxBuffer: 10 * 1024 * 1024, timeout: 15 * 60 * 1000 },
|
|
649
|
+
);
|
|
650
|
+
} else {
|
|
651
|
+
console.log(" self-check: framing OK");
|
|
652
|
+
}
|
|
653
|
+
} catch (e) {
|
|
654
|
+
console.error(` self-check skipped: ${e.message}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
173
658
|
// Upload results (client-upload tokens are minted by the server per file,
|
|
174
659
|
// scoped to this job; the PNG bytes go straight to Blob storage).
|
|
175
|
-
|
|
660
|
+
// One result per slide, in deck order: the MP4 where a video slide
|
|
661
|
+
// composited, the PNG otherwise. A video slide leaves both on disk — the PNG
|
|
662
|
+
// is its fallback and what the framing check read — so the MP4 wins the slot.
|
|
663
|
+
const bySlide = new Map();
|
|
664
|
+
for (const f of readdirSync(outDir).filter((f) => /^slide-\d+\.(png|mp4)$/.test(f)).sort()) {
|
|
665
|
+
const stem = f.slice(0, f.lastIndexOf("."));
|
|
666
|
+
if (f.endsWith(".mp4") || !bySlide.has(stem)) bySlide.set(stem, f);
|
|
667
|
+
}
|
|
176
668
|
const resultUrls = [];
|
|
177
|
-
for (const
|
|
178
|
-
const
|
|
669
|
+
for (const stem of [...bySlide.keys()].sort()) {
|
|
670
|
+
const file = bySlide.get(stem);
|
|
671
|
+
const isVideo = file.endsWith(".mp4");
|
|
672
|
+
const blob = await upload(`carousel/results/${job.id}/${file}`, readFileSync(join(outDir, file)), {
|
|
179
673
|
access: "public",
|
|
180
674
|
handleUploadUrl: `${BASE_URL}/api/carousel/upload`,
|
|
181
675
|
clientPayload: JSON.stringify({ workerToken: TOKEN }),
|
|
182
|
-
contentType: "image/png",
|
|
676
|
+
contentType: isVideo ? "video/mp4" : "image/png",
|
|
677
|
+
// Clips are far bigger than slide PNGs; chunked upload survives them.
|
|
678
|
+
...(isVideo ? { multipart: true } : {}),
|
|
183
679
|
});
|
|
184
680
|
resultUrls.push(blob.url);
|
|
185
681
|
}
|
|
186
682
|
|
|
187
683
|
await api("complete", { jobId: job.id, ok: true, slidesYml, resultUrls });
|
|
188
684
|
console.log(`✓ job ${job.id}: ${resultUrls.length} slides uploaded`);
|
|
685
|
+
|
|
686
|
+
// The slides are safely in Blob storage now, so the local scratch dir (photos
|
|
687
|
+
// + rendered PNGs, tens of MB per job) has done its job. Without this it
|
|
688
|
+
// accumulates forever under carousels/. Only reached on success — a failed
|
|
689
|
+
// job keeps its dir for debugging (the main loop's catch handles those).
|
|
690
|
+
rmSync(dir, { recursive: true, force: true });
|
|
189
691
|
}
|
|
190
692
|
|
|
191
693
|
// ── Main loop ────────────────────────────────────────────────────────────────
|
|
@@ -200,8 +702,18 @@ while (true) {
|
|
|
200
702
|
firstPoll = false;
|
|
201
703
|
}
|
|
202
704
|
if (job) {
|
|
705
|
+
lastJobAt = Date.now();
|
|
203
706
|
try {
|
|
204
|
-
|
|
707
|
+
if (job.kind === "style") {
|
|
708
|
+
const { runStyleJob } = await import("./style.js");
|
|
709
|
+
await runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_URL, writeBrandFile });
|
|
710
|
+
} else if (job.kind === "carousel" || !job.kind) {
|
|
711
|
+
await runJob(job);
|
|
712
|
+
} else {
|
|
713
|
+
throw new Error(
|
|
714
|
+
"This computer's worker is outdated for this job — update with: npx --yes goosetools-worker@latest install",
|
|
715
|
+
);
|
|
716
|
+
}
|
|
205
717
|
} catch (e) {
|
|
206
718
|
console.error(`✗ job ${job.id}: ${e.message}`);
|
|
207
719
|
await api("complete", { jobId: job.id, ok: false, error: e.message }).catch(() => {});
|
|
@@ -211,5 +723,5 @@ while (true) {
|
|
|
211
723
|
} catch (e) {
|
|
212
724
|
console.error(`Poll failed (will retry): ${e.message}`);
|
|
213
725
|
}
|
|
214
|
-
await new Promise((r) => setTimeout(r,
|
|
726
|
+
await new Promise((r) => setTimeout(r, pollDelay()));
|
|
215
727
|
}
|