/** * Concept → visual motif layer (the source skill's "visualize what's being said"). * * Scans the transcript and, at the moment a concept is spoken, surfaces a matching * **hairline icon** (money → `$`, team → people, launch → rocket, …) or an * **infographic stat callout** (a number/percentage with a ring gauge). These are * full-frame SVG overlays composited in the upper area by the local renderer, the * same way the giant anchor headlines are — free, no provider, no moderation. * * Everything here is pure + deterministic and unit-tested. Icons are simple stroked * geometry (no portraits, no bitmaps) so `sharp`/resvg renders them reliably. */ /** A detected motif and the window it pulses on. */ export interface MotifMoment { kind: 'icon' | 'stat'; /** icon id (kind === 'icon'). */ iconId?: string; /** display number, e.g. "40" or "3" (kind === 'stat'). */ value?: string; /** unit suffix, e.g. "%" (kind === 'stat'). */ unit?: string; /** ring-gauge fill 0..1 (percentages; kind === 'stat'). */ gauge?: number; /** small caption under the motif (a short concept tag). */ label?: string; start: number; end: number; } /** Spelled-number → value (covers the common reel vocabulary). */ const NUMBER_WORDS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000, }; /** * Concept keyword → icon. Ordered by priority (first match wins per segment). Each * entry: [matcher, iconId, short tag label]. Stems are matched word-wise. */ const CONCEPT_ICONS: Array<{ re: RegExp; icon: string; label: string }> = [ { re: /\b(revenue|sales?|profit|money|dollars?|earn(ed|ings)?|paid|pricing|price|income)\b/i, icon: 'dollar', label: 'REVENUE' }, { re: /\b(budget|cost|costs|spend|spent|cheap|expensive|free)\b/i, icon: 'wallet', label: 'COST' }, { re: /\b(grow(th|s|n)?|grew|jump(ed)?|rise|rising|rose|increase[ds]?|scal(e|ing|ed)|surg(e|ed)|boom(ed)?)\b/i, icon: 'trend', label: 'GROWTH' }, { re: /\b(team|teams|people|audience|users?|customers?|community|everyone|hire[ds]?|crew)\b/i, icon: 'people', label: 'PEOPLE' }, { re: /\b(ai|a\.i\.|agents?|bots?|robots?|model(s)?|intelligence|neural|automation|automate[ds]?)\b/i, icon: 'nodes', label: 'AI' }, { re: /\b(build|built|building|ship(ped|s)?|launch(ed|es)?|create[ds]?|made|make|produc(e|ed|tion))\b/i, icon: 'rocket', label: 'BUILD' }, { re: /\b(time|days?|weeks?|months?|years?|hours?|minutes?|fast|quick(ly)?|now|today|instant(ly)?)\b/i, icon: 'clock', label: 'TIME' }, { re: /\b(world|global(ly)?|everywhere|planet|earth|international|worldwide)\b/i, icon: 'globe', label: 'GLOBAL' }, { re: /\b(video|videos|watch(ing|ed)?|reel|reels|content|stream(ing)?|clip)\b/i, icon: 'play', label: 'VIDEO' }, { re: /\b(energy|power(ful)?|force|impact|strong|surge|boost|electric)\b/i, icon: 'bolt', label: 'POWER' }, { re: /\b(idea|ideas|think(ing)?|learn(ing|ed)?|knowledge|insight|smart|discover(y|ed)?)\b/i, icon: 'bulb', label: 'IDEA' }, { re: /\b(goal|goals|target|campaign|mission|aim|focus|win(ning)?)\b/i, icon: 'target', label: 'GOAL' }, { re: /\b(data|stats?|metrics?|numbers?|chart|analytics|results?|performance)\b/i, icon: 'bars', label: 'DATA' }, { re: /\b(secret|hidden|unknown|reveal(ed|s)?|behind|beneath|surface)\b/i, icon: 'eye', label: 'REVEAL' }, { re: /\b(ocean|sea|water|wave|waves|tide|deep)\b/i, icon: 'wave', label: 'OCEAN' }, ]; /** Parse a number value from a token (digit or spelled word). Pure. */ function numberFromToken(tok: string): number | undefined { const bare = tok.toLowerCase().replace(/[^a-z0-9]/g, ''); if (/^\d+$/.test(bare)) return Number(bare); if (bare in NUMBER_WORDS) return NUMBER_WORDS[bare]; return undefined; } /** * Detect the strongest motif per segment — a numeric **stat** (preferred when a * number is present) or a concept **icon**. Returns at most `max` moments (one per * segment), each pulsed on a short window at the spoken moment, so the reel doesn't * clutter. Pure & deterministic. */ export function detectMotifs( segments: Array<{ start: number; end: number; text: string }>, max = 5, ): MotifMoment[] { const out: MotifMoment[] = []; for (const seg of segments) { if (out.length >= max) break; const tokens = seg.text.split(/\s+/); let motif: MotifMoment | undefined; // 1. Stat: the first number token (+ a trailing percent/unit). for (let i = 0; i < tokens.length; i++) { const n = numberFromToken(tokens[i]); if (n === undefined) continue; const next = (tokens[i + 1] ?? '').toLowerCase().replace(/[^a-z%]/g, ''); const isPct = next === 'percent' || tokens[i].includes('%'); motif = { kind: 'stat', value: String(n), ...(isPct ? { unit: '%', gauge: Math.max(0, Math.min(1, n / 100)) } : {}), start: seg.start + 0.15, end: Math.min(seg.end, seg.start + 1.8), }; break; } // 2. Else a concept icon. if (!motif) { const hit = CONCEPT_ICONS.find((c) => c.re.test(seg.text)); if (hit) { motif = { kind: 'icon', iconId: hit.icon, label: hit.label, start: seg.start + 0.15, end: Math.min(seg.end, seg.start + 1.8), }; } } if (motif) out.push(motif); } return out; } /** Inner stroked geometry for each icon, drawn in a 0..100 box. Pure. */ function iconGeometry(iconId: string, color: string): string { const s = `stroke="${color}" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"`; switch (iconId) { case 'trend': return ``; case 'bars': return ``; case 'dollar': return ``; case 'wallet': return ``; case 'people': return ``; case 'nodes': return ``; case 'rocket': return ``; case 'clock': return ``; case 'globe': return ``; case 'play': return ``; case 'bolt': return ``; case 'bulb': return ``; case 'target': return ``; case 'eye': return ``; case 'wave': return ``; default: return ``; } } /** * Full-frame SVG for an **icon motif** — a hairline concept icon in the accent * colour, centred in the upper third, with a small uppercase concept tag beneath. * Pure & deterministic. */ export function iconMotifSvg( motif: MotifMoment, dims: { width: number; height: number }, opts: { accent: string; labelFamily: string }, ): string { const { width: W, height: H } = dims; const box = Math.round(Math.min(W, H) * 0.14); // Upper-right "stat bug" position — clears a centred talking-head / avatar face. const cx = Math.round(W * 0.82); const cy = Math.round(H * 0.22); const x0 = cx - box / 2; const y0 = cy - box / 2; const label = (motif.label ?? '').toUpperCase(); const labelSvg = label ? `${esc(label)}` : ''; return ` ${iconGeometry(motif.iconId ?? '', opts.accent)} ${labelSvg} `; } /** * Full-frame SVG for a **stat callout** — a big accent number (+ optional unit) with * a ring gauge for percentages, centred in the upper third. Pure & deterministic. */ export function statMotifSvg( motif: MotifMoment, dims: { width: number; height: number }, opts: { accent: string; numberFamily: string }, ): string { const { width: W, height: H } = dims; // Upper-right "stat bug" position — clears a centred talking-head / avatar face. const cx = Math.round(W * 0.82); const cy = Math.round(H * 0.22); const hasGauge = motif.gauge !== undefined; const r = Math.round(Math.min(W, H) * 0.095); const value = `${motif.value ?? ''}${motif.unit ?? ''}`; // Plain numbers read bigger (no ring competing); percentages sit inside the ring. const fontSize = hasGauge ? Math.round(r * 0.86) : Math.round(Math.min(W, H) * 0.1); let deco = ''; if (hasGauge) { const circ = 2 * Math.PI * r; const fill = circ * Math.max(0, Math.min(1, motif.gauge as number)); deco = ` `; } else { // A thin accent underline turns a lone number into an infographic beat. const half = Math.round(fontSize * 0.42); deco = ``; } return ` ${deco} ${esc(value)} `; } /** Local XML escaper (kept independent of render-local to avoid a cycle). */ function esc(t: string): string { return t.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); }