goosetools-worker 0.1.1 → 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 +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 +65 -4
- package/worker/index.js +542 -30
- package/worker/service.js +386 -0
- package/worker/skill.js +26 -0
- package/worker/style.js +250 -0
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# carousel-maker
|
|
2
2
|
|
|
3
|
-
Brand-aware Instagram carousel generator. Photos
|
|
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
|
|
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
|
-
|
|
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`).
|
|
68
|
-
|
|
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.
|
|
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();
|