@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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +734 -48
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +244 -36
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +711 -26
- package/src/compose.ts +1318 -101
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +19 -4
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +224 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +208 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +640 -0
- package/src/inspect.ts +316 -0
- package/src/lint.ts +673 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +5 -1
- package/src/mcp/server.ts +468 -27
- package/src/player.ts +1286 -41
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/qa.ts +295 -0
- package/src/recorder.ts +316 -21
- package/src/safe-regex.ts +36 -0
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +796 -22
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
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
|
-
|
|
42
|
-
|
|
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
|
|
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, {
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
391
|
-
|
|
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
|
|
395
|
-
if (cur.length
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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, ">");
|
|
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
|
|
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}
|