@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tandryukha/aidemo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"aidemo": "tsx src/cli.ts",
|
|
27
27
|
"typecheck": "tsc --noEmit",
|
|
28
|
+
"build": "tsc --noEmit",
|
|
28
29
|
"serve-example": "node examples/local-demo/serve.mjs",
|
|
29
30
|
"mcp-smoke": "node test/mcp-smoke.mjs",
|
|
30
31
|
"guard-openai-key": "tsx scripts/guard-openai-key.ts",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"check:plugin-skill": "diff -q .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@modelcontextprotocol/sdk": "1.
|
|
36
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
36
37
|
"commander": "^12.1.0",
|
|
37
38
|
"openai": "^4.67.0",
|
|
38
39
|
"playwright": "^1.48.0",
|
package/src/anchors.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narration-anchored beats. An author writes `{{@add}}` in the narration
|
|
3
|
+
* right before the word the beat should land on and puts `anchor: "add"`
|
|
4
|
+
* on the action. Parse strips the markers (word indexes survive on the
|
|
5
|
+
* scene), the player timestamps the anchored action, captions know when
|
|
6
|
+
* the word is spoken, and compose retimes the scene piecewise so the click
|
|
7
|
+
* lands on the word. No LLM, no steering — one more deterministic mapping.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Storyboard } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** `{{@name}}` — the params syntax with an @, so PLACEHOLDER_RE never sees it. */
|
|
13
|
+
export const ANCHOR_RE = /\{\{\s*@([\w.-]+)\s*\}\}/g;
|
|
14
|
+
|
|
15
|
+
export interface ExtractedAnchors {
|
|
16
|
+
/** Narration with the markers removed (whitespace collapsed at the seam). */
|
|
17
|
+
text: string;
|
|
18
|
+
/** name → 0-based index of the word the marker precedes. */
|
|
19
|
+
anchors: Record<string, number>;
|
|
20
|
+
/** Names that appeared more than once (the last one wins). */
|
|
21
|
+
duplicates: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Strip `{{@name}}` markers and record which word each one precedes. */
|
|
25
|
+
export function extractAnchors(narration: string): ExtractedAnchors {
|
|
26
|
+
const anchors: Record<string, number> = {};
|
|
27
|
+
const duplicates: string[] = [];
|
|
28
|
+
let out = "";
|
|
29
|
+
let last = 0;
|
|
30
|
+
ANCHOR_RE.lastIndex = 0;
|
|
31
|
+
let m: RegExpExecArray | null;
|
|
32
|
+
while ((m = ANCHOR_RE.exec(narration))) {
|
|
33
|
+
out += narration.slice(last, m.index);
|
|
34
|
+
last = m.index + m[0].length;
|
|
35
|
+
const before = out.trim();
|
|
36
|
+
const idx = before ? before.split(/\s+/).length : 0;
|
|
37
|
+
// A marker glued to the end of a word ("basket{{@x}}") still means the
|
|
38
|
+
// NEXT word — unless nothing follows.
|
|
39
|
+
const gluedToWord = before.length > 0 && !/\s$/.test(out);
|
|
40
|
+
const name = m[1];
|
|
41
|
+
if (name in anchors) duplicates.push(name);
|
|
42
|
+
anchors[name] = gluedToWord ? idx : idx;
|
|
43
|
+
}
|
|
44
|
+
out += narration.slice(last);
|
|
45
|
+
const text = out.replace(/[ \t]{2,}/g, " ").replace(/ +([,.;:!?])/g, "$1").trim();
|
|
46
|
+
const wordCount = text ? text.split(/\s+/).length : 0;
|
|
47
|
+
for (const k of Object.keys(anchors)) anchors[k] = Math.min(anchors[k], Math.max(0, wordCount - 1));
|
|
48
|
+
return { text, anchors, duplicates };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plain text without markers (for consumers that never need positions). */
|
|
52
|
+
export function stripAnchors(narration: string): string {
|
|
53
|
+
return extractAnchors(narration).text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse-time pass: strip markers from `narration` / `narrations[*]` and
|
|
58
|
+
* record the word indexes on the scene (`anchors`, `narrationAnchors`), so
|
|
59
|
+
* every downstream stage sees clean text and can still find the beats.
|
|
60
|
+
*/
|
|
61
|
+
export function applyAnchors(sb: Storyboard): Storyboard {
|
|
62
|
+
let touched = false;
|
|
63
|
+
const scenes = sb.scenes.map((scene) => {
|
|
64
|
+
const base = extractAnchors(scene.narration);
|
|
65
|
+
let narrationAnchors: Record<string, Record<string, number>> | undefined;
|
|
66
|
+
let narrations = scene.narrations;
|
|
67
|
+
if (scene.narrations) {
|
|
68
|
+
narrations = {};
|
|
69
|
+
for (const [lang, text] of Object.entries(scene.narrations)) {
|
|
70
|
+
const ex = extractAnchors(text);
|
|
71
|
+
narrations[lang] = ex.text;
|
|
72
|
+
if (Object.keys(ex.anchors).length) {
|
|
73
|
+
narrationAnchors ??= {};
|
|
74
|
+
narrationAnchors[lang] = ex.anchors;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const hasBase = Object.keys(base.anchors).length > 0;
|
|
79
|
+
if (!hasBase && !narrationAnchors && base.text === scene.narration) return scene;
|
|
80
|
+
touched = true;
|
|
81
|
+
return {
|
|
82
|
+
...scene,
|
|
83
|
+
narration: base.text,
|
|
84
|
+
...(narrations ? { narrations } : {}),
|
|
85
|
+
...(hasBase ? { anchors: base.anchors } : {}),
|
|
86
|
+
...(narrationAnchors ? { narrationAnchors } : {}),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
return touched ? { ...sb, scenes } : sb;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Normalize a token for script ↔ transcript matching. */
|
|
93
|
+
function norm(w: string): string {
|
|
94
|
+
return w.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* When is script word #`wordIndex` spoken? `words` are the scene's timed
|
|
99
|
+
* words (STT transcript or script-timed), `script` the narration text.
|
|
100
|
+
* Aligns by normalized token near the proportional position, so a transcript
|
|
101
|
+
* that split "add-on" or heard "to" as "too" still lands on the right beat;
|
|
102
|
+
* falls back to proportional interpolation. Returns scene-relative ms.
|
|
103
|
+
*/
|
|
104
|
+
export function wordStartMs(
|
|
105
|
+
words: Array<{ word: string; start: number; end: number }>,
|
|
106
|
+
script: string,
|
|
107
|
+
wordIndex: number
|
|
108
|
+
): number | null {
|
|
109
|
+
if (!words.length) return null;
|
|
110
|
+
const tokens = script.split(/\s+/).filter(Boolean);
|
|
111
|
+
const idx = Math.max(0, Math.min(wordIndex, tokens.length - 1));
|
|
112
|
+
const target = norm(tokens[idx] ?? "");
|
|
113
|
+
// Exact count match — the common case for script-timed captions.
|
|
114
|
+
if (tokens.length === words.length && norm(words[idx].word) === target) {
|
|
115
|
+
return Math.round(words[idx].start * 1000);
|
|
116
|
+
}
|
|
117
|
+
const guess = Math.round((idx / Math.max(1, tokens.length)) * words.length);
|
|
118
|
+
const window = Math.max(3, Math.ceil(words.length * 0.15));
|
|
119
|
+
let best: { d: number; i: number } | null = null;
|
|
120
|
+
for (let i = Math.max(0, guess - window); i < Math.min(words.length, guess + window + 1); i++) {
|
|
121
|
+
if (!target || norm(words[i].word) !== target) continue;
|
|
122
|
+
const d = Math.abs(i - guess);
|
|
123
|
+
if (!best || d < best.d) best = { d, i };
|
|
124
|
+
}
|
|
125
|
+
if (best) return Math.round(words[best.i].start * 1000);
|
|
126
|
+
const i = Math.min(words.length - 1, guess);
|
|
127
|
+
return Math.round(words[i].start * 1000);
|
|
128
|
+
}
|
package/src/attention.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention overlays — compose-time drawing of the beats the player measured
|
|
3
|
+
* (timeline `attentionEvents`, `keyEvents`, `redactSpans`, click points).
|
|
4
|
+
*
|
|
5
|
+
* Same portability trick as captions and cards: every mark is a transparent
|
|
6
|
+
* PNG rasterized by headless Chrome (CSS gives us rings, pills, arrows and
|
|
7
|
+
* soft shadows for free) and overlaid with a time-gated `enable` — the
|
|
8
|
+
* baseline `overlay` filter only, no drawtext/drawbox. Redaction is
|
|
9
|
+
* `crop` + `boxblur` + `overlay`, also baseline.
|
|
10
|
+
*
|
|
11
|
+
* Positions arrive in viewport CSS px and are mapped to output px by pxScale;
|
|
12
|
+
* every PNG is rendered at deviceScaleFactor = pxScale so it lands 1:1.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { chromium, type Page } from "playwright";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import type { AttentionEvent, AttentionStyle, RedactSpan } from "./types.js";
|
|
18
|
+
import { ensureDir } from "./util.js";
|
|
19
|
+
|
|
20
|
+
/** One PNG to overlay at (x, y) output px during [a, b] content seconds. */
|
|
21
|
+
export interface OverlayItem {
|
|
22
|
+
png: string;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
a: number;
|
|
26
|
+
b: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An attention event already mapped into content time (seconds). */
|
|
30
|
+
export interface PlacedAttention extends AttentionEvent {
|
|
31
|
+
a: number;
|
|
32
|
+
b: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A key chip already mapped into content time. */
|
|
36
|
+
export interface PlacedKey {
|
|
37
|
+
keys: string;
|
|
38
|
+
a: number;
|
|
39
|
+
b: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A click already mapped into content time (viewport CSS px). */
|
|
43
|
+
export interface PlacedClick {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
t: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A redact span already mapped into content time (viewport CSS px). */
|
|
50
|
+
export interface PlacedRedact extends RedactSpan {
|
|
51
|
+
a: number;
|
|
52
|
+
b: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_ACCENT = "#ff5a5f";
|
|
56
|
+
const SHADOW_MARGIN = 10;
|
|
57
|
+
/** How long a keystroke chip stays up, ms. */
|
|
58
|
+
export const KEY_CHIP_MS = 900;
|
|
59
|
+
/** Click ring: three stepped frames (diameter logical px, opacity, ms). */
|
|
60
|
+
const CLICK_RING_STEPS: Array<{ d: number; o: number; ms: number }> = [
|
|
61
|
+
{ d: 18, o: 0.9, ms: 90 },
|
|
62
|
+
{ d: 30, o: 0.6, ms: 90 },
|
|
63
|
+
{ d: 44, o: 0.3, ms: 110 },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function esc(text: string): string {
|
|
67
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function styleOf(ev: AttentionEvent, accent: string): Required<AttentionStyle> {
|
|
71
|
+
const s: AttentionStyle = ev.style ?? {};
|
|
72
|
+
return {
|
|
73
|
+
color: s.color ?? accent,
|
|
74
|
+
thickness: s.thickness ?? 3,
|
|
75
|
+
padding: s.padding ?? 6,
|
|
76
|
+
shape: s.shape ?? "box",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PAGE_CSS = `html,body{margin:0;padding:0;background:transparent;}
|
|
81
|
+
*{box-sizing:border-box;font-family:-apple-system,"Helvetica Neue",Arial,sans-serif;
|
|
82
|
+
-webkit-font-smoothing:antialiased;}`;
|
|
83
|
+
|
|
84
|
+
/** Screenshot the element `#m` of the current page to `png`; returns its size. */
|
|
85
|
+
async function shotElement(
|
|
86
|
+
page: Page,
|
|
87
|
+
png: string
|
|
88
|
+
): Promise<{ w: number; h: number }> {
|
|
89
|
+
const el = page.locator("#m");
|
|
90
|
+
const box = await el.boundingBox();
|
|
91
|
+
if (!box) throw new Error("attention: overlay element has no box");
|
|
92
|
+
await el.screenshot({ path: png, omitBackground: true });
|
|
93
|
+
return { w: Math.round(box.width), h: Math.round(box.height) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rasterize highlight / spotlight / callout events into positioned overlay
|
|
98
|
+
* items (output px, content seconds). One headless Chrome for the whole batch.
|
|
99
|
+
*/
|
|
100
|
+
export async function renderAttentionPngs(
|
|
101
|
+
events: PlacedAttention[],
|
|
102
|
+
outDir: string,
|
|
103
|
+
videoW: number,
|
|
104
|
+
videoH: number,
|
|
105
|
+
pxScale: number,
|
|
106
|
+
accent: string = DEFAULT_ACCENT
|
|
107
|
+
): Promise<OverlayItem[]> {
|
|
108
|
+
if (events.length === 0) return [];
|
|
109
|
+
await ensureDir(outDir);
|
|
110
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
111
|
+
const context = await browser.newContext({
|
|
112
|
+
viewport: { width: videoW, height: videoH },
|
|
113
|
+
deviceScaleFactor: pxScale,
|
|
114
|
+
});
|
|
115
|
+
const page = await context.newPage();
|
|
116
|
+
const items: OverlayItem[] = [];
|
|
117
|
+
try {
|
|
118
|
+
for (let i = 0; i < events.length; i++) {
|
|
119
|
+
const ev = events[i];
|
|
120
|
+
const st = styleOf(ev, accent);
|
|
121
|
+
const png = resolve(outDir, `${ev.kind}-${String(i).padStart(3, "0")}.png`);
|
|
122
|
+
const radius = st.shape === "ring" ? 999 : 10;
|
|
123
|
+
if (ev.kind === "highlight") {
|
|
124
|
+
const inset = st.padding + st.thickness;
|
|
125
|
+
const w = ev.w + 2 * inset + 2 * SHADOW_MARGIN;
|
|
126
|
+
const h = ev.h + 2 * inset + 2 * SHADOW_MARGIN;
|
|
127
|
+
await page.setContent(
|
|
128
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}
|
|
129
|
+
#m{position:absolute;left:0;top:0;width:${w}px;height:${h}px;}
|
|
130
|
+
.r{position:absolute;left:${SHADOW_MARGIN}px;top:${SHADOW_MARGIN}px;
|
|
131
|
+
width:${w - 2 * SHADOW_MARGIN}px;height:${h - 2 * SHADOW_MARGIN}px;
|
|
132
|
+
border:${st.thickness}px solid ${st.color};border-radius:${radius}px;
|
|
133
|
+
box-shadow:0 0 0 2px rgba(255,255,255,.35),0 4px 16px rgba(0,0,0,.28);}
|
|
134
|
+
</style></head><body><div id="m"><div class="r"></div></div></body></html>`,
|
|
135
|
+
{ waitUntil: "load" }
|
|
136
|
+
);
|
|
137
|
+
await shotElement(page, png);
|
|
138
|
+
items.push({
|
|
139
|
+
png,
|
|
140
|
+
x: Math.round((ev.x - inset - SHADOW_MARGIN) * pxScale),
|
|
141
|
+
y: Math.round((ev.y - inset - SHADOW_MARGIN) * pxScale),
|
|
142
|
+
a: ev.a,
|
|
143
|
+
b: ev.b,
|
|
144
|
+
});
|
|
145
|
+
} else if (ev.kind === "spotlight") {
|
|
146
|
+
const pad = ev.padding ?? 10;
|
|
147
|
+
const dim = ev.dimTo ?? 0.55;
|
|
148
|
+
const hx = Math.max(0, ev.x - pad);
|
|
149
|
+
const hy = Math.max(0, ev.y - pad);
|
|
150
|
+
const hw = Math.min(videoW - hx, ev.w + 2 * pad);
|
|
151
|
+
const hh = Math.min(videoH - hy, ev.h + 2 * pad);
|
|
152
|
+
await page.setContent(
|
|
153
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}
|
|
154
|
+
#m{position:absolute;left:0;top:0;width:${videoW}px;height:${videoH}px;overflow:hidden;}
|
|
155
|
+
.h{position:absolute;left:${hx}px;top:${hy}px;width:${hw}px;height:${hh}px;
|
|
156
|
+
border-radius:${radius}px;box-shadow:0 0 0 ${videoW + videoH}px rgba(0,0,0,${dim});
|
|
157
|
+
border:${st.thickness}px solid ${st.color};opacity:1;}
|
|
158
|
+
</style></head><body><div id="m"><div class="h"></div></div></body></html>`,
|
|
159
|
+
{ waitUntil: "load" }
|
|
160
|
+
);
|
|
161
|
+
await shotElement(page, png);
|
|
162
|
+
items.push({ png, x: 0, y: 0, a: ev.a, b: ev.b });
|
|
163
|
+
} else {
|
|
164
|
+
// callout: a pill with a small arrow facing the element.
|
|
165
|
+
const gap = 12;
|
|
166
|
+
const cx = ev.x + ev.w / 2;
|
|
167
|
+
const cy = ev.y + ev.h / 2;
|
|
168
|
+
let placement = ev.placement ?? "auto";
|
|
169
|
+
if (placement === "auto") {
|
|
170
|
+
placement = ev.y + ev.h + 90 < videoH ? "bottom" : ev.y - 90 > 0 ? "top" : "right";
|
|
171
|
+
}
|
|
172
|
+
const arrowSide = { bottom: "top", top: "bottom", left: "right", right: "left" }[
|
|
173
|
+
placement as "bottom" | "top" | "left" | "right"
|
|
174
|
+
];
|
|
175
|
+
const tri = 9;
|
|
176
|
+
const arrowCss = {
|
|
177
|
+
top: `left:50%;top:-${tri}px;margin-left:-${tri}px;border-width:0 ${tri}px ${tri}px ${tri}px;border-color:transparent transparent ${st.color} transparent;`,
|
|
178
|
+
bottom: `left:50%;bottom:-${tri}px;margin-left:-${tri}px;border-width:${tri}px ${tri}px 0 ${tri}px;border-color:${st.color} transparent transparent transparent;`,
|
|
179
|
+
left: `top:50%;left:-${tri}px;margin-top:-${tri}px;border-width:${tri}px ${tri}px ${tri}px 0;border-color:transparent ${st.color} transparent transparent;`,
|
|
180
|
+
right: `top:50%;right:-${tri}px;margin-top:-${tri}px;border-width:${tri}px 0 ${tri}px ${tri}px;border-color:transparent transparent transparent ${st.color};`,
|
|
181
|
+
}[arrowSide];
|
|
182
|
+
await page.setContent(
|
|
183
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}
|
|
184
|
+
#m{position:absolute;left:0;top:0;padding:${tri + SHADOW_MARGIN}px;display:inline-block;}
|
|
185
|
+
.p{position:relative;display:inline-block;max-width:${Math.round(videoW * 0.5)}px;
|
|
186
|
+
padding:10px 16px;border-radius:12px;background:${st.color};color:#fff;
|
|
187
|
+
font-size:22px;line-height:1.25;font-weight:650;white-space:nowrap;
|
|
188
|
+
box-shadow:0 6px 20px rgba(0,0,0,.35);}
|
|
189
|
+
.p:after{content:"";position:absolute;width:0;height:0;border-style:solid;${arrowCss}}
|
|
190
|
+
</style></head><body><div id="m"><div class="p">${esc(ev.text ?? "")}</div></div></body></html>`,
|
|
191
|
+
{ waitUntil: "load" }
|
|
192
|
+
);
|
|
193
|
+
const size = await shotElement(page, png);
|
|
194
|
+
// Size includes the transparent padding; the pill itself is inset.
|
|
195
|
+
const inset = tri + SHADOW_MARGIN;
|
|
196
|
+
const pw = size.w - 2 * inset;
|
|
197
|
+
const ph = size.h - 2 * inset;
|
|
198
|
+
let px = cx - pw / 2;
|
|
199
|
+
let py = cy - ph / 2;
|
|
200
|
+
if (placement === "bottom") py = ev.y + ev.h + gap;
|
|
201
|
+
else if (placement === "top") py = ev.y - gap - ph;
|
|
202
|
+
else if (placement === "left") px = ev.x - gap - pw;
|
|
203
|
+
else px = ev.x + ev.w + gap;
|
|
204
|
+
px = Math.max(4, Math.min(videoW - pw - 4, px));
|
|
205
|
+
py = Math.max(4, Math.min(videoH - ph - 4, py));
|
|
206
|
+
items.push({
|
|
207
|
+
png,
|
|
208
|
+
x: Math.round((px - inset) * pxScale),
|
|
209
|
+
y: Math.round((py - inset) * pxScale),
|
|
210
|
+
a: ev.a,
|
|
211
|
+
b: ev.b,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
await browser.close();
|
|
217
|
+
}
|
|
218
|
+
return items;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Keystroke chips: one PNG per distinct label, positioned bottom-right above
|
|
223
|
+
* the caption band. Returns overlay items in content time.
|
|
224
|
+
*/
|
|
225
|
+
export async function renderKeyChipPngs(
|
|
226
|
+
keys: PlacedKey[],
|
|
227
|
+
outDir: string,
|
|
228
|
+
videoW: number,
|
|
229
|
+
videoH: number,
|
|
230
|
+
pxScale: number
|
|
231
|
+
): Promise<OverlayItem[]> {
|
|
232
|
+
if (keys.length === 0) return [];
|
|
233
|
+
await ensureDir(outDir);
|
|
234
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
235
|
+
const context = await browser.newContext({
|
|
236
|
+
viewport: { width: 600, height: 120 },
|
|
237
|
+
deviceScaleFactor: pxScale,
|
|
238
|
+
});
|
|
239
|
+
const page = await context.newPage();
|
|
240
|
+
const byLabel = new Map<string, { png: string; w: number; h: number }>();
|
|
241
|
+
const items: OverlayItem[] = [];
|
|
242
|
+
try {
|
|
243
|
+
for (const k of keys) {
|
|
244
|
+
let r = byLabel.get(k.keys);
|
|
245
|
+
if (!r) {
|
|
246
|
+
const png = resolve(outDir, `key-${byLabel.size}.png`);
|
|
247
|
+
const parts = k.keys
|
|
248
|
+
.split(" ")
|
|
249
|
+
.map((p) => `<kbd>${esc(p)}</kbd>`)
|
|
250
|
+
.join("<span class=plus>+</span>");
|
|
251
|
+
await page.setContent(
|
|
252
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}
|
|
253
|
+
#m{position:absolute;left:0;top:0;display:inline-flex;align-items:center;gap:6px;
|
|
254
|
+
padding:8px 12px;border-radius:12px;background:rgba(12,14,22,.78);
|
|
255
|
+
box-shadow:0 4px 16px rgba(0,0,0,.35);}
|
|
256
|
+
kbd{display:inline-block;min-width:36px;padding:4px 10px;border-radius:8px;
|
|
257
|
+
background:#f4f5f8;color:#111;font-size:22px;font-weight:700;text-align:center;
|
|
258
|
+
box-shadow:inset 0 -2px 0 rgba(0,0,0,.25);}
|
|
259
|
+
.plus{color:#fff;font-size:18px;font-weight:600;}
|
|
260
|
+
</style></head><body><div id="m">${parts}</div></body></html>`,
|
|
261
|
+
{ waitUntil: "load" }
|
|
262
|
+
);
|
|
263
|
+
const size = await shotElement(page, png);
|
|
264
|
+
r = { png, ...size };
|
|
265
|
+
byLabel.set(k.keys, r);
|
|
266
|
+
}
|
|
267
|
+
items.push({
|
|
268
|
+
png: r.png,
|
|
269
|
+
x: Math.round((videoW - r.w - 28) * pxScale),
|
|
270
|
+
y: Math.round((videoH - r.h - 200) * pxScale),
|
|
271
|
+
a: k.a,
|
|
272
|
+
b: k.b,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
await browser.close();
|
|
277
|
+
}
|
|
278
|
+
return items;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Click rings: three stepped ring PNGs per click (a compose-time click pulse). */
|
|
282
|
+
export async function renderClickRingPngs(
|
|
283
|
+
clicks: PlacedClick[],
|
|
284
|
+
outDir: string,
|
|
285
|
+
pxScale: number,
|
|
286
|
+
color: string = DEFAULT_ACCENT
|
|
287
|
+
): Promise<OverlayItem[]> {
|
|
288
|
+
if (clicks.length === 0) return [];
|
|
289
|
+
await ensureDir(outDir);
|
|
290
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
291
|
+
const context = await browser.newContext({
|
|
292
|
+
viewport: { width: 64, height: 64 },
|
|
293
|
+
deviceScaleFactor: pxScale,
|
|
294
|
+
});
|
|
295
|
+
const page = await context.newPage();
|
|
296
|
+
const pngs: string[] = [];
|
|
297
|
+
try {
|
|
298
|
+
for (let i = 0; i < CLICK_RING_STEPS.length; i++) {
|
|
299
|
+
const s = CLICK_RING_STEPS[i];
|
|
300
|
+
const png = resolve(outDir, `ring-${i}.png`);
|
|
301
|
+
await page.setContent(
|
|
302
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}
|
|
303
|
+
#m{position:absolute;left:0;top:0;width:${s.d}px;height:${s.d}px;border-radius:50%;
|
|
304
|
+
border:3px solid ${color};opacity:${s.o};background:rgba(255,255,255,${s.o * 0.25});}
|
|
305
|
+
</style></head><body><div id="m"></div></body></html>`,
|
|
306
|
+
{ waitUntil: "load" }
|
|
307
|
+
);
|
|
308
|
+
await shotElement(page, png);
|
|
309
|
+
pngs.push(png);
|
|
310
|
+
}
|
|
311
|
+
} finally {
|
|
312
|
+
await browser.close();
|
|
313
|
+
}
|
|
314
|
+
const items: OverlayItem[] = [];
|
|
315
|
+
for (const c of clicks) {
|
|
316
|
+
let t = c.t;
|
|
317
|
+
CLICK_RING_STEPS.forEach((s, i) => {
|
|
318
|
+
items.push({
|
|
319
|
+
png: pngs[i],
|
|
320
|
+
x: Math.round((c.x - s.d / 2) * pxScale),
|
|
321
|
+
y: Math.round((c.y - s.d / 2) * pxScale),
|
|
322
|
+
a: t,
|
|
323
|
+
b: t + s.ms / 1000,
|
|
324
|
+
});
|
|
325
|
+
t += s.ms / 1000;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return items;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Max redact spans per ffmpeg pass (each is a split + crop + blur + overlay). */
|
|
332
|
+
export const REDACT_BATCH = 12;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Build one `-filter_complex` blurring `spans` over input 0. Boxes are clamped
|
|
336
|
+
* to the frame and the blur radius to what boxblur accepts for the crop size.
|
|
337
|
+
*/
|
|
338
|
+
export function buildRedactFilter(
|
|
339
|
+
spans: PlacedRedact[],
|
|
340
|
+
outW: number,
|
|
341
|
+
outH: number,
|
|
342
|
+
pxScale: number
|
|
343
|
+
): string | null {
|
|
344
|
+
if (spans.length === 0) return null;
|
|
345
|
+
const chain: string[] = [];
|
|
346
|
+
let prev = "0:v";
|
|
347
|
+
spans.forEach((s, i) => {
|
|
348
|
+
const x = Math.max(0, Math.round(s.x * pxScale));
|
|
349
|
+
const y = Math.max(0, Math.round(s.y * pxScale));
|
|
350
|
+
// Even crop dims (yuv420p chroma is half-size); luma radius < min(w,h)/2,
|
|
351
|
+
// chroma radius < min(w,h)/4 — boxblur rejects anything larger.
|
|
352
|
+
const w = Math.max(4, Math.min(outW - x, Math.round(s.w * pxScale)) & ~1);
|
|
353
|
+
const h = Math.max(4, Math.min(outH - y, Math.round(s.h * pxScale)) & ~1);
|
|
354
|
+
const r = Math.max(1, Math.min(Math.round(s.blur * pxScale), Math.floor(Math.min(w, h) / 2) - 1));
|
|
355
|
+
const cr = Math.max(0, Math.min(Math.floor(r / 2), Math.floor(Math.min(w, h) / 4) - 1));
|
|
356
|
+
const label = i === spans.length - 1 ? "vout" : `r${i}`;
|
|
357
|
+
chain.push(
|
|
358
|
+
`[${prev}]split[a${i}][b${i}];` +
|
|
359
|
+
`[b${i}]crop=${w}:${h}:${x}:${y},boxblur=lr=${r}:lp=2:cr=${cr}:cp=2[bb${i}];` +
|
|
360
|
+
`[a${i}][bb${i}]overlay=${x}:${y}:enable='between(t,${s.a.toFixed(3)},${s.b.toFixed(3)})'[${label}]`
|
|
361
|
+
);
|
|
362
|
+
prev = label;
|
|
363
|
+
});
|
|
364
|
+
return chain.join(";");
|
|
365
|
+
}
|
package/src/caption-render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chromium } from "playwright";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { Cue } from "./captions.js";
|
|
4
|
+
import type { CaptionsConfig } from "./types.js";
|
|
4
5
|
import { ensureDir } from "./util.js";
|
|
5
6
|
|
|
6
7
|
export interface RenderedCue {
|
|
@@ -23,7 +24,8 @@ export async function renderCaptionPngs(
|
|
|
23
24
|
cues: Cue[],
|
|
24
25
|
outDir: string,
|
|
25
26
|
videoWidth: number,
|
|
26
|
-
scale = 1
|
|
27
|
+
scale = 1,
|
|
28
|
+
look: CaptionsConfig & { font?: string } = {}
|
|
27
29
|
): Promise<RenderedCue[]> {
|
|
28
30
|
await ensureDir(outDir);
|
|
29
31
|
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
@@ -39,7 +41,7 @@ export async function renderCaptionPngs(
|
|
|
39
41
|
const rendered: RenderedCue[] = [];
|
|
40
42
|
try {
|
|
41
43
|
for (const cue of cues) {
|
|
42
|
-
await page.setContent(html(cue.text), { waitUntil: "load" });
|
|
44
|
+
await page.setContent(html(cue.text, look), { waitUntil: "load" });
|
|
43
45
|
const png = resolve(outDir, `cue-${String(cue.index).padStart(3, "0")}.png`);
|
|
44
46
|
await page.screenshot({ path: png, omitBackground: true });
|
|
45
47
|
rendered.push({ png, startMs: cue.startMs, endMs: cue.endMs, height: STRIP_HEIGHT });
|
|
@@ -50,20 +52,27 @@ export async function renderCaptionPngs(
|
|
|
50
52
|
return rendered;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function html(text: string): string {
|
|
55
|
+
function html(text: string, look: CaptionsConfig & { font?: string }): string {
|
|
54
56
|
const safe = text
|
|
55
57
|
.replace(/&/g, "&")
|
|
56
58
|
.replace(/</g, "<")
|
|
57
59
|
.replace(/>/g, ">");
|
|
60
|
+
const font = look.font ?? '-apple-system,"Helvetica Neue",Arial,sans-serif';
|
|
61
|
+
const size = look.size ?? 30;
|
|
62
|
+
const color = look.color ?? "#fff";
|
|
63
|
+
const bg = look.background ?? "rgba(12,14,22,.66)";
|
|
64
|
+
const bar = look.style === "bar";
|
|
65
|
+
// "bar": a full-width band flush with the strip's bottom edge; "pill": the
|
|
66
|
+
// default centered rounded box 22 px above it (compose positions the strip).
|
|
58
67
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
59
68
|
html,body{margin:0;padding:0;background:transparent;width:100%;height:100%;}
|
|
60
69
|
.wrap{position:absolute;inset:0;display:flex;align-items:flex-end;
|
|
61
|
-
justify-content:center;padding-bottom:
|
|
62
|
-
.cap{max-width:80%;
|
|
63
|
-
font-size
|
|
64
|
-
background
|
|
70
|
+
justify-content:center;padding-bottom:${bar ? 0 : 22}px;}
|
|
71
|
+
.cap{max-width:${bar ? "100%" : "80%"};${bar ? "width:100%;" : ""}font-family:${font};
|
|
72
|
+
font-size:${size}px;line-height:1.3;font-weight:650;color:${color};text-align:center;
|
|
73
|
+
background:${bg};padding:${bar ? "14px 40px" : "10px 20px"};border-radius:${bar ? 0 : 12}px;
|
|
65
74
|
-webkit-font-smoothing:antialiased;
|
|
66
75
|
text-shadow:0 1px 3px rgba(0,0,0,.55);
|
|
67
|
-
box-shadow:0 4px 18px rgba(0,0,0,.35);}
|
|
76
|
+
${bar ? "" : "box-shadow:0 4px 18px rgba(0,0,0,.35);"}}
|
|
68
77
|
</style></head><body><div class="wrap"><div class="cap">${safe}</div></div></body></html>`;
|
|
69
78
|
}
|