goosetools-worker 0.1.2 → 0.2.1

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # carousel-maker
2
2
 
3
- Brand-aware Instagram carousel generator. Photos + words in → ready-to-post 1080×1350 PNGs out.
3
+ Brand-aware Instagram carousel generator. Photos, clips and words in → ready-to-post 1080×1350 slides out (PNG, or MP4 for slides built on a video).
4
4
 
5
5
  ## How it works
6
6
 
7
7
  1. **`brand.yml`** — your colors, fonts, handle. Set once; every slide inherits it.
8
8
  2. **A style** — `styles/<name>/` holds a template set + `style.yml` (font/color overrides) + the reference images that inspired it. Like Recraft: drop inspo screenshots in `styles/<name>/refs/`, have the agent distill them into templates, reuse forever. `brand.yml → defaultStyle` picks the default; a carousel can override with `style: <name>` in its slides.yml.
9
- 3. **A carousel folder** — `carousels/<name>/` with a `slides.yml` (and photos alongside it).
9
+ 3. **A carousel folder** — `carousels/<name>/` with a `slides.yml` (and `photos/` + `videos/` alongside it).
10
10
  4. **Render** — HTML templates filled with your brand tokens, screenshotted headless at 2x.
11
11
 
12
12
  ## Usage
@@ -16,6 +16,9 @@ npm install && npx playwright install chromium # first time only
16
16
 
17
17
  node src/render.js carousels/my-post
18
18
  # → carousels/my-post/output/slide-01.png, slide-02.png, ...
19
+
20
+ # optional, last: only if you want video slides (see below)
21
+ brew install ffmpeg
19
22
  ```
20
23
 
21
24
  ## slides.yml
@@ -29,6 +32,9 @@ slides:
29
32
  - template: photo # your photo, text over a dark gradient
30
33
  photo: photos/desk.jpg
31
34
  text: caption over the photo
35
+ - template: photo # same template, but the background MOVES
36
+ video: videos/build.mp4
37
+ text: caption over the clip
32
38
  - template: text # dark slide, *asterisks* render in accent color
33
39
  text: |-
34
40
  body copy with
@@ -51,21 +57,149 @@ Every slide gets a progress bar + page counter baked in, and a swipe chevron on
51
57
 
52
58
  Add a template by dropping a new `templates/<name>.html` — it gets the full brand.yml context plus `{{text}}`, `{{photoSrc}}`, `{{handle}}`, `{{ui}}` (the progress bar + chevron; include it in every template). If the new template has a light background, add its name to `LIGHT_TEMPLATES` in `src/render.js` so the chrome adapts.
53
59
 
60
+ ## Video slides
61
+
62
+ Any slide that takes a `photo:` takes a `video:` instead — same template, same
63
+ `focus:` / `zoom:` framing controls, but the clip plays full-bleed behind the
64
+ slide's text. Instagram carousels accept video items, so a deck can mix stills
65
+ and motion freely.
66
+
67
+ ```yaml
68
+ - template: photo
69
+ video: videos/build.mp4
70
+ focus: "60% 45%" # centre of the subject, as on a photo
71
+ zoom: 1.8
72
+ text: watch it *come together*
73
+ ```
74
+
75
+ How it renders: the slide is screenshotted twice — once normally over a frame
76
+ pulled out of the clip (`slide-03.png` — what the framing self-check reads),
77
+ and once with every
78
+ background stripped so only the artwork remains. ffmpeg then crops the clip to
79
+ the slide shape and burns that artwork over it (`slide-03.mp4`, H.264 /
80
+ yuv420p, 1080 wide, audio kept, first 60s).
81
+
82
+ This means **no template needs to know about video** — every style's `photo`
83
+ template gained video support for free.
84
+
85
+ ### ffmpeg (optional, but required for video)
86
+
87
+ Video slides need `ffmpeg` and `ffprobe` on your PATH. They do **not** come
88
+ with Node, npm, or Playwright — Playwright bundles an ffmpeg, but it's built
89
+ `--disable-everything` for WebM screen recording: no H.264, no AAC, no
90
+ ffprobe. So it's a real install:
91
+
92
+ ```bash
93
+ brew install ffmpeg # macOS
94
+ winget install Gyan.FFmpeg # Windows
95
+ sudo apt install ffmpeg # Linux
96
+ ```
97
+
98
+ It's genuinely optional: a deck with no `video:` slides never even checks for
99
+ it. But a deck **with** clips fails loudly rather than quietly rendering them
100
+ as stills — a carousel that looks finished and silently isn't the one you
101
+ asked for is the worse outcome. `goosetools-worker status` shows whether this
102
+ computer can do video.
103
+
54
104
  ## Goose Tools worker
55
105
 
56
106
  This repo doubles as the **worker** behind goosetools.com's Carousel Maker.
57
107
  The web app queues jobs; a worker on your own computer claims them, drafts
58
108
  the slide copy with your local Claude Code login (your subscription), renders
59
- with the engine above, and uploads the finished PNGs.
109
+ with the engine above, and uploads the finished slides.
60
110
 
61
111
  ```bash
62
112
  # one-time: install Claude Code + sign in, then
63
- node worker/index.js --url https://goosetools.com --token gt_... # token from the website
113
+ npx --yes goosetools-worker install --url https://goosetools.com --token gt_... # token from the website
64
114
  ```
65
115
 
116
+ `install` saves the token to `~/.goosetools/env` and registers a login
117
+ service (launchd on macOS, a Scheduled Task on Windows) so the worker runs
118
+ in the background whenever the computer is on — even after a restart.
119
+ `npx --yes goosetools-worker uninstall` removes it. To run in the foreground
120
+ instead: `npx --yes goosetools-worker run --url … --token …`.
121
+
66
122
  Optional env: `VOICE_DIR` points at a writing-voice folder the drafts should
67
- follow (see `worker/index.js`). Erin's Mac runs it permanently via launchd —
68
- `scripts/com.ern.carousel-maker.worker.plist` (config in `worker/.env`).
123
+ follow (see `worker/index.js`).
124
+
125
+ ### Worker commands
126
+
127
+ | Command | What it does |
128
+ | --- | --- |
129
+ | `install --token gt_…` | First-time setup: save the token, install the background copy, register the service. |
130
+ | `update` | Pull the newest code into the background copy and restart it. Reuses the saved token. |
131
+ | `status` | What's installed, whether it's running, and recent log lines. Start here when something's wrong. |
132
+ | `uninstall` | Remove the service and the saved token. |
133
+ | `run` | Run the loop in this window instead of in the background. |
134
+
135
+ All of them work via `npx --yes goosetools-worker@latest <command>`.
136
+
137
+ ## Worker FAQ
138
+
139
+ **The website says my computer isn't connected.**
140
+ Run `npx --yes goosetools-worker@latest status`. It tells you whether the
141
+ worker is installed, whether the process is actually running, and whether a
142
+ token is saved. Each line is one of the things that has to be true for the
143
+ website to show "Connected".
144
+
145
+ **How do I get a new version onto my computer?**
146
+ `npx --yes goosetools-worker@latest update`.
147
+
148
+ This matters more than it looks. The background service does **not** run from
149
+ wherever you ran `npx`, or from a git checkout — it runs a copy installed
150
+ under `~/.goosetools/app`. New code only reaches that copy when you reinstall
151
+ it, and the running process only picks the new code up when it restarts (Node
152
+ reads every file once, at startup). `update` does both, and reuses your saved
153
+ token so you don't have to find it again.
154
+
155
+ **I updated but nothing changed.**
156
+ Check `status` — the "Installed" line is the version the daemon will run, and
157
+ "Running" shows the live process. If Installed is the new version but the
158
+ behavior is old, the process didn't restart; run `update` again.
159
+
160
+ **`status` shows errors — is my worker broken?**
161
+ Look at the "last written" time next to the error block. The service restarts
162
+ itself and keeps its old log, so errors from days ago sit there long after
163
+ they stopped mattering. If "Activity" was written recently and "Errors" wasn't,
164
+ the worker is fine — it's polling normally.
165
+
166
+ **"Poll failed (will retry)"** is the worker not reaching the server: laptop
167
+ asleep, wifi dropped, or the site briefly down. It retries every 30s on its
168
+ own; no action needed unless it's still happening now.
169
+
170
+ **"This computer's worker is outdated for this job"** means the website sent
171
+ a kind of job this worker version doesn't know how to do. Run `update`.
172
+
173
+ **"Style X isn't installed on this computer yet"** — styles live on the
174
+ machine that renders them (`styles/<slug>/`). A style generated on a different
175
+ computer won't exist on this one; regenerate it here.
176
+
177
+ **Jobs sit in "queued" forever.**
178
+ Queued means the website has the job but no worker has claimed it. Confirm the
179
+ worker is running with `status`, and confirm it's pointed at the same server
180
+ (the "Server" line) as the site you queued from.
181
+
182
+ **Where are the logs?**
183
+ `~/.goosetools/worker.log` (activity) and `~/.goosetools/worker.err.log`
184
+ (errors). `status` tails both.
185
+
186
+ **Do I need to keep a terminal open?** No — that's what `install` sets up.
187
+ `run` is the foreground alternative if you'd rather watch it work.
188
+
189
+ ### Publishing a new worker version
190
+
191
+ Users get updates from npm, so pushing to git is not enough — `update` and
192
+ `npx goosetools-worker@latest` both resolve to the **published** version:
193
+
194
+ ```bash
195
+ npm version patch # or minor/major
196
+ npm publish
197
+ git push --follow-tags
198
+ ```
199
+
200
+ Until you publish, `npm view goosetools-worker version` will lag the repo and
201
+ everyone else stays on the older code. `status` flags the mismatch on your own
202
+ machine ("you're ahead — local dev build").
69
203
 
70
204
  ## Roadmap
71
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goosetools-worker",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
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",
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "worker",
11
11
  "src",
12
- "styles",
12
+ "styles/graph-paper",
13
+ "styles/polka-dot",
13
14
  "carousels/demo/slides.yml",
14
15
  "brand.yml",
15
16
  "README.md"
package/src/fonts.js ADDED
@@ -0,0 +1,102 @@
1
+ // Shared font registry — the only fonts the renderer will load. Used by
2
+ // render.js (Google Fonts URL) and the worker (validating/prompting the
3
+ // top-level `fonts:` override in slides.yml).
4
+
5
+ // per-family axis specs — some families don't ship every weight
6
+ export const FONT_SPECS = {
7
+ "Instrument Serif": "ital@0;1",
8
+ "Fraunces": "ital,wght@0,400;0,500;0,600;1,400;1,500",
9
+ "Silkscreen": "wght@400;700",
10
+ "Pixelify Sans": "wght@400;500;700",
11
+ "Doto": "wght@400;700;900",
12
+ "VT323": "wght@400",
13
+ "Space Mono": "wght@400;700",
14
+ "Geist Mono": "wght@400;500",
15
+ // brand-settings shortlist (goosetools.com)
16
+ "Space Grotesk": "wght@400;500;700",
17
+ "Inter": "wght@400;500;700",
18
+ "DM Sans": "opsz,wght@9..40,400;9..40,500;9..40,700",
19
+ "DM Serif Display": "ital@0;1",
20
+ "Archivo": "wght@400;600;700",
21
+ "Bricolage Grotesque": "opsz,wght@12..96,400;12..96,700",
22
+ "Work Sans": "wght@400;500;700",
23
+ "IBM Plex Sans": "wght@400;500;700",
24
+ };
25
+
26
+ export const FONT_OVERRIDE_KEYS = ["heading", "body", "mono"];
27
+
28
+ // ── Uploaded fonts ───────────────────────────────────────────────────────────
29
+ // A font the user uploaded on goosetools.com. It arrives with the brand as
30
+ // {family, url} and is NOT in the registry above — Google has never heard of
31
+ // it, so it has to be fetched and declared as an @font-face instead.
32
+
33
+ /**
34
+ * Font container signatures — the same check the overlay worker does.
35
+ *
36
+ * The content type a browser reports for a .woff2 is routinely
37
+ * application/octet-stream, so the bytes are the only real signal. Anything
38
+ * that isn't a font (a renamed zip, an image) is dropped rather than handed to
39
+ * Chromium's font parser.
40
+ */
41
+ function sniff(buf) {
42
+ const tag = buf.subarray(0, 4).toString("latin1");
43
+ if (tag === "wOF2") return "woff2";
44
+ if (tag === "wOFF") return "woff";
45
+ if (tag === "OTTO") return "otf";
46
+ if (tag === "true" || tag === "ttcf") return "ttf";
47
+ if (buf.readUInt32BE(0) === 0x00010000) return "ttf";
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * @font-face rules for the uploaded fonts, with the file inlined.
53
+ *
54
+ * Inlined as a data URI rather than linked because the page is rendered from
55
+ * setContent with no origin to resolve a relative path against, and a font
56
+ * that 404s mid-render is a silent fallback rather than an error.
57
+ *
58
+ * One bad or unreachable font is skipped with a warning — it must not take
59
+ * down a render the rest of which is fine.
60
+ */
61
+ export async function customFontFaces(fonts) {
62
+ const out = [];
63
+ for (const f of fonts ?? []) {
64
+ if (!f?.family || !/^https:\/\//.test(String(f.url ?? ""))) continue;
65
+ try {
66
+ const res = await fetch(f.url);
67
+ if (!res.ok) throw new Error(`download failed: ${res.status}`);
68
+ const buf = Buffer.from(await res.arrayBuffer());
69
+ const format = sniff(buf);
70
+ if (!format) {
71
+ console.warn(` ! "${f.family}" isn't a font file — skipped`);
72
+ continue;
73
+ }
74
+ out.push(
75
+ `@font-face{font-family:"${f.family}";` +
76
+ `src:url("data:font/${format};base64,${buf.toString("base64")}") format("${format}");` +
77
+ `font-display:block;}`,
78
+ );
79
+ } catch (e) {
80
+ console.warn(` ! "${f.family}" skipped (${e?.message ?? e})`);
81
+ }
82
+ }
83
+ return out.join("\n");
84
+ }
85
+
86
+ // Per-carousel font overrides (slides.yml top-level `fonts:`) may only name
87
+ // families from the registry; unknown keys/families are dropped, never fatal.
88
+ export function sanitizeFonts(fonts, ownFamilies = []) {
89
+ if (!fonts || typeof fonts !== "object" || Array.isArray(fonts)) return null;
90
+ // A family the user uploaded is as legitimate as a registry one — it is
91
+ // declared as an @font-face instead of fetched from Google, and dropping it
92
+ // here is what made "use my brand font" silently fall back to the style's.
93
+ const own = new Set(ownFamilies);
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(fonts)) {
96
+ if (FONT_OVERRIDE_KEYS.includes(k) && (FONT_SPECS[String(v)] || own.has(String(v)))) {
97
+ out[k] = String(v);
98
+ }
99
+ if (k === "headingWeight" && Number.isFinite(Number(v))) out.headingWeight = Number(v);
100
+ }
101
+ return Object.keys(out).length > 0 ? out : null;
102
+ }
@@ -0,0 +1,53 @@
1
+ // Normalize photo orientation: iPhone JPEGs store sideways pixels + an EXIF
2
+ // "rotate me" flag. Browsers honor the flag; raw-pixel readers don't — so
3
+ // focus/zoom coordinates estimated from the raw file disagree with the
4
+ // rendered crop. Re-encoding through Chromium's decoder bakes the rotation
5
+ // into the pixels (and strips the flag), making both views agree.
6
+ //
7
+ // usage: node src/normalize-photos.js <photos-dir>
8
+
9
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { extname, join, resolve } from "node:path";
11
+ import { chromium } from "playwright";
12
+
13
+ const dir = resolve(process.argv[2] ?? "");
14
+ if (!dir) {
15
+ console.error("usage: node src/normalize-photos.js <photos-dir>");
16
+ process.exit(1);
17
+ }
18
+
19
+ const MIME = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp" };
20
+ const files = readdirSync(dir).filter((f) => MIME[extname(f).toLowerCase()]);
21
+ if (files.length === 0) process.exit(0);
22
+
23
+ const browser = await chromium.launch({ args: ["--mute-audio", "--use-mock-keychain"] });
24
+ const page = await browser.newPage();
25
+
26
+ for (const f of files) {
27
+ const p = join(dir, f);
28
+ const mime = MIME[extname(f).toLowerCase()];
29
+ const dataUri = `data:${mime};base64,${readFileSync(p).toString("base64")}`;
30
+ const out = await page.evaluate(
31
+ async ({ src, mime }) => {
32
+ const img = new Image();
33
+ await new Promise((ok, err) => {
34
+ img.onload = ok;
35
+ img.onerror = err;
36
+ img.src = src;
37
+ });
38
+ // naturalWidth/Height are post-EXIF-rotation in Chromium; drawing to a
39
+ // canvas bakes the upright orientation into the pixels.
40
+ const canvas = document.createElement("canvas");
41
+ canvas.width = img.naturalWidth;
42
+ canvas.height = img.naturalHeight;
43
+ canvas.getContext("2d").drawImage(img, 0, 0);
44
+ return canvas.toDataURL(mime, 0.92);
45
+ },
46
+ { src: dataUri, mime },
47
+ );
48
+ // Same filename, same format — nothing downstream has to know.
49
+ writeFileSync(p, Buffer.from(out.split(",")[1], "base64"));
50
+ console.log(`normalized ${f}`);
51
+ }
52
+
53
+ await browser.close();