@tandryukha/aidemo 0.8.0 → 0.14.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/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}