goosetools-worker 0.2.4 → 0.2.6
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/package.json +3 -2
- package/skills/reference-analysis.md +149 -0
- package/src/render.js +3 -1
- package/src/state.js +55 -0
- package/worker/index.js +12 -3
- package/worker/style.js +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "goosetools-worker",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "The Goose Tools carousel worker — your computer drafts and renders carousel slides for goosetools.com using your own Claude account.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"styles/polka-dot",
|
|
14
14
|
"carousels/demo/slides.yml",
|
|
15
15
|
"brand.yml",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"skills"
|
|
17
18
|
],
|
|
18
19
|
"type": "module",
|
|
19
20
|
"bin": {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Reference Analysis Methodology
|
|
2
|
+
|
|
3
|
+
How to study a set of reference images, map them onto something new, and check
|
|
4
|
+
the finished render against them. Shared by the Carousel Maker (slides) and the
|
|
5
|
+
Overlay Factory (overlays on reels) — the reference images are the source of
|
|
6
|
+
truth for EVERY behavior described here; there are no hardcoded rules. Worker
|
|
7
|
+
prompts splice individual sections by the SECTION markers below.
|
|
8
|
+
|
|
9
|
+
SHARED FILE: the source of truth is workers/shared/reference-analysis.md in the
|
|
10
|
+
goosetools repo, copied into each worker's skills/ by `npm run workers:sync`.
|
|
11
|
+
|
|
12
|
+
<!-- SECTION: principles -->
|
|
13
|
+
## Principles — both tools
|
|
14
|
+
|
|
15
|
+
- **Purpose first, paint second.** Work out what each reference is FOR — the
|
|
16
|
+
recurring format a follower would recognize — before its colours and fonts.
|
|
17
|
+
A copy that nails the palette but misses the format is wrong.
|
|
18
|
+
- **Variance vs constants.** Across the set, note what varies and what never
|
|
19
|
+
changes. Freedom lives exactly where the references vary; fidelity is owed
|
|
20
|
+
everywhere they don't.
|
|
21
|
+
- **Same-account test.** The finished thing should read as the same account's
|
|
22
|
+
work if posted next to the references — same format, same typographic
|
|
23
|
+
voice, same palette logic. Pixel-matching any one reference is NOT the goal.
|
|
24
|
+
- **Look, don't assume.** Read every image before deciding anything, and judge
|
|
25
|
+
your own output by looking at the render, not by re-reading your markup.
|
|
26
|
+
|
|
27
|
+
<!-- SECTION: analyze -->
|
|
28
|
+
## Phase A — understand what each reference is DOING
|
|
29
|
+
|
|
30
|
+
Read every reference image first. For each one, work out:
|
|
31
|
+
|
|
32
|
+
- **Role**: is this the cover (the hook slide), a middle content slide, or
|
|
33
|
+
the closer/CTA? The first reference is usually the cover; use layout and
|
|
34
|
+
copy cues (page counters, "swipe", "follow") to confirm.
|
|
35
|
+
- **Content job**: what is the slide FOR? Presenting a color palette with
|
|
36
|
+
labeled swatches? Comparing a before vs an after? Listing tips? Showcasing
|
|
37
|
+
a product photo? The recurring format a follower would recognize is the
|
|
38
|
+
thing to reproduce — purpose first, paint second.
|
|
39
|
+
- **Photo and subject**: where does the photo's main subject sit in the
|
|
40
|
+
frame? How tight is the crop?
|
|
41
|
+
- **Overlays**: where do overlays (swatch stacks, badges, labels, text
|
|
42
|
+
blocks) sit relative to the subject? Record which behavior this set uses:
|
|
43
|
+
some styles keep overlays OFF the subject (overlays live in the empty
|
|
44
|
+
side of the frame); others deliberately lay elements OVER the photo as
|
|
45
|
+
part of the look. Both are legitimate — copy whichever these references do.
|
|
46
|
+
- **Variance vs constants**: across the whole set, what varies (number of
|
|
47
|
+
swatches, which side the overlay sits on, crop tightness) and what never
|
|
48
|
+
changes (the format, the typography, the palette treatment, the border
|
|
49
|
+
style)? Freedom lives exactly where the references vary; fidelity is owed
|
|
50
|
+
everywhere they don't.
|
|
51
|
+
|
|
52
|
+
<!-- SECTION: map -->
|
|
53
|
+
## Phase B — map references onto the new carousel, role to role
|
|
54
|
+
|
|
55
|
+
Match by ROLE and FORMAT, never image-to-image: your cover should do what
|
|
56
|
+
the reference cover does, your closer what their closer does, and every
|
|
57
|
+
middle slide should follow the references' middle-slide format. Your slide 3
|
|
58
|
+
does not need to mirror reference slide 3.
|
|
59
|
+
|
|
60
|
+
- **Colors**: if the user's photos share a coherent palette (e.g. they are
|
|
61
|
+
all sunsets, all one product line), sample the carousel's colors from THE
|
|
62
|
+
USER'S PHOTOS so the deck feels made from their images. If the photos are
|
|
63
|
+
visually unrelated, keep the references' original colors. Never mix in
|
|
64
|
+
colors from anywhere else.
|
|
65
|
+
- **Variance**: whatever varied across the references may vary across your
|
|
66
|
+
slides the same way — swatch counts, overlay side, crop tightness. Do not
|
|
67
|
+
make every slide identical when the references weren't.
|
|
68
|
+
- **Overlay placement**: follow the coverage behavior recorded in Phase A.
|
|
69
|
+
If the references keep overlays clear of the subject, look at each of the
|
|
70
|
+
user's photos (and what the user's prompt says the photo is about) and
|
|
71
|
+
place overlays on the empty side — use `variant: flip` on a slide to
|
|
72
|
+
mirror a template's overlay to the other side. If the references overlay
|
|
73
|
+
the photo deliberately, do the same.
|
|
74
|
+
- **Photo count adaptation** — the references decide the slide mix:
|
|
75
|
+
- More photos than slides: choose the photos that best serve each slide's
|
|
76
|
+
message, but if the user's prompt asks to show something specific, the
|
|
77
|
+
photo showing it MUST be used.
|
|
78
|
+
- Fewer photos than the format wants: if the references put a photo on
|
|
79
|
+
every slide, reuse a photo with a DIFFERENT focus/zoom crop so it reads
|
|
80
|
+
as a new frame; if the references include photo-free formats (statement
|
|
81
|
+
slides, text slides), use those instead. Never pad with invented filler
|
|
82
|
+
content.
|
|
83
|
+
|
|
84
|
+
<!-- SECTION: compare -->
|
|
85
|
+
## Phase C — compare each rendered slide against the references
|
|
86
|
+
|
|
87
|
+
For each rendered slide, find the reference(s) with the same ROLE (slide 1 =
|
|
88
|
+
cover, last slide = CTA/closer, everything else = middle) and ask:
|
|
89
|
+
|
|
90
|
+
- **Same-account test**: if this slide were posted next to the references,
|
|
91
|
+
would it read as the same account's work — same format, same typographic
|
|
92
|
+
voice, same palette logic? Exact alignment with any single reference is
|
|
93
|
+
NOT required; matching the format is.
|
|
94
|
+
- **Overlay-behavior test**: does the slide follow the references' coverage
|
|
95
|
+
behavior? If they keep overlays off the subject, is this slide's subject
|
|
96
|
+
(or a face) hidden under a swatch stack, badge, or caption? If so it
|
|
97
|
+
fails — fix with `variant: flip` (mirror the overlay side) or a focus/zoom
|
|
98
|
+
change that moves the subject clear.
|
|
99
|
+
- **Framing test**: is the thing the slide is about actually prominent and
|
|
100
|
+
centered by the crop, exactly as the reference format frames its subjects?
|
|
101
|
+
|
|
102
|
+
Fix vocabulary (per slide): `focus` ("X% Y%" on the original photo),
|
|
103
|
+
`zoom` (1–3), `variant: flip` (mirror the overlay side), `swatches`
|
|
104
|
+
(recount/recolor a swatch list — only on slides that already have one).
|
|
105
|
+
Never rewrite a slide's text or switch its template during comparison.
|
|
106
|
+
|
|
107
|
+
<!-- SECTION: overlay-analyze -->
|
|
108
|
+
## Overlay Phase A — understand what the references are DOING
|
|
109
|
+
|
|
110
|
+
These are frames from reels (or screenshots of them) with text or graphics laid
|
|
111
|
+
over footage. For the set, work out:
|
|
112
|
+
|
|
113
|
+
- **Format**: what one episode of this is — a question, a hot take, a stat, a
|
|
114
|
+
quote, a before/after label? That is what the fields should capture.
|
|
115
|
+
- **Hierarchy**: what the eye reads first, second, third, and how that is
|
|
116
|
+
achieved (size, weight, colour, a box, a bar, a label above the main line).
|
|
117
|
+
- **Type**: weight, case, letter-spacing, serif vs sans vs mono, line length,
|
|
118
|
+
how many lines the main text runs to.
|
|
119
|
+
- **Containers**: solid box, translucent scrim, outline, shadow only, or text
|
|
120
|
+
straight on footage — and corner radius, border, padding.
|
|
121
|
+
- **Placement**: where on the 9:16 frame each element sits, and how much of
|
|
122
|
+
the frame the overlay takes. Note anything placed where Instagram's UI would
|
|
123
|
+
cover it — keep the format but move it into the safe band.
|
|
124
|
+
- **Colour logic**: which colours are fixed accents and which come from the
|
|
125
|
+
footage; how contrast is kept over busy or bright video.
|
|
126
|
+
- **Variance vs constants** across the set (see Principles).
|
|
127
|
+
|
|
128
|
+
<!-- SECTION: overlay-compare -->
|
|
129
|
+
## Overlay Phase C — compare the rendered frame against the references
|
|
130
|
+
|
|
131
|
+
Look at the rendered frame (sample text in every field, over grey) next to the
|
|
132
|
+
references and ask:
|
|
133
|
+
|
|
134
|
+
- **Same-account test**: would this sit next to the references as the same
|
|
135
|
+
series? Same format, hierarchy, type treatment, container style, colour
|
|
136
|
+
logic.
|
|
137
|
+
- **Hierarchy test**: does the eye land where it lands in the references, in
|
|
138
|
+
the same order?
|
|
139
|
+
- **Proportion test**: is the overlay roughly the size the references use — not
|
|
140
|
+
a tiny label where they fill the band, not a slab where they are a caption?
|
|
141
|
+
- **Legibility test**: would it read over ANY footage, on a phone at arm's
|
|
142
|
+
length? Thin text straight on video fails.
|
|
143
|
+
- **Safe-area test**: is anything in Instagram's top bar, right action rail or
|
|
144
|
+
bottom caption zone?
|
|
145
|
+
|
|
146
|
+
Fix by editing the templates (sizes, weights, spacing, colours, containers,
|
|
147
|
+
widthPct, anchor). Never change field keys, the content brief, or wording that
|
|
148
|
+
the user typed or asked for.
|
|
149
|
+
|
package/src/render.js
CHANGED
|
@@ -5,6 +5,7 @@ import yaml from "js-yaml";
|
|
|
5
5
|
import { chromium } from "playwright";
|
|
6
6
|
import { FONT_SPECS, customFontFaces, sanitizeFonts } from "./fonts.js";
|
|
7
7
|
import { assertFfmpeg, composite, posterFrame } from "./video.js";
|
|
8
|
+
import { styleDirFor } from "./state.js";
|
|
8
9
|
|
|
9
10
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
11
|
|
|
@@ -39,7 +40,8 @@ const spec = yaml.load(readFileSync(join(dir, "slides.yml"), "utf8"));
|
|
|
39
40
|
|
|
40
41
|
// resolve active style: slides.yml `style:` > brand.yml defaultStyle
|
|
41
42
|
const styleName = spec.style ?? brandBase.defaultStyle;
|
|
42
|
-
|
|
43
|
+
// Built-ins ship in the package; everything else is per-machine (state.js).
|
|
44
|
+
const styleDir = styleDirFor(styleName);
|
|
43
45
|
if (!existsSync(styleDir)) {
|
|
44
46
|
console.error(`Unknown style "${styleName}" — expected ${styleDir}`);
|
|
45
47
|
process.exit(1);
|
package/src/state.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Where the Carousel Maker's per-machine work lives.
|
|
2
|
+
//
|
|
3
|
+
// Generated styles and rendered decks used to be written inside the package
|
|
4
|
+
// folder (styles/<slug>, carousels/web-*). Installed from npm, that folder is
|
|
5
|
+
// ~/.goosetools/app/node_modules/goosetools-worker, which `update` deletes and
|
|
6
|
+
// reinstalls — so every update silently deleted every style the user had
|
|
7
|
+
// generated (it happened on 2026-09-26). They now live in ~/.goosetools/carousel,
|
|
8
|
+
// which nothing reinstalls. The built-in styles and the demo deck still ship
|
|
9
|
+
// in the package, because they ARE the package.
|
|
10
|
+
|
|
11
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
|
|
18
|
+
/** Styles that ship in the package (see package.json `files`). */
|
|
19
|
+
export const SHIPPED_STYLES = ["graph-paper", "polka-dot"];
|
|
20
|
+
|
|
21
|
+
export const STATE_DIR = process.env.GOOSETOOLS_CAROUSEL_DIR ?? join(homedir(), ".goosetools", "carousel");
|
|
22
|
+
export const STYLES_DIR = join(STATE_DIR, "styles");
|
|
23
|
+
/** Per-job work folders: the deck, its photos, its rendered slides. */
|
|
24
|
+
export const WORK_DIR = join(STATE_DIR, "carousels");
|
|
25
|
+
|
|
26
|
+
/** Where a style's folder is: in the package if it ships, otherwise in state. */
|
|
27
|
+
export function styleDirFor(slug) {
|
|
28
|
+
return SHIPPED_STYLES.includes(slug) ? join(ROOT, "styles", slug) : join(STYLES_DIR, slug);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Move any style that isn't a built-in out of the package folder.
|
|
33
|
+
*
|
|
34
|
+
* Only a checkout still has them there (an npm install lost them when it was
|
|
35
|
+
* replaced), but a checkout is exactly where a style made during development
|
|
36
|
+
* sits — so this runs on every start and is a no-op once done. A style
|
|
37
|
+
* already in state wins; the package copy is then just removed.
|
|
38
|
+
*/
|
|
39
|
+
export function migrateStylesOutOfPackage() {
|
|
40
|
+
const pkgStyles = join(ROOT, "styles");
|
|
41
|
+
if (!existsSync(pkgStyles)) return 0;
|
|
42
|
+
mkdirSync(STYLES_DIR, { recursive: true });
|
|
43
|
+
let moved = 0;
|
|
44
|
+
for (const d of readdirSync(pkgStyles, { withFileTypes: true })) {
|
|
45
|
+
if (!d.isDirectory() || SHIPPED_STYLES.includes(d.name)) continue;
|
|
46
|
+
const from = join(pkgStyles, d.name);
|
|
47
|
+
const to = join(STYLES_DIR, d.name);
|
|
48
|
+
if (!existsSync(to)) {
|
|
49
|
+
cpSync(from, to, { recursive: true });
|
|
50
|
+
moved++;
|
|
51
|
+
}
|
|
52
|
+
rmSync(from, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
return moved;
|
|
55
|
+
}
|
package/worker/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
|
|
|
13
13
|
import yaml from "js-yaml";
|
|
14
14
|
import { upload } from "./upload.js";
|
|
15
15
|
import { skillSection } from "./skill.js";
|
|
16
|
+
import { migrateStylesOutOfPackage, STYLES_DIR, styleDirFor, WORK_DIR } from "../src/state.js";
|
|
16
17
|
import { extFromUrl } from "./style.js";
|
|
17
18
|
import { FONT_SPECS, sanitizeFonts } from "../src/fonts.js";
|
|
18
19
|
import { assertFfmpeg, isVideoPath, posterFrame } from "../src/video.js";
|
|
@@ -120,7 +121,7 @@ const MAX_PROMPT_REFS = 6;
|
|
|
120
121
|
async function ensureStyleRefs(styleSlug, styleRefUrls) {
|
|
121
122
|
const urls = (styleRefUrls ?? []).slice(0, 20);
|
|
122
123
|
if (urls.length === 0) return [];
|
|
123
|
-
const refsDir = join(
|
|
124
|
+
const refsDir = join(styleDirFor(styleSlug), "refs");
|
|
124
125
|
const manifestPath = join(refsDir, "manifest.json");
|
|
125
126
|
|
|
126
127
|
const cachedPaths = () => {
|
|
@@ -485,10 +486,10 @@ async function draftSlides(job, styleDir, media, refPaths, brand) {
|
|
|
485
486
|
async function runJob(job) {
|
|
486
487
|
console.log(`▶ job ${job.id}: "${(job.prompt ?? "").slice(0, 60)}" [${job.styleSlug}]`);
|
|
487
488
|
|
|
488
|
-
const styleDir =
|
|
489
|
+
const styleDir = styleDirFor(job.styleSlug);
|
|
489
490
|
if (!existsSync(styleDir)) throw new Error(`Style "${job.styleSlug}" isn't installed on this computer yet`);
|
|
490
491
|
|
|
491
|
-
const dir = join(
|
|
492
|
+
const dir = join(WORK_DIR, `web-${job.id.slice(0, 8)}`);
|
|
492
493
|
mkdirSync(join(dir, "photos"), { recursive: true });
|
|
493
494
|
mkdirSync(join(dir, "videos"), { recursive: true });
|
|
494
495
|
// Older servers don't send these — default to applying the brand, matching
|
|
@@ -699,6 +700,14 @@ Do not rewrite any slide's text and do not switch templates.`;
|
|
|
699
700
|
|
|
700
701
|
// ── Main loop ────────────────────────────────────────────────────────────────
|
|
701
702
|
console.log(`Goose Tools worker connected to ${BASE_URL} — waiting for jobs (Ctrl+C to stop)`);
|
|
703
|
+
// Styles made from a checkout sat inside the package folder; move them to
|
|
704
|
+
// ~/.goosetools/carousel so an update can't delete them (src/state.js).
|
|
705
|
+
try {
|
|
706
|
+
const moved = migrateStylesOutOfPackage();
|
|
707
|
+
if (moved) console.log(`Moved ${moved} style(s) to ${STYLES_DIR}.`);
|
|
708
|
+
} catch (e) {
|
|
709
|
+
console.error(`Couldn't move styles out of the package: ${e.message}`);
|
|
710
|
+
}
|
|
702
711
|
let firstPoll = true;
|
|
703
712
|
|
|
704
713
|
while (true) {
|
package/worker/style.js
CHANGED
|
@@ -17,6 +17,7 @@ import { extname, join } from "node:path";
|
|
|
17
17
|
import yaml from "js-yaml";
|
|
18
18
|
import { upload } from "./upload.js";
|
|
19
19
|
import { skillSection } from "./skill.js";
|
|
20
|
+
import { styleDirFor, WORK_DIR } from "../src/state.js";
|
|
20
21
|
|
|
21
22
|
const RESERVED_SLUGS = new Set(["graph-paper", "polka-dot"]);
|
|
22
23
|
const REQUIRED_TEMPLATES = ["cover", "text", "photo", "cta"];
|
|
@@ -137,13 +138,13 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
|
|
|
137
138
|
console.log(`▶ style ${job.id}: "${name}" (${refUrls.length} refs) [${slug}]`);
|
|
138
139
|
|
|
139
140
|
if (RESERVED_SLUGS.has(slug)) throw new Error(`"${slug}" is a built-in style`);
|
|
140
|
-
const styleDir =
|
|
141
|
+
const styleDir = styleDirFor(slug);
|
|
141
142
|
const marker = join(styleDir, ".generated");
|
|
142
143
|
if (existsSync(styleDir) && !existsSync(marker)) {
|
|
143
|
-
throw new Error(`A hand-made style already exists at
|
|
144
|
+
throw new Error(`A hand-made style already exists at ${styleDir} on this computer`);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const workDir = join(
|
|
147
|
+
const workDir = join(WORK_DIR, `style-${job.id.slice(0, 8)}`);
|
|
147
148
|
const refsDir = join(workDir, "refs");
|
|
148
149
|
mkdirSync(refsDir, { recursive: true });
|
|
149
150
|
|
|
@@ -157,7 +158,7 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
|
|
|
157
158
|
refPaths.push(p);
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
const exemplarDir =
|
|
161
|
+
const exemplarDir = styleDirFor("graph-paper");
|
|
161
162
|
const exemplar = {
|
|
162
163
|
styleYml: readFileSync(join(exemplarDir, "style.yml"), "utf8"),
|
|
163
164
|
cover: readFileSync(join(exemplarDir, "templates", "cover.html"), "utf8"),
|
|
@@ -207,7 +208,7 @@ export async function runStyleJob(job, { api, execFileAsync, ROOT, TOKEN, BASE_U
|
|
|
207
208
|
join(styleDir, "refs", "manifest.json"),
|
|
208
209
|
JSON.stringify({ urls: refUrls.slice(0, 20) }, null, 2),
|
|
209
210
|
);
|
|
210
|
-
console.log(`
|
|
211
|
+
console.log(` ${styleDir} written (${Object.keys(files).length} files + ${refPaths.length} refs)`);
|
|
211
212
|
|
|
212
213
|
// Preview: render the new style's cover + text slide with the user's brand.
|
|
213
214
|
const previewDir = join(workDir, "preview");
|