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/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
- const brandBase = yaml.load(readFileSync(join(ROOT, "brand.yml"), "utf8"));
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
- // shallow merge per top-level key (fonts/colors/style), style wins
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]) brand[key] = { ...brandBase[key], ...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
- // per-family axis specs — some families don't ship every weight
88
- const FONT_SPECS = {
89
- "Instrument Serif": "ital@0;1",
90
- "Fraunces": "ital,wght@0,400;0,500;0,600;1,400;1,500",
91
- "Silkscreen": "wght@400;700",
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
- <link href="https://fonts.googleapis.com/css2?${fontFamilies}&display=block" rel="stylesheet">
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
- const browser = await chromium.launch();
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: slide.photo ? photoDataUri(slide.photo) : "",
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 html = pageHtml(fill(readFileSync(templatePath, "utf8"), ctx));
177
- await page.setContent(html, { waitUntil: "networkidle" });
178
- const out = join(outDir, `slide-${String(i + 1).padStart(2, "0")}.png`);
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>