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/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 { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
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
- function loadDotEnv() {
19
- const p = join(ROOT, "worker", ".env");
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 = loadDotEnv();
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
- const POLL_MS = 30_000;
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
- function draftingPrompt(job, templates, photoFiles) {
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
- ${photoFiles.length > 0 ? `- The user attached photos, available as: ${photoFiles.join(", ")}. Use the "photo" template with photo: photos/<filename> for slides that should feature them.` : `- No photos were attached — do not use the "photo" template.`}
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
- async function draftSlides(job, styleDir, photoFiles) {
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
- const prompt = draftingPrompt(job, templates, photoFiles);
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
- const { stdout } = await execFileAsync(
116
- "claude",
117
- ["-p", prompt, "--output-format", "text"],
118
- { maxBuffer: 10 * 1024 * 1024, timeout: 10 * 60 * 1000 },
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
- return { spec: { style: job.styleSlug, slides }, raw };
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 photos.
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(`Photo download failed: ${url}`);
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
- writeFileSync(join(dir, "photos", safe), Buffer.from(await res.arrayBuffer()));
157
- photoFiles.push(safe);
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, photoFiles);
162
- const slidesYml = yaml.dump(spec, { lineWidth: 100 });
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
- const pngs = readdirSync(outDir).filter((f) => f.endsWith(".png")).sort();
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 png of pngs) {
178
- const blob = await upload(`carousel/results/${job.id}/${png}`, readFileSync(join(outDir, png)), {
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
- await runJob(job);
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, POLL_MS));
726
+ await new Promise((r) => setTimeout(r, pollDelay()));
215
727
  }