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/src/render.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, mkdirSync, existsSync } from "node:fs";
|
|
1
|
+
import { readFileSync, readdirSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { join, dirname, resolve, extname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
5
|
import { chromium } from "playwright";
|
|
6
|
+
import { FONT_SPECS, customFontFaces, sanitizeFonts } from "./fonts.js";
|
|
7
|
+
import { assertFfmpeg, composite, posterFrame } from "./video.js";
|
|
6
8
|
|
|
7
9
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
10
|
|
|
@@ -27,7 +29,12 @@ if (!carouselDir) {
|
|
|
27
29
|
process.exit(1);
|
|
28
30
|
}
|
|
29
31
|
const dir = resolve(carouselDir);
|
|
30
|
-
|
|
32
|
+
// A carousel dir may carry its own brand.yml (written by the worker from the
|
|
33
|
+
// user's saved brand); fall back to the repo brand for local CLI use.
|
|
34
|
+
const brandPath = existsSync(join(dir, "brand.yml"))
|
|
35
|
+
? join(dir, "brand.yml")
|
|
36
|
+
: join(ROOT, "brand.yml");
|
|
37
|
+
const brandBase = yaml.load(readFileSync(brandPath, "utf8"));
|
|
31
38
|
const spec = yaml.load(readFileSync(join(dir, "slides.yml"), "utf8"));
|
|
32
39
|
|
|
33
40
|
// resolve active style: slides.yml `style:` > brand.yml defaultStyle
|
|
@@ -39,11 +46,46 @@ if (!existsSync(styleDir)) {
|
|
|
39
46
|
}
|
|
40
47
|
const stylePath = join(styleDir, "style.yml");
|
|
41
48
|
const styleSpec = existsSync(stylePath) ? yaml.load(readFileSync(stylePath, "utf8")) : {};
|
|
42
|
-
//
|
|
49
|
+
// Shallow merge per top-level key (fonts/colors/style). The style normally
|
|
50
|
+
// wins — it was designed as a coherent look. But the carousel's brand.yml can
|
|
51
|
+
// ask for the user's brand to win instead, per key: applyBrandFonts renders
|
|
52
|
+
// the deck in their typography, applyBrandColors in their palette. The worker
|
|
53
|
+
// writes those from the per-carousel checkboxes; a bare CLI render leaves them
|
|
54
|
+
// unset and keeps the style's own look.
|
|
55
|
+
const brandWins = {
|
|
56
|
+
fonts: brandBase.applyBrandFonts === true,
|
|
57
|
+
colors: brandBase.applyBrandColors === true,
|
|
58
|
+
style: false, // layout tokens (padding/radius) always belong to the style
|
|
59
|
+
};
|
|
43
60
|
const brand = { ...brandBase };
|
|
44
61
|
for (const key of ["fonts", "colors", "style"]) {
|
|
45
|
-
if (styleSpec[key])
|
|
62
|
+
if (!styleSpec[key]) continue;
|
|
63
|
+
brand[key] = brandWins[key]
|
|
64
|
+
? { ...styleSpec[key], ...brandBase[key] }
|
|
65
|
+
: { ...brandBase[key], ...styleSpec[key] };
|
|
66
|
+
}
|
|
67
|
+
// Fonts the user uploaded, sent with their brand. Declared as @font-face rules
|
|
68
|
+
// below; named here because everything downstream has to treat them as valid
|
|
69
|
+
// families even though the registry has never heard of them.
|
|
70
|
+
const CUSTOM_FONTS = (brand.customFonts ?? []).filter((f) => f?.family);
|
|
71
|
+
const customFamilies = new Set(CUSTOM_FONTS.map((f) => f.family));
|
|
72
|
+
|
|
73
|
+
// per-carousel color overrides (slides.yml top-level `colors:`) win over the
|
|
74
|
+
// style — the drafting pass sets them when the user's photos share a palette
|
|
75
|
+
// worth building the deck from. Valid hex values on known keys only.
|
|
76
|
+
if (spec.colors && typeof spec.colors === "object" && !Array.isArray(spec.colors)) {
|
|
77
|
+
const overrides = Object.fromEntries(
|
|
78
|
+
Object.entries(spec.colors).filter(
|
|
79
|
+
([k, v]) => k in (brand.colors ?? {}) && /^#[0-9a-fA-F]{3,8}$/.test(String(v)),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
brand.colors = { ...brand.colors, ...overrides };
|
|
46
83
|
}
|
|
84
|
+
// per-carousel font overrides (slides.yml top-level `fonts:`) win over the
|
|
85
|
+
// style — set when the user asks for a deck-wide typography change (e.g.
|
|
86
|
+
// "use my brand font"). Registry families only.
|
|
87
|
+
const fontOverrides = sanitizeFonts(spec.fonts, [...customFamilies]);
|
|
88
|
+
if (fontOverrides) brand.fonts = { ...brand.fonts, ...fontOverrides };
|
|
47
89
|
const slides = spec.slides ?? [];
|
|
48
90
|
if (!slides.length) {
|
|
49
91
|
console.error("slides.yml has no slides");
|
|
@@ -76,33 +118,99 @@ function photoDataUri(photoPath) {
|
|
|
76
118
|
return `data:${mime};base64,${readFileSync(p).toString("base64")}`;
|
|
77
119
|
}
|
|
78
120
|
|
|
121
|
+
// Real zoom: cut a window out of the photo CENTERED on the focus point, so
|
|
122
|
+
// the subject lands in the middle of the slide by construction (CSS
|
|
123
|
+
// object-position keeps the subject at its original frame position, which
|
|
124
|
+
// reads as off-center — this doesn't).
|
|
125
|
+
async function croppedPhotoDataUri(pg, photoPath, fx, fy, zoom) {
|
|
126
|
+
const src = photoDataUri(photoPath);
|
|
127
|
+
return pg.evaluate(
|
|
128
|
+
async ({ src, fx, fy, zoom }) => {
|
|
129
|
+
const img = new Image();
|
|
130
|
+
await new Promise((ok, err) => {
|
|
131
|
+
img.onload = ok;
|
|
132
|
+
img.onerror = err;
|
|
133
|
+
img.src = src;
|
|
134
|
+
});
|
|
135
|
+
const W = img.naturalWidth;
|
|
136
|
+
const H = img.naturalHeight;
|
|
137
|
+
const sw = W / zoom;
|
|
138
|
+
const sh = H / zoom;
|
|
139
|
+
const sx = Math.min(Math.max(fx * W - sw / 2, 0), W - sw);
|
|
140
|
+
const sy = Math.min(Math.max(fy * H - sh / 2, 0), H - sh);
|
|
141
|
+
const canvas = document.createElement("canvas");
|
|
142
|
+
canvas.width = Math.round(sw);
|
|
143
|
+
canvas.height = Math.round(sh);
|
|
144
|
+
canvas.getContext("2d").drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
145
|
+
return canvas.toDataURL("image/jpeg", 0.92);
|
|
146
|
+
},
|
|
147
|
+
{ src, fx, fy, zoom },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const FOCUS_KEYWORDS = {
|
|
152
|
+
"top": [0.5, 0], "center": [0.5, 0.5], "bottom": [0.5, 1],
|
|
153
|
+
"left": [0, 0.5], "right": [1, 0.5],
|
|
154
|
+
"left top": [0, 0], "left bottom": [0, 1],
|
|
155
|
+
"right top": [1, 0], "right bottom": [1, 1],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function focusPoint(focus) {
|
|
159
|
+
if (typeof focus !== "string") return { fx: 0.5, fy: 0.5 };
|
|
160
|
+
const kw = FOCUS_KEYWORDS[focus.trim()];
|
|
161
|
+
if (kw) return { fx: kw[0], fy: kw[1] };
|
|
162
|
+
const m = /^(\d{1,3})% (\d{1,3})%$/.exec(focus.trim());
|
|
163
|
+
if (!m) return { fx: 0.5, fy: 0.5 };
|
|
164
|
+
return { fx: Math.min(100, Number(m[1])) / 100, fy: Math.min(100, Number(m[2])) / 100 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function zoomOf(slide) {
|
|
168
|
+
const z = Number(slide.zoom);
|
|
169
|
+
return Number.isFinite(z) && z > 1 && z <= 3 ? z : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function slidePhotoSrc(slide, photoPath) {
|
|
173
|
+
const z = zoomOf(slide);
|
|
174
|
+
if (!z) return photoDataUri(photoPath);
|
|
175
|
+
const { fx, fy } = focusPoint(slide.focus);
|
|
176
|
+
return croppedPhotoDataUri(page, photoPath, fx, fy, z);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A video slide's still: the poster frame, cached next to the clip. It is both
|
|
180
|
+
// what the drafting/self-check passes look at and the PNG fallback, so the
|
|
181
|
+
// deck still makes sense if the video never composites.
|
|
182
|
+
async function posterFor(slide) {
|
|
183
|
+
const src = resolve(dir, slide.video);
|
|
184
|
+
const out = src.replace(/\.[^.]+$/, "") + ".poster.jpg";
|
|
185
|
+
return posterFrame(src, out);
|
|
186
|
+
}
|
|
187
|
+
|
|
79
188
|
// which templates sit on a light background (UI chrome adapts)
|
|
80
|
-
const LIGHT_TEMPLATES = new Set(["light", "resource", "cover", "text", "cta"]);
|
|
189
|
+
const LIGHT_TEMPLATES = new Set(["light", "resource", "cover", "text", "cta", "closer", "detail"]);
|
|
81
190
|
|
|
82
191
|
// no per-slide chrome — arrows, counters, and progress bars all rejected (Erin, 2026-07-14)
|
|
83
192
|
function slideUi() {
|
|
84
193
|
return "";
|
|
85
194
|
}
|
|
86
195
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"Pixelify Sans": "wght@400;500;700",
|
|
93
|
-
"Doto": "wght@400;700;900",
|
|
94
|
-
"VT323": "wght@400",
|
|
95
|
-
"Space Mono": "wght@400;700",
|
|
96
|
-
"Geist Mono": "wght@400;500",
|
|
97
|
-
};
|
|
196
|
+
// An uploaded family MUST be kept out of the Google Fonts URL: css2 answers
|
|
197
|
+
// 400 for a family it doesn't know, which drops the whole stylesheet and takes
|
|
198
|
+
// every other font on the slide down with it.
|
|
199
|
+
const customFaces = await customFontFaces(CUSTOM_FONTS);
|
|
200
|
+
|
|
98
201
|
const fontFamilies = [brand.fonts.heading, brand.fonts.body, brand.fonts.mono, brand.fonts.pixel]
|
|
99
202
|
.filter(Boolean)
|
|
203
|
+
.filter((f) => !customFamilies.has(f))
|
|
100
204
|
.map((f) => `family=${f.replace(/ /g, "+")}:${FONT_SPECS[f] ?? "wght@400;500;700"}`)
|
|
101
205
|
.join("&");
|
|
102
206
|
|
|
103
207
|
// style background — flat patterns only (no visual gradients)
|
|
104
208
|
const [gr, gg, gb] = (brand.colors.primary.match(/\w\w/g) ?? []).map((h) => parseInt(h, 16));
|
|
105
209
|
function styleBg() {
|
|
210
|
+
if (styleSpec.background === "flat") {
|
|
211
|
+
return `
|
|
212
|
+
background-color: ${brand.colors.lightBg};`;
|
|
213
|
+
}
|
|
106
214
|
if (styleSpec.background === "dots") {
|
|
107
215
|
// offset polka dots in pale brand blue
|
|
108
216
|
const dot = `%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88'%3E%3Ccircle cx='22' cy='22' r='9' fill='rgba(${gr},${gg},${gb},0.14)'/%3E%3Ccircle cx='66' cy='66' r='9' fill='rgba(${gr},${gg},${gb},0.14)'/%3E%3C/svg%3E`;
|
|
@@ -124,8 +232,9 @@ const GRID_BG = styleBg();
|
|
|
124
232
|
|
|
125
233
|
function pageHtml(slideHtml) {
|
|
126
234
|
return `<!doctype html><html><head><meta charset="utf-8">
|
|
127
|
-
|
|
235
|
+
${fontFamilies ? `<link href="https://fonts.googleapis.com/css2?${fontFamilies}&display=block" rel="stylesheet">` : ""}
|
|
128
236
|
<style>
|
|
237
|
+
${customFaces}
|
|
129
238
|
* { margin: 0; box-sizing: border-box; }
|
|
130
239
|
html, body { width: ${WIDTH}px; height: ${HEIGHT}px; overflow: hidden; }
|
|
131
240
|
.slide { position: relative; width: ${WIDTH}px; height: ${HEIGHT}px; padding: ${brand.style.padding}px; overflow: hidden; }
|
|
@@ -135,7 +244,23 @@ function pageHtml(slideHtml) {
|
|
|
135
244
|
const outDir = outOverride ? resolve(outOverride) : join(dir, "output");
|
|
136
245
|
mkdirSync(outDir, { recursive: true });
|
|
137
246
|
|
|
138
|
-
|
|
247
|
+
// The extra args stop Chromium probing macOS media services (Apple Music /
|
|
248
|
+
// media library), which otherwise triggers a permission prompt for "node"
|
|
249
|
+
// on every worker run.
|
|
250
|
+
const browser = await chromium.launch({
|
|
251
|
+
args: [
|
|
252
|
+
"--mute-audio",
|
|
253
|
+
"--disable-features=MediaRouter,AudioServiceOutOfProcess",
|
|
254
|
+
"--use-mock-keychain",
|
|
255
|
+
"--disable-component-update",
|
|
256
|
+
// Stop Chromium probing other apps / default-browser state — each probe
|
|
257
|
+
// is a macOS "access data from other apps" permission prompt.
|
|
258
|
+
"--no-default-browser-check",
|
|
259
|
+
"--disable-default-apps",
|
|
260
|
+
"--no-first-run",
|
|
261
|
+
"--disable-sync",
|
|
262
|
+
],
|
|
263
|
+
});
|
|
139
264
|
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: 2 });
|
|
140
265
|
|
|
141
266
|
// live screenshot of a resource's site, embedded as a receipt on the slide
|
|
@@ -151,6 +276,25 @@ async function siteScreenshot(url) {
|
|
|
151
276
|
}
|
|
152
277
|
}
|
|
153
278
|
|
|
279
|
+
// Video slides need ffmpeg (a native binary npm can't install for the user).
|
|
280
|
+
// A deck with no clips never checks; a deck with clips stops here rather than
|
|
281
|
+
// quietly rendering them as stills.
|
|
282
|
+
const hasVideo = slides.some((s) => s.video);
|
|
283
|
+
if (hasVideo) {
|
|
284
|
+
try {
|
|
285
|
+
await assertFfmpeg();
|
|
286
|
+
} catch (e) {
|
|
287
|
+
console.error(e.message);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Artwork-only pass: strip every background the slide paints so the clip
|
|
293
|
+
// shows through. Child backgrounds (caption boxes, scrims) survive on
|
|
294
|
+
// purpose — those ARE the overlay.
|
|
295
|
+
const TRANSPARENT_CSS =
|
|
296
|
+
'<style>html, body, .slide { background: transparent !important; background-image: none !important; }</style>';
|
|
297
|
+
|
|
154
298
|
for (const [i, slide] of slides.entries()) {
|
|
155
299
|
const templatePath = join(styleDir, "templates", `${slide.template}.html`);
|
|
156
300
|
if (!existsSync(templatePath)) throw new Error(`Unknown template: ${slide.template}`);
|
|
@@ -158,14 +302,42 @@ for (const [i, slide] of slides.entries()) {
|
|
|
158
302
|
if (slide.url && !slide.screenshot) {
|
|
159
303
|
console.log(` capturing ${slide.url} ...`);
|
|
160
304
|
}
|
|
305
|
+
// Optional per-slide color swatches (e.g. palette-presentation formats):
|
|
306
|
+
// slides.yml `swatches: ["#aabbcc", ...]` renders as .swatch spans that the
|
|
307
|
+
// template lays out and styles.
|
|
308
|
+
const swatches = Array.isArray(slide.swatches)
|
|
309
|
+
? slide.swatches
|
|
310
|
+
.filter((c) => /^#[0-9a-fA-F]{3,8}$/.test(String(c)))
|
|
311
|
+
.map(
|
|
312
|
+
(c) =>
|
|
313
|
+
`<span class="swatch" style="background:${c}"><span class="swatch-label">${c}</span></span>`,
|
|
314
|
+
)
|
|
315
|
+
.join("")
|
|
316
|
+
: "";
|
|
317
|
+
// The still this slide is built on: an attached photo, or — for a video
|
|
318
|
+
// slide — a frame pulled out of the clip.
|
|
319
|
+
let stillPath = slide.photo ?? null;
|
|
320
|
+
if (!stillPath && slide.video) {
|
|
321
|
+
try {
|
|
322
|
+
stillPath = await posterFor(slide);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
throw new Error(`Couldn't read a frame from ${slide.video}: ${e.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
161
327
|
const ctx = {
|
|
162
328
|
...brand,
|
|
163
329
|
gridBg: GRID_BG,
|
|
164
330
|
text: richText(slide.text ?? ""),
|
|
165
331
|
name: escapeHtml(slide.name ?? ""),
|
|
166
332
|
subtitle: escapeHtml(slide.subtitle ?? ""),
|
|
333
|
+
swatches,
|
|
334
|
+
// "flip" mirrors a template's movable overlay to the opposite side —
|
|
335
|
+
// templates opt in by putting {{variant}} in their root class list.
|
|
336
|
+
variant: slide.variant === "flip" ? "flip" : "",
|
|
167
337
|
handle: brand.handle,
|
|
168
|
-
photoSrc:
|
|
338
|
+
photoSrc: stillPath ? await slidePhotoSrc(slide, stillPath) : "",
|
|
339
|
+
logoSrc: slide.logo ? photoDataUri(slide.logo) : "",
|
|
340
|
+
cutoutSrc: slide.cutout ? photoDataUri(slide.cutout) : "",
|
|
169
341
|
screenshotSrc: slide.screenshot
|
|
170
342
|
? photoDataUri(slide.screenshot)
|
|
171
343
|
: slide.url
|
|
@@ -173,11 +345,68 @@ for (const [i, slide] of slides.entries()) {
|
|
|
173
345
|
: "",
|
|
174
346
|
ui: slideUi(i, slides.length, isLight, i === slides.length - 1),
|
|
175
347
|
};
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
348
|
+
const templateSrc = readFileSync(templatePath, "utf8");
|
|
349
|
+
// Same template, same fixups, twice: once with the still behind it, once
|
|
350
|
+
// with nothing behind it (the artwork a video slide gets burned over).
|
|
351
|
+
function buildSlide(slideCtx) {
|
|
352
|
+
let slideHtml = fill(templateSrc, slideCtx);
|
|
353
|
+
// Drop image tags whose source resolved to nothing (e.g. a photo-optional
|
|
354
|
+
// template used without a photo) — otherwise they render broken-image icons.
|
|
355
|
+
slideHtml = slideHtml.replace(/<img\b[^>]*src=""[^>]*>/g, "");
|
|
356
|
+
// A blank brand handle (campaign decks running on someone else's channel)
|
|
357
|
+
// drops the handle label entirely — otherwise the bare "[ ]" brackets render.
|
|
358
|
+
if (!brand.handle) {
|
|
359
|
+
slideHtml = slideHtml.replace(/<div class="(?:handle|follow)">[^<]*<\/div>/g, "");
|
|
360
|
+
}
|
|
361
|
+
// focus without zoom: no crop window to center, so aim object-fit instead.
|
|
362
|
+
if (!zoomOf(slide) && typeof slide.focus === "string" && slide.focus.trim()) {
|
|
363
|
+
slideHtml += `<style>.slide img { object-position: ${slide.focus} !important; }</style>`;
|
|
364
|
+
}
|
|
365
|
+
// With a pre-cropped window the subject sits at the window's center —
|
|
366
|
+
// override template object-position (e.g. "left top") so it stays there.
|
|
367
|
+
if (stillPath && zoomOf(slide)) {
|
|
368
|
+
slideHtml += `<style>.slide img { object-position: center !important; }</style>`;
|
|
369
|
+
}
|
|
370
|
+
return slideHtml;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const stem = `slide-${String(i + 1).padStart(2, "0")}`;
|
|
374
|
+
await page.setContent(pageHtml(buildSlide(ctx)), { waitUntil: "networkidle" });
|
|
375
|
+
const out = join(outDir, `${stem}.png`);
|
|
179
376
|
await page.screenshot({ path: out });
|
|
180
377
|
console.log(`✓ ${out}`);
|
|
378
|
+
|
|
379
|
+
// Video slide: re-shoot the same slide with nothing behind the artwork, then
|
|
380
|
+
// let ffmpeg burn that over the clip. The PNG above stays on disk as the
|
|
381
|
+
// fallback and as what the worker's framing self-check inspects.
|
|
382
|
+
if (slide.video) {
|
|
383
|
+
const overlayPng = join(outDir, `.${stem}-overlay.png`);
|
|
384
|
+
const mp4 = join(outDir, `${stem}.mp4`);
|
|
385
|
+
try {
|
|
386
|
+
await page.setContent(pageHtml(buildSlide({ ...ctx, photoSrc: "" })) + TRANSPARENT_CSS, {
|
|
387
|
+
waitUntil: "networkidle",
|
|
388
|
+
});
|
|
389
|
+
await page.screenshot({ path: overlayPng, omitBackground: true });
|
|
390
|
+
const { fx, fy } = focusPoint(slide.focus);
|
|
391
|
+
await composite({
|
|
392
|
+
videoPath: resolve(dir, slide.video),
|
|
393
|
+
overlayPath: overlayPng,
|
|
394
|
+
outPath: mp4,
|
|
395
|
+
W: WIDTH,
|
|
396
|
+
H: HEIGHT,
|
|
397
|
+
fx,
|
|
398
|
+
fy,
|
|
399
|
+
zoom: zoomOf(slide),
|
|
400
|
+
});
|
|
401
|
+
console.log(`✓ ${mp4}`);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
// A half-written MP4 is worse than none: it would upload as the slide.
|
|
404
|
+
rmSync(mp4, { force: true });
|
|
405
|
+
throw new Error(`Video slide ${i + 1} (${slide.video}) failed to render: ${e.message}`);
|
|
406
|
+
} finally {
|
|
407
|
+
rmSync(overlayPng, { force: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
181
410
|
}
|
|
182
411
|
|
|
183
412
|
await browser.close();
|
package/src/video.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Video slides — the ffmpeg half of the render engine.
|
|
2
|
+
//
|
|
3
|
+
// A video slide is a photo slide whose background moves: the template renders
|
|
4
|
+
// exactly as always, but its artwork (text, scrim, caption box) is captured as
|
|
5
|
+
// a TRANSPARENT PNG and burned over the user's clip instead of over a still.
|
|
6
|
+
// So every style's photo template gets video support for free — no new
|
|
7
|
+
// templates, no per-style work.
|
|
8
|
+
//
|
|
9
|
+
// Two things the rest of the engine needs from here:
|
|
10
|
+
// posterFrame() — a still from the clip, so the drafting pass and the
|
|
11
|
+
// framing self-check can SEE what the video shows (Claude
|
|
12
|
+
// reads images, not MP4s) and so a video slide still has a
|
|
13
|
+
// PNG fallback.
|
|
14
|
+
// composite() — overlay PNG + clip → a 1080-wide MP4 Instagram accepts.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
export const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
|
|
23
|
+
|
|
24
|
+
export function isVideoPath(p) {
|
|
25
|
+
const dot = String(p).lastIndexOf(".");
|
|
26
|
+
return dot !== -1 && VIDEO_EXTS.has(String(p).slice(dot).toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Instagram plays carousel video slides up to 60s; anything longer is the
|
|
30
|
+
// user's mistake, not ours to guess at, so we take the first minute.
|
|
31
|
+
export const MAX_SECONDS = 60;
|
|
32
|
+
|
|
33
|
+
let ffmpegChecked = null;
|
|
34
|
+
|
|
35
|
+
// ffmpeg is not a dependency npm can install for the user (it's a native
|
|
36
|
+
// binary), so it stays an optional setup step — checked only when a carousel
|
|
37
|
+
// actually has a clip on it.
|
|
38
|
+
export async function hasFfmpeg() {
|
|
39
|
+
if (ffmpegChecked !== null) return ffmpegChecked;
|
|
40
|
+
try {
|
|
41
|
+
await execFileAsync("ffmpeg", ["-version"], { timeout: 15_000 });
|
|
42
|
+
await execFileAsync("ffprobe", ["-version"], { timeout: 15_000 });
|
|
43
|
+
ffmpegChecked = true;
|
|
44
|
+
} catch {
|
|
45
|
+
ffmpegChecked = false;
|
|
46
|
+
}
|
|
47
|
+
return ffmpegChecked;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The one command that fixes it, for the machine we're actually on.
|
|
51
|
+
export const FFMPEG_INSTALL_CMD =
|
|
52
|
+
process.platform === "win32"
|
|
53
|
+
? "winget install Gyan.FFmpeg"
|
|
54
|
+
: process.platform === "darwin"
|
|
55
|
+
? "brew install ffmpeg"
|
|
56
|
+
: "sudo apt install ffmpeg";
|
|
57
|
+
|
|
58
|
+
// ffmpeg is optional right up until a carousel actually has a clip on it —
|
|
59
|
+
// then it is the whole feature, and silently shipping stills would hand back a
|
|
60
|
+
// deck that looks finished and isn't. So this reads as a fixable setup step,
|
|
61
|
+
// not a failure of the request.
|
|
62
|
+
export const FFMPEG_HELP =
|
|
63
|
+
`Video slides need ffmpeg, and it isn't installed on this computer.\n` +
|
|
64
|
+
`Install it, then try again:\n\n` +
|
|
65
|
+
` ${FFMPEG_INSTALL_CMD}\n\n` +
|
|
66
|
+
`(Photo-only carousels don't need it.)`;
|
|
67
|
+
|
|
68
|
+
// Throws unless BOTH binaries are present — ffprobe ships alongside ffmpeg in
|
|
69
|
+
// every packaged build, so one without the other means a partial install.
|
|
70
|
+
export async function assertFfmpeg() {
|
|
71
|
+
if (!(await hasFfmpeg())) throw new Error(FFMPEG_HELP);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Native pixel dimensions, duration, and whether there's a sound track.
|
|
75
|
+
// ffprobe reports the STORED dimensions; phone clips are often stored
|
|
76
|
+
// sideways with a rotation flag, so we swap w/h when the display matrix says
|
|
77
|
+
// the picture is quarter-turned — otherwise every portrait iPhone clip gets
|
|
78
|
+
// cropped as if it were landscape.
|
|
79
|
+
export async function probe(path) {
|
|
80
|
+
const { stdout } = await execFileAsync(
|
|
81
|
+
"ffprobe",
|
|
82
|
+
[
|
|
83
|
+
"-v", "error",
|
|
84
|
+
"-show_entries", "stream=index,codec_type,width,height,duration:stream_side_data=rotation:format=duration",
|
|
85
|
+
"-of", "json",
|
|
86
|
+
path,
|
|
87
|
+
],
|
|
88
|
+
{ timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
|
|
89
|
+
);
|
|
90
|
+
const info = JSON.parse(stdout);
|
|
91
|
+
const streams = info.streams ?? [];
|
|
92
|
+
const v = streams.find((s) => s.codec_type === "video");
|
|
93
|
+
if (!v?.width || !v?.height) throw new Error(`no video track in ${path}`);
|
|
94
|
+
|
|
95
|
+
const rotation = Number(
|
|
96
|
+
v.side_data_list?.find((d) => d.rotation !== undefined)?.rotation ?? 0,
|
|
97
|
+
);
|
|
98
|
+
const quarterTurned = Math.abs(rotation) % 180 === 90;
|
|
99
|
+
|
|
100
|
+
const duration =
|
|
101
|
+
Number(v.duration) || Number(info.format?.duration) || 0;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
width: quarterTurned ? v.height : v.width,
|
|
105
|
+
height: quarterTurned ? v.width : v.height,
|
|
106
|
+
duration: duration > 0 ? duration : MAX_SECONDS,
|
|
107
|
+
hasAudio: streams.some((s) => s.codec_type === "audio"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A representative still. Grabbing frame 0 tends to catch a black or blurry
|
|
112
|
+
// first frame, so we seek a little way in (but never past a short clip).
|
|
113
|
+
export async function posterFrame(videoPath, outPath, { at } = {}) {
|
|
114
|
+
if (existsSync(outPath)) return outPath;
|
|
115
|
+
let seek = at;
|
|
116
|
+
if (seek === undefined) {
|
|
117
|
+
const { duration } = await probe(videoPath);
|
|
118
|
+
seek = Math.min(1, duration / 4);
|
|
119
|
+
}
|
|
120
|
+
await execFileAsync(
|
|
121
|
+
"ffmpeg",
|
|
122
|
+
[
|
|
123
|
+
"-y",
|
|
124
|
+
"-ss", String(seek),
|
|
125
|
+
"-i", videoPath,
|
|
126
|
+
"-frames:v", "1",
|
|
127
|
+
// Bake in the rotation flag the same way a browser would, so focus/zoom
|
|
128
|
+
// coordinates read off the poster match the composited video.
|
|
129
|
+
"-vf", "scale=iw:ih",
|
|
130
|
+
"-q:v", "3",
|
|
131
|
+
outPath,
|
|
132
|
+
],
|
|
133
|
+
{ timeout: 5 * 60_000, maxBuffer: 10 * 1024 * 1024 },
|
|
134
|
+
);
|
|
135
|
+
return outPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The crop window, in scaled-video pixels. Same model as the photo path:
|
|
139
|
+
// "cover" the slide, then slide the window so the focus point sits at its
|
|
140
|
+
// center — which puts the subject dead center of the slide by construction.
|
|
141
|
+
export function cropWindow({ width, height, W, H, fx, fy, zoom }) {
|
|
142
|
+
const scale = Math.max(W / width, H / height) * (zoom > 1 ? zoom : 1);
|
|
143
|
+
// Even dimensions keep yuv420p happy.
|
|
144
|
+
const even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
145
|
+
const sw = even(width * scale);
|
|
146
|
+
const sh = even(height * scale);
|
|
147
|
+
const x = Math.round(Math.min(Math.max(fx * sw - W / 2, 0), sw - W));
|
|
148
|
+
const y = Math.round(Math.min(Math.max(fy * sh - H / 2, 0), sh - H));
|
|
149
|
+
return { sw, sh, x, y };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Burn the slide's artwork over the clip and encode what Instagram wants:
|
|
153
|
+
// H.264 / yuv420p, even dimensions, faststart, AAC audio if the clip had any.
|
|
154
|
+
export async function composite({
|
|
155
|
+
videoPath,
|
|
156
|
+
overlayPath,
|
|
157
|
+
outPath,
|
|
158
|
+
W,
|
|
159
|
+
H,
|
|
160
|
+
fx = 0.5,
|
|
161
|
+
fy = 0.5,
|
|
162
|
+
zoom = 0,
|
|
163
|
+
fps = 30,
|
|
164
|
+
}) {
|
|
165
|
+
const meta = await probe(videoPath);
|
|
166
|
+
const { sw, sh, x, y } = cropWindow({
|
|
167
|
+
width: meta.width,
|
|
168
|
+
height: meta.height,
|
|
169
|
+
W,
|
|
170
|
+
H,
|
|
171
|
+
fx,
|
|
172
|
+
fy,
|
|
173
|
+
zoom,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const filter = [
|
|
177
|
+
`[0:v]scale=${sw}:${sh},crop=${W}:${H}:${x}:${y},setsar=1,fps=${fps}[bg]`,
|
|
178
|
+
// The overlay is screenshotted at deviceScaleFactor 2, so it comes in at
|
|
179
|
+
// 2x and gets scaled back down here — which also anti-aliases the text.
|
|
180
|
+
`[1:v]scale=${W}:${H}[ov]`,
|
|
181
|
+
`[bg][ov]overlay=0:0:format=auto[v]`,
|
|
182
|
+
].join(";");
|
|
183
|
+
|
|
184
|
+
const args = [
|
|
185
|
+
"-y",
|
|
186
|
+
"-t", String(Math.min(meta.duration, MAX_SECONDS)),
|
|
187
|
+
"-i", videoPath,
|
|
188
|
+
"-i", overlayPath,
|
|
189
|
+
"-filter_complex", filter,
|
|
190
|
+
"-map", "[v]",
|
|
191
|
+
...(meta.hasAudio ? ["-map", "0:a:0", "-c:a", "aac", "-b:a", "128k"] : ["-an"]),
|
|
192
|
+
"-c:v", "libx264",
|
|
193
|
+
"-preset", "medium",
|
|
194
|
+
"-crf", "20",
|
|
195
|
+
"-pix_fmt", "yuv420p",
|
|
196
|
+
"-profile:v", "high",
|
|
197
|
+
"-movflags", "+faststart",
|
|
198
|
+
outPath,
|
|
199
|
+
];
|
|
200
|
+
await execFileAsync("ffmpeg", args, {
|
|
201
|
+
timeout: 20 * 60_000,
|
|
202
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
return outPath;
|
|
205
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<div class="slide closer">
|
|
2
|
+
<div class="inner">
|
|
3
|
+
<div class="handle">[ {{handle}} ]</div>
|
|
4
|
+
<div class="text">{{text}}</div>
|
|
5
|
+
<div class="button">{{subtitle}}</div>
|
|
6
|
+
</div>
|
|
7
|
+
{{ui}}
|
|
8
|
+
</div>
|
|
9
|
+
<style>
|
|
10
|
+
.closer {
|
|
11
|
+
{{gridBg}}
|
|
12
|
+
color: {{colors.textOnLight}};
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
text-align: center;
|
|
17
|
+
}
|
|
18
|
+
.closer .handle {
|
|
19
|
+
font-family: "{{fonts.pixel}}", monospace;
|
|
20
|
+
text-transform: uppercase;
|
|
21
|
+
font-size: 42px;
|
|
22
|
+
color: {{colors.primary}};
|
|
23
|
+
margin-bottom: 72px;
|
|
24
|
+
}
|
|
25
|
+
.closer .text {
|
|
26
|
+
font-family: "{{fonts.heading}}", serif;
|
|
27
|
+
font-weight: {{fonts.headingWeight}};
|
|
28
|
+
font-size: 108px;
|
|
29
|
+
line-height: 1.12;
|
|
30
|
+
white-space: pre-wrap;
|
|
31
|
+
margin-bottom: 88px;
|
|
32
|
+
}
|
|
33
|
+
.closer .text i { font-style: italic; }
|
|
34
|
+
.closer .text em { font-style: normal; color: {{colors.primary}}; }
|
|
35
|
+
.closer .button {
|
|
36
|
+
display: inline-block;
|
|
37
|
+
font-family: "{{fonts.pixel}}", monospace;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
font-size: 52px;
|
|
40
|
+
letter-spacing: 0.02em;
|
|
41
|
+
color: {{colors.text}};
|
|
42
|
+
background: {{colors.primary}};
|
|
43
|
+
padding: 28px 64px;
|
|
44
|
+
border: 4px solid {{colors.textOnLight}};
|
|
45
|
+
box-shadow: 12px 12px 0 {{colors.textOnLight}};
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
<div class="slide cover">
|
|
2
|
+
<img class="bg" src="{{photoSrc}}" alt="" />
|
|
3
|
+
<div class="veil"></div>
|
|
2
4
|
<div class="handle">[ {{handle}} ]</div>
|
|
3
5
|
<div class="hook">{{text}}<div class="subtitle">{{subtitle}}</div></div>
|
|
6
|
+
<img class="logo" src="{{logoSrc}}" alt="" />
|
|
7
|
+
<img class="cutout" src="{{cutoutSrc}}" alt="" />
|
|
4
8
|
{{ui}}
|
|
5
9
|
</div>
|
|
6
10
|
<style>
|
|
@@ -40,4 +44,53 @@
|
|
|
40
44
|
letter-spacing: 0;
|
|
41
45
|
color: {{colors.primary}};
|
|
42
46
|
}
|
|
47
|
+
.cover .logo {
|
|
48
|
+
height: 170px;
|
|
49
|
+
margin-bottom: 24px;
|
|
50
|
+
/* multiply drops a white logo background into the paper color */
|
|
51
|
+
mix-blend-mode: multiply;
|
|
52
|
+
}
|
|
53
|
+
/* the human behind the deck — big in the bottom-right corner. The hook
|
|
54
|
+
and logo shift left (scoped to :has(.cutout)) so she stays fully
|
|
55
|
+
visible instead of hiding behind centered text. */
|
|
56
|
+
.cover .cutout {
|
|
57
|
+
position: absolute;
|
|
58
|
+
right: 0;
|
|
59
|
+
bottom: 0;
|
|
60
|
+
height: 780px;
|
|
61
|
+
filter: drop-shadow(-18px 0 40px rgba(0, 0, 0, 0.18));
|
|
62
|
+
}
|
|
63
|
+
.cover:has(.cutout) { align-items: flex-start; text-align: left; }
|
|
64
|
+
.cover:has(.cutout) .hook { margin-bottom: 340px; }
|
|
65
|
+
.cover:has(.cutout) .logo { align-self: flex-start; margin-left: 8px; }
|
|
66
|
+
/* Photo cover: a full-bleed photo behind the hook. A flat dark veil (NO
|
|
67
|
+
gradients — brand hard rule) keeps the text readable; the chrome flips
|
|
68
|
+
to the dark-slide palette. All scoped to :has(.bg) so a photo-less
|
|
69
|
+
cover renders exactly as before. */
|
|
70
|
+
.cover .bg {
|
|
71
|
+
position: absolute;
|
|
72
|
+
inset: 0;
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: 100%;
|
|
75
|
+
object-fit: cover;
|
|
76
|
+
}
|
|
77
|
+
.cover .veil { display: none; }
|
|
78
|
+
.cover:has(.bg) .veil {
|
|
79
|
+
display: block;
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: 0;
|
|
82
|
+
background: rgba(5, 7, 31, 0.34);
|
|
83
|
+
}
|
|
84
|
+
.cover:has(.bg) .handle,
|
|
85
|
+
.cover:has(.bg) .hook,
|
|
86
|
+
.cover:has(.bg) .logo { position: relative; }
|
|
87
|
+
.cover:has(.bg) { color: {{colors.text}}; }
|
|
88
|
+
/* photo covers: hook compact in the lower band, over the subject's body
|
|
89
|
+
rather than their face */
|
|
90
|
+
.cover:has(.bg) .hook { margin-top: auto; margin-bottom: 48px; font-size: 104px; }
|
|
91
|
+
.cover:has(.bg) .handle { color: {{colors.accent}}; }
|
|
92
|
+
.cover:has(.bg) .hook em { color: {{colors.accent}}; }
|
|
93
|
+
.cover:has(.bg) .subtitle { color: {{colors.accent}}; }
|
|
94
|
+
/* photo covers pair with a light logo variant — no blend trick needed */
|
|
95
|
+
.cover:has(.bg) .logo { mix-blend-mode: normal; }
|
|
43
96
|
</style>
|