@tandryukha/aidemo 0.8.0 → 0.15.0

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/captions.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  VoiceManifestSchema,
12
12
  CaptionsManifestSchema,
13
13
  type CaptionsManifest,
14
+ type CaptionsConfig,
14
15
  type Storyboard,
15
16
  } from "./types.js";
16
17
  import { resolveNarrationLanguage } from "./i18n.js";
@@ -38,8 +39,52 @@ interface Word {
38
39
  end: number; // seconds
39
40
  }
40
41
 
41
- const MAX_WORDS = 6;
42
- const MAX_CUE_MS = 3000;
42
+ /**
43
+ * Cue-segmentation defaults (issue #48). The old 6-word / 3 s hard cap split
44
+ * half of all cue breaks mid-clause ("…nobody has / filled in a form, and no /
45
+ * account exists yet"), which is harder to read than the narration. Broadcast
46
+ * practice is ~2 lines x ~37 characters, 1–6 s per cue, broken at punctuation
47
+ * or a conjunction. Override per storyboard via the `captions` block.
48
+ */
49
+ const DEFAULT_SEG: Required<
50
+ Pick<CaptionsConfig, "maxWords" | "maxCueMs" | "minCueMs" | "maxLines" | "maxCharsPerLine">
51
+ > = {
52
+ maxWords: 12,
53
+ maxCueMs: 6000,
54
+ minCueMs: 1000,
55
+ maxLines: 2,
56
+ maxCharsPerLine: 38,
57
+ };
58
+
59
+ /** Conjunctions/discourse markers that make a decent break point, by language. */
60
+ const CONJUNCTIONS = new Set([
61
+ // en
62
+ "and", "but", "so", "or", "then", "because", "while", "when", "which", "that", "if",
63
+ // et
64
+ "ja", "aga", "või", "siis", "kui", "sest", "mis", "ning", "kuid",
65
+ // de / nl / sv
66
+ "und", "aber", "oder", "dann", "weil", "wenn", "och", "men", "eller", "en", "maar",
67
+ // fr / es / it / pt
68
+ "et", "mais", "ou", "donc", "parce", "quand", "y", "pero", "o", "entonces", "porque",
69
+ "e", "ma", "oppure", "quindi", "perché", "mas", "então",
70
+ ]);
71
+
72
+ type SegConfig = typeof DEFAULT_SEG;
73
+
74
+ function segConfig(storyboard?: Storyboard): SegConfig {
75
+ return { ...DEFAULT_SEG, ...cleanSeg(storyboard?.captions) };
76
+ }
77
+
78
+ function cleanSeg(c: CaptionsConfig | undefined): Partial<SegConfig> {
79
+ if (!c) return {};
80
+ const out: Partial<SegConfig> = {};
81
+ if (c.maxWords != null) out.maxWords = c.maxWords;
82
+ if (c.maxCueMs != null) out.maxCueMs = c.maxCueMs;
83
+ if (c.minCueMs != null) out.minCueMs = c.minCueMs;
84
+ if (c.maxLines != null) out.maxLines = c.maxLines;
85
+ if (c.maxCharsPerLine != null) out.maxCharsPerLine = c.maxCharsPerLine;
86
+ return out;
87
+ }
43
88
 
44
89
  /** Whisper's `prompt` is only used to bias roughly its first ~224 tokens of
45
90
  * context; cap what we send so a long demo's script doesn't balloon the
@@ -93,7 +138,8 @@ export async function generateCaptions(
93
138
  for (let i = 0; i < total; i++) opts.onSceneComplete?.(ids[i] ?? "", i, total);
94
139
  };
95
140
  if (total > 0) opts.onSceneStart?.(ids[0] ?? "", 0, total);
96
- const config = captionConfig(await loadGapMs(project));
141
+ const seg = segConfig(storyboard);
142
+ const config = captionConfig(await loadGapMs(project), seg);
97
143
 
98
144
  const prompt = storyboard ? buildSttPrompt(storyboard) : undefined;
99
145
  const language = opts.language ?? resolveNarrationLanguage(storyboard, project.lang);
@@ -151,24 +197,88 @@ export async function generateCaptions(
151
197
  if (words.length === 0) {
152
198
  log("no word timestamps returned; captions may be empty");
153
199
  }
154
- const cues = groupWords(words, sceneEnds);
200
+ const cues = groupWords(words, sceneEnds, seg);
155
201
  await writeCaptionFiles(project, cues);
156
- await writeCaptionsManifest(project, { mode: "stt", inputHash, config, cues });
202
+ await writeCaptionsManifest(project, {
203
+ mode: "stt",
204
+ inputHash,
205
+ config,
206
+ scenes: splitWordsByScene(words, ids, sceneEnds, config.gapMs),
207
+ cues,
208
+ });
157
209
  reportDone();
158
210
  }
159
211
 
160
212
  /**
161
- * Whisper prompt bias: the known narration script, in scene order, so the
162
- * transcript converges on the correctly-spelled words instead of guessing
163
- * from audio phonetics alone. Capped to MAX_PROMPT_CHARS (see above).
213
+ * Split an absolute transcript into scene-relative words (for narration
214
+ * anchors and per-scene reuse). Scene i spans [prevEnd + gap, sceneEnds[i]];
215
+ * a word is assigned by its start time.
216
+ */
217
+ function splitWordsByScene(
218
+ words: Word[],
219
+ ids: string[],
220
+ sceneEnds: number[],
221
+ gapMs: number
222
+ ): NonNullable<CaptionsManifest["scenes"]> {
223
+ const out: NonNullable<CaptionsManifest["scenes"]> = [];
224
+ let startMs = 0;
225
+ for (let i = 0; i < sceneEnds.length; i++) {
226
+ const endMs = sceneEnds[i];
227
+ const mine = words
228
+ .filter((w) => {
229
+ const t = w.start * 1000;
230
+ return t >= startMs - 1 && (i === sceneEnds.length - 1 || t < endMs + gapMs / 2);
231
+ })
232
+ .map((w) => ({
233
+ word: w.word.trim(),
234
+ start: Math.max(0, w.start - startMs / 1000),
235
+ end: Math.max(0, w.end - startMs / 1000),
236
+ }));
237
+ out.push({
238
+ id: ids[i] ?? String(i),
239
+ hash: hashOf({ stt: true, startMs, endMs }),
240
+ durationMs: endMs - startMs,
241
+ words: mine,
242
+ });
243
+ startMs = endMs + gapMs;
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /** Scene-relative words for a scene; script-timed fallback when the manifest has none. */
249
+ export async function sceneWordsFor(
250
+ project: Project,
251
+ sceneId: string,
252
+ narration: string,
253
+ durationMs: number
254
+ ): Promise<Word[]> {
255
+ const manifest = await readCaptionsManifest(project);
256
+ const stored = manifest?.scenes?.find((s) => s.id === sceneId);
257
+ if (stored && stored.words.length) return stored.words;
258
+ return deriveSceneWords(narration, durationMs);
259
+ }
260
+
261
+ /**
262
+ * Whisper prompt bias: any `knownTerms` glossary first (product names and
263
+ * jargon Whisper mis-hears — it only biases on roughly its first 224 tokens,
264
+ * so the terms must lead), then the known narration script in scene order, so
265
+ * the transcript converges on correctly-spelled words instead of guessing from
266
+ * audio phonetics alone. Capped to MAX_PROMPT_CHARS (see above).
164
267
  */
165
268
  function buildSttPrompt(storyboard: Storyboard): string | undefined {
269
+ const terms = (storyboard.knownTerms ?? [])
270
+ .map((t) => t.trim())
271
+ .filter(Boolean);
272
+ const glossary = terms.length ? `${terms.join(", ")}. ` : "";
166
273
  const text = storyboard.scenes
167
274
  .map((s) => s.narration.trim())
168
275
  .filter(Boolean)
169
276
  .join(" ");
170
- if (!text) return undefined;
171
- return text.length > MAX_PROMPT_CHARS ? text.slice(0, MAX_PROMPT_CHARS) : text;
277
+ const prompt = glossary + text;
278
+ if (!prompt.trim()) return undefined;
279
+ return prompt.length > MAX_PROMPT_CHARS
280
+ ? prompt.slice(0, MAX_PROMPT_CHARS)
281
+ : prompt;
172
282
  }
173
283
 
174
284
  /**
@@ -188,7 +298,8 @@ export async function generateCaptionsOffline(
188
298
  await readJson(project.voiceManifestPath)
189
299
  );
190
300
  const durById = new Map(voice.scenes.map((s) => [s.id, s.durationMs]));
191
- const config = captionConfig(voice.gapMs);
301
+ const seg = segConfig(storyboard);
302
+ const config = captionConfig(voice.gapMs, seg);
192
303
 
193
304
  // Per-scene caption identity = narration + this scene's measured duration (+
194
305
  // grouping config). The scene-relative word timings depend on nothing else, so
@@ -269,7 +380,7 @@ export async function generateCaptionsOffline(
269
380
  cursor += voice.gapMs;
270
381
  }
271
382
 
272
- const cues = groupWords(words, sceneEnds);
383
+ const cues = groupWords(words, sceneEnds, seg);
273
384
  await writeCaptionFiles(project, cues);
274
385
  await writeCaptionsManifest(project, {
275
386
  mode: "offline",
@@ -305,6 +416,12 @@ async function writeCaptionFiles(project: Project, cues: Cue[]): Promise<void> {
305
416
  await fs.writeFile(project.captionsSrtPath, toSrt(cues));
306
417
  await fs.writeFile(project.captionsVttPath, toVtt(cues));
307
418
  await writeJson(project.captionsCuesPath, cues);
419
+ // Readability, so an agent sees it without opening the SRT (issue #48).
420
+ const stats = cueStats(cues);
421
+ log(
422
+ `cues: ${cues.length}, ${stats.midClausePct}% mid-clause breaks, ` +
423
+ `${stats.short} shorter than 1s`
424
+ );
308
425
  ok(`captions → ${project.captionsSrtPath} (${cues.length} cues)`);
309
426
  ok(`captions → ${project.captionsCuesPath}`);
310
427
  }
@@ -317,8 +434,8 @@ async function writeCaptionFiles(project: Project, cues: Cue[]): Promise<void> {
317
434
  // ---------------------------------------------------------------------------
318
435
 
319
436
  /** Grouping parameters that affect cue segmentation — part of the cache key. */
320
- function captionConfig(gapMs: number): CaptionsManifest["config"] {
321
- return { gapMs, maxWords: MAX_WORDS, maxCueMs: MAX_CUE_MS };
437
+ function captionConfig(gapMs: number, cfg: SegConfig = DEFAULT_SEG): CaptionsManifest["config"] {
438
+ return { gapMs, maxWords: cfg.maxWords, maxCueMs: cfg.maxCueMs };
322
439
  }
323
440
 
324
441
  /** Inter-scene gap from voice.json (0 when absent — captions need voice.json). */
@@ -387,38 +504,129 @@ async function sceneIdsFor(project: Project): Promise<string[]> {
387
504
  return voice.success ? voice.data.scenes.map((s) => s.id) : [];
388
505
  }
389
506
 
390
- function groupWords(words: Word[], sceneEnds: number[]): Cue[] {
391
- const cues: Cue[] = [];
507
+ /** Text of a run of words, with punctuation re-attached. */
508
+ function cueText(ws: Word[]): string {
509
+ // trim each word: faster-whisper servers pad words with leading spaces
510
+ return ws
511
+ .map((w) => w.word.trim())
512
+ .join(" ")
513
+ .replace(/\s+([,.!?;:])/g, "$1")
514
+ .trim();
515
+ }
516
+
517
+ /** A word that ends a clause (punctuation) — the best place to break. */
518
+ function endsClause(w: Word): boolean {
519
+ return /[,;:—–]$/.test(w.word.trim());
520
+ }
521
+
522
+ function endsSentence(w: Word): boolean {
523
+ return /[.!?]["'”’)]?$/.test(w.word.trim());
524
+ }
525
+
526
+ /**
527
+ * Split a run of words into cues at the most readable boundaries: sentence
528
+ * ends first, then clause punctuation, then a conjunction, and only then on
529
+ * pure length. Never leaves a 2–3 word tail dangling when the previous
530
+ * boundary can absorb it.
531
+ */
532
+ function splitRun(ws: Word[], cfg: SegConfig): Word[][] {
533
+ const capChars = cfg.maxLines * cfg.maxCharsPerLine;
534
+ const fits = (run: Word[]): boolean =>
535
+ run.length <= cfg.maxWords &&
536
+ cueText(run).length <= capChars &&
537
+ (run[run.length - 1].end - run[0].start) * 1000 <= cfg.maxCueMs;
538
+ if (ws.length === 0) return [];
539
+ if (fits(ws)) return [ws];
540
+ // The furthest index that still fits, then walk back to a real boundary.
541
+ let end = 1;
542
+ while (end < ws.length && fits(ws.slice(0, end + 1))) end++;
543
+ const floor = Math.max(1, Math.ceil(end * 0.45));
544
+ let cut = end;
545
+ for (let i = end - 1; i >= floor; i--) {
546
+ if (endsSentence(ws[i]) || endsClause(ws[i])) {
547
+ cut = i + 1;
548
+ break;
549
+ }
550
+ }
551
+ if (cut === end) {
552
+ for (let i = end - 1; i >= floor; i--) {
553
+ const next = ws[i + 1];
554
+ if (next && CONJUNCTIONS.has(next.word.trim().toLowerCase().replace(/[^\p{L}]/gu, ""))) {
555
+ cut = i + 1;
556
+ break;
557
+ }
558
+ }
559
+ }
560
+ return [ws.slice(0, cut), ...splitRun(ws.slice(cut), cfg)];
561
+ }
562
+
563
+ /** Merge a too-short cue into the neighbour it still fits in. */
564
+ function mergeShort(runs: Word[][], cfg: SegConfig): Word[][] {
565
+ const capChars = cfg.maxLines * cfg.maxCharsPerLine;
566
+ const room = (a: Word[], b: Word[]): boolean =>
567
+ a.length + b.length <= cfg.maxWords &&
568
+ cueText([...a, ...b]).length <= capChars &&
569
+ (b[b.length - 1].end - a[0].start) * 1000 <= cfg.maxCueMs;
570
+ const out: Word[][] = [];
571
+ for (const run of runs) {
572
+ const durMs = (run[run.length - 1].end - run[0].start) * 1000;
573
+ const prev = out[out.length - 1];
574
+ if (durMs < cfg.minCueMs && prev && room(prev, run)) {
575
+ out[out.length - 1] = [...prev, ...run];
576
+ continue;
577
+ }
578
+ out.push(run);
579
+ }
580
+ return out;
581
+ }
582
+
583
+ function groupWords(words: Word[], sceneEnds: number[], cfg: SegConfig = DEFAULT_SEG): Cue[] {
584
+ // Scene boundaries and sentence ends are hard breaks; inside those runs the
585
+ // split is clause-aware.
586
+ const runs: Word[][] = [];
392
587
  let cur: Word[] = [];
393
588
  let sceneIdx = 0;
394
- const flush = () => {
395
- if (cur.length === 0) return;
396
- cues.push({
397
- index: cues.length + 1,
398
- startMs: Math.round(cur[0].start * 1000),
399
- endMs: Math.round(cur[cur.length - 1].end * 1000),
400
- // trim each word: faster-whisper servers pad words with leading spaces
401
- text: cur.map((w) => w.word.trim()).join(" ").replace(/\s+([,.!?])/g, "$1").trim(),
402
- });
589
+ const cut = (): void => {
590
+ if (cur.length) runs.push(cur);
403
591
  cur = [];
404
592
  };
405
593
  for (const w of words) {
406
594
  const startMs = w.start * 1000;
407
- // Crossed into a later scene? Break the cue there.
408
- while (
409
- sceneIdx < sceneEnds.length - 1 &&
410
- startMs >= sceneEnds[sceneIdx]
411
- ) {
412
- flush();
595
+ while (sceneIdx < sceneEnds.length - 1 && startMs >= sceneEnds[sceneIdx]) {
596
+ cut();
413
597
  sceneIdx++;
414
598
  }
415
599
  cur.push(w);
416
- const durMs = (w.end - cur[0].start) * 1000;
417
- const endsSentence = /[.!?]$/.test(w.word.trim());
418
- if (cur.length >= MAX_WORDS || durMs >= MAX_CUE_MS || endsSentence) flush();
600
+ if (endsSentence(w)) cut();
419
601
  }
420
- flush();
421
- return cues;
602
+ cut();
603
+ const split = runs.flatMap((r) => splitRun(r, cfg));
604
+ return mergeShort(split, cfg).map((ws, i) => ({
605
+ index: i + 1,
606
+ startMs: Math.round(ws[0].start * 1000),
607
+ endMs: Math.round(ws[ws.length - 1].end * 1000),
608
+ text: cueText(ws),
609
+ }));
610
+ }
611
+
612
+ /** Readability stats for the log: mid-clause breaks and too-short cues. */
613
+ export function cueStats(cues: Cue[], minCueMs = DEFAULT_SEG.minCueMs): {
614
+ short: number;
615
+ midClause: number;
616
+ midClausePct: number;
617
+ } {
618
+ let short = 0;
619
+ let midClause = 0;
620
+ cues.forEach((c, i) => {
621
+ if (c.endMs - c.startMs < minCueMs) short++;
622
+ const next = cues[i + 1];
623
+ if (!next) return;
624
+ const cleanBreak = /[.!?,;:—–]["'”’)]?$/.test(c.text.trim());
625
+ const startsLower = /^\p{Ll}/u.test(next.text.trim());
626
+ if (!cleanBreak && startsLower) midClause++;
627
+ });
628
+ const breaks = Math.max(1, cues.length - 1);
629
+ return { short, midClause, midClausePct: Math.round((midClause / breaks) * 100) };
422
630
  }
423
631
 
424
632
  function toSrt(cues: Cue[]): string {
package/src/capture.ts CHANGED
@@ -572,3 +572,116 @@ export async function cropCaptureToViewport(
572
572
  dst,
573
573
  ]);
574
574
  }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // post-capture content verification
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /** Downscaled grayscale thumbnail size for the content comparison. */
581
+ const VERIFY_W = 64;
582
+ const VERIFY_H = 36;
583
+ /** Mean |Δgray| below this = same content even before correlating. */
584
+ const VERIFY_MAD_PASS = 15;
585
+ /** Minimum Pearson correlation of the two thumbnails to pass. */
586
+ const VERIFY_CORR_PASS = 0.7;
587
+
588
+ /** Decode one frame of `input` to a raw grayscale VERIFY_W×VERIFY_H buffer. */
589
+ async function grayThumb(input: string, preArgs: string[]): Promise<Buffer> {
590
+ const out = `${input}.gray`;
591
+ await runFfmpeg([
592
+ ...preArgs,
593
+ "-i",
594
+ input,
595
+ "-vf",
596
+ `scale=${VERIFY_W}:${VERIFY_H}:flags=area,format=gray`,
597
+ "-frames:v",
598
+ "1",
599
+ "-f",
600
+ "rawvideo",
601
+ out,
602
+ ]);
603
+ const buf = await fs.readFile(out);
604
+ await fs.rm(out, { force: true });
605
+ return buf;
606
+ }
607
+
608
+ /**
609
+ * Verify that the CONTENT of the cropped capture is the page that was driven
610
+ * (issue #21). The geometry gates above prove the crop *rectangle* is right,
611
+ * but not that the right *pixels* were under it: a display-region screen grab
612
+ * records whatever is painted at those coordinates, so a browser window that
613
+ * is occluded (another app raised mid-take), on another display, or behind a
614
+ * notification produces a take that passes every geometry check and still
615
+ * shows someone else's window — silent garbage, and a privacy hazard, since
616
+ * the leaked pixels ship in an otherwise "publish-ready" video.
617
+ *
618
+ * The reference is a CDP screenshot of the page taken at capture-stop time
619
+ * (the page's own buffer — immune to occlusion). Both it and the tail frame
620
+ * of the cropped capture are reduced to a small grayscale thumbnail (ffmpeg
621
+ * rawvideo — no image deps) and compared: near-identical mean gray delta
622
+ * passes outright; otherwise the Pearson correlation must clear a threshold
623
+ * (robust to the gamma/color-profile shift between a screen grab and a CDP
624
+ * screenshot). A mismatch ABORTS like the geometry gates
625
+ * (AIDEMO_NATIVE_CROP_UNSAFE=1 downgrades to a loud warning).
626
+ */
627
+ export async function verifyCaptureMatchesPage(
628
+ croppedVideo: string,
629
+ referencePng: string
630
+ ): Promise<void> {
631
+ // Tail frame: the reference screenshot was taken just before capture stop,
632
+ // and the page is static by then (the last scene has ended).
633
+ const vid = await grayThumb(croppedVideo, ["-sseof", "-0.3"]);
634
+ const ref = await grayThumb(referencePng, []);
635
+ const n = Math.min(vid.length, ref.length);
636
+ if (n === 0) {
637
+ log("⚠ capture verification skipped (empty thumbnail) — frame-review the take");
638
+ return;
639
+ }
640
+ let sumAbs = 0;
641
+ let sumA = 0;
642
+ let sumB = 0;
643
+ for (let i = 0; i < n; i++) {
644
+ sumAbs += Math.abs(vid[i] - ref[i]);
645
+ sumA += vid[i];
646
+ sumB += ref[i];
647
+ }
648
+ const mad = sumAbs / n;
649
+ const meanA = sumA / n;
650
+ const meanB = sumB / n;
651
+ let cov = 0;
652
+ let varA = 0;
653
+ let varB = 0;
654
+ for (let i = 0; i < n; i++) {
655
+ const da = vid[i] - meanA;
656
+ const db = ref[i] - meanB;
657
+ cov += da * db;
658
+ varA += da * da;
659
+ varB += db * db;
660
+ }
661
+ // Degenerate (near-uniform) thumbnails make correlation meaningless — the
662
+ // MAD pass above is the only meaningful check for e.g. a blank white page.
663
+ const corr =
664
+ varA > 1e-6 && varB > 1e-6 ? cov / Math.sqrt(varA * varB) : NaN;
665
+ log(
666
+ `capture verification: mean |Δgray| ${mad.toFixed(1)} (pass ≤ ${VERIFY_MAD_PASS}), ` +
667
+ `correlation ${Number.isNaN(corr) ? "n/a (uniform frame)" : corr.toFixed(3)} (pass ≥ ${VERIFY_CORR_PASS})`
668
+ );
669
+ if (mad <= VERIFY_MAD_PASS || (!Number.isNaN(corr) && corr >= VERIFY_CORR_PASS)) {
670
+ return;
671
+ }
672
+ gateGeometryProblems(
673
+ [
674
+ `the captured pixels do not look like the page that was driven ` +
675
+ `(mean gray delta ${mad.toFixed(1)}, correlation ${Number.isNaN(corr) ? "n/a" : corr.toFixed(3)}) — ` +
676
+ `the browser window was likely occluded by another window, on a different ` +
677
+ `display than the captured one, or covered by a notification during the take`,
678
+ ],
679
+ `Native capture verification failed: the recording shows something other than ` +
680
+ `the driven page. The cropped take (${croppedVideo}) and the page reference ` +
681
+ `(${referencePng}) were kept — compare them side by side, and treat the take ` +
682
+ `as sensitive until reviewed (it may show other windows). Re-record with the ` +
683
+ `browser window front-most and unoccluded on the captured display, or use the ` +
684
+ `default --capture playwright (records the page buffer directly — immune to ` +
685
+ `occlusion).`
686
+ );
687
+ }
package/src/cards.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chromium } from "playwright";
2
- import type { Card } from "./types.js";
2
+ import type { Brand, Card } from "./types.js";
3
3
 
4
4
  /**
5
5
  * Rasterizes an intro/outro title card to a PNG with headless Chrome — same
@@ -13,7 +13,9 @@ export async function renderCardPng(
13
13
  outPath: string,
14
14
  logicalW: number,
15
15
  logicalH: number,
16
- scale: number
16
+ scale: number,
17
+ brand?: Brand,
18
+ logoDataUri?: string
17
19
  ): Promise<void> {
18
20
  const browser = await chromium.launch({ channel: "chrome", headless: true });
19
21
  const context = await browser.newContext({
@@ -22,7 +24,7 @@ export async function renderCardPng(
22
24
  });
23
25
  const page = await context.newPage();
24
26
  try {
25
- await page.setContent(html(card, logicalW), { waitUntil: "load" });
27
+ await page.setContent(html(card, logicalW, brand, logoDataUri), { waitUntil: "load" });
26
28
  await page.screenshot({ path: outPath });
27
29
  } finally {
28
30
  await browser.close();
@@ -36,21 +38,27 @@ function esc(text: string): string {
36
38
  .replace(/>/g, "&gt;");
37
39
  }
38
40
 
39
- function html(card: Card, logicalW: number): string {
41
+ function html(card: Card, logicalW: number, brand?: Brand, logoDataUri?: string): string {
40
42
  const background =
41
43
  card.background ??
42
44
  "radial-gradient(120% 140% at 20% 0%, #182036 0%, #0b0d16 55%, #07080f 100%)";
43
- const accent = card.accent ?? "#6c8cff";
45
+ const accent = card.accent ?? brand?.accent ?? "#6c8cff";
46
+ const font = brand?.font ?? '-apple-system,"Helvetica Neue",Arial,sans-serif';
44
47
  const titleSize = Math.round(logicalW / 17);
45
48
  const subSize = Math.round(logicalW / 40);
46
49
  const subtitle = card.subtitle
47
50
  ? `<div class="sub">${esc(card.subtitle)}</div>`
48
51
  : "";
52
+ const logo = logoDataUri
53
+ ? `<img class="logo" src="${logoDataUri}" alt="">`
54
+ : "";
49
55
  return `<!doctype html><html><head><meta charset="utf-8"><style>
50
56
  html,body{margin:0;padding:0;width:100%;height:100%;}
51
57
  body{background:${background};display:flex;align-items:center;justify-content:center;}
58
+ .logo{display:block;margin:0 auto ${Math.round(subSize * 1.4)}px;
59
+ max-width:${Math.round(logicalW * 0.18)}px;max-height:${Math.round(logicalW * 0.08)}px;}
52
60
  .box{max-width:78%;text-align:center;
53
- font-family:-apple-system,"Helvetica Neue",Arial,sans-serif;
61
+ font-family:${font};
54
62
  -webkit-font-smoothing:antialiased;}
55
63
  .rule{width:${Math.round(logicalW / 22)}px;height:5px;border-radius:3px;
56
64
  background:${accent};margin:0 auto ${Math.round(subSize * 1.2)}px;}
@@ -60,6 +68,7 @@ function html(card: Card, logicalW: number): string {
60
68
  .sub{margin-top:${Math.round(subSize * 0.9)}px;font-size:${subSize}px;
61
69
  line-height:1.4;font-weight:450;color:rgba(226,231,244,.72);}
62
70
  </style></head><body><div class="box">
71
+ ${logo}
63
72
  <div class="rule"></div>
64
73
  <div class="title">${esc(card.title)}</div>
65
74
  ${subtitle}