@argo-video/cli 0.1.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.
Files changed (57) hide show
  1. package/.claude/settings.local.json +34 -0
  2. package/DESIGN.md +261 -0
  3. package/README.md +192 -0
  4. package/bin/argo.js +2 -0
  5. package/docs/enhancement-proposal.md +262 -0
  6. package/docs/superpowers/plans/2026-03-12-argo.md +208 -0
  7. package/docs/superpowers/plans/2026-03-12-editorial-overlay-system.md +1560 -0
  8. package/docs/superpowers/plans/2026-03-13-npm-rename-skill-showcase.md +499 -0
  9. package/docs/superpowers/specs/2026-03-13-npm-rename-skill-showcase-design.md +109 -0
  10. package/package.json +38 -0
  11. package/skills/argo-demo-creator.md +355 -0
  12. package/src/asset-server.ts +81 -0
  13. package/src/captions.ts +36 -0
  14. package/src/cli.ts +97 -0
  15. package/src/config.ts +125 -0
  16. package/src/export.ts +93 -0
  17. package/src/fixtures.ts +50 -0
  18. package/src/index.ts +41 -0
  19. package/src/init.ts +114 -0
  20. package/src/narration.ts +31 -0
  21. package/src/overlays/index.ts +54 -0
  22. package/src/overlays/manifest.ts +68 -0
  23. package/src/overlays/motion.ts +27 -0
  24. package/src/overlays/templates.ts +121 -0
  25. package/src/overlays/types.ts +73 -0
  26. package/src/overlays/zones.ts +82 -0
  27. package/src/pipeline.ts +120 -0
  28. package/src/record.ts +123 -0
  29. package/src/tts/align.ts +75 -0
  30. package/src/tts/cache.ts +65 -0
  31. package/src/tts/engine.ts +147 -0
  32. package/src/tts/generate.ts +83 -0
  33. package/src/tts/kokoro.ts +51 -0
  34. package/tests/asset-server.test.ts +67 -0
  35. package/tests/captions.test.ts +76 -0
  36. package/tests/cli.test.ts +131 -0
  37. package/tests/config.test.ts +150 -0
  38. package/tests/e2e/fake-server.ts +45 -0
  39. package/tests/e2e/record.e2e.test.ts +131 -0
  40. package/tests/export.test.ts +155 -0
  41. package/tests/fixtures.test.ts +74 -0
  42. package/tests/init.test.ts +77 -0
  43. package/tests/narration.test.ts +120 -0
  44. package/tests/overlays/index.test.ts +73 -0
  45. package/tests/overlays/manifest.test.ts +120 -0
  46. package/tests/overlays/motion.test.ts +34 -0
  47. package/tests/overlays/templates.test.ts +69 -0
  48. package/tests/overlays/types.test.ts +36 -0
  49. package/tests/overlays/zones.test.ts +49 -0
  50. package/tests/pipeline.test.ts +177 -0
  51. package/tests/record.test.ts +87 -0
  52. package/tests/tts/align.test.ts +118 -0
  53. package/tests/tts/cache.test.ts +110 -0
  54. package/tests/tts/engine.test.ts +204 -0
  55. package/tests/tts/generate.test.ts +177 -0
  56. package/tests/tts/kokoro.test.ts +25 -0
  57. package/tsconfig.json +19 -0
package/src/export.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export interface ExportOptions {
6
+ demoName: string;
7
+ argoDir: string;
8
+ outputDir: string;
9
+ preset?: string;
10
+ crf?: number;
11
+ fps?: number;
12
+ }
13
+
14
+ /**
15
+ * Check whether ffmpeg is available on the system PATH.
16
+ * Returns true if found, throws with install instructions otherwise.
17
+ */
18
+ export function checkFfmpeg(): boolean {
19
+ try {
20
+ execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });
21
+ return true;
22
+ } catch {
23
+ throw new Error(
24
+ 'ffmpeg is not installed. Install it with:\n' +
25
+ ' macOS: brew install ffmpeg\n' +
26
+ ' Linux: apt install ffmpeg\n' +
27
+ ' Windows: choco install ffmpeg',
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Export a demo to MP4 by combining the screen recording with aligned narration audio.
34
+ */
35
+ export async function exportVideo(options: ExportOptions): Promise<string> {
36
+ const {
37
+ demoName,
38
+ argoDir,
39
+ outputDir,
40
+ preset = 'slow',
41
+ crf = 16,
42
+ fps,
43
+ } = options;
44
+
45
+ checkFfmpeg();
46
+
47
+ const demoDir = join(argoDir, demoName);
48
+ const videoPath = join(demoDir, 'video.webm');
49
+ const audioPath = join(demoDir, 'narration-aligned.wav');
50
+
51
+ if (!existsSync(videoPath)) {
52
+ throw new Error(`Missing video.webm at ${videoPath}`);
53
+ }
54
+ if (!existsSync(audioPath)) {
55
+ throw new Error(`Missing narration-aligned.wav at ${audioPath}`);
56
+ }
57
+
58
+ if (!existsSync(outputDir)) {
59
+ mkdirSync(outputDir, { recursive: true });
60
+ }
61
+
62
+ const outputPath = join(outputDir, `${demoName}.mp4`);
63
+
64
+ const args: string[] = [
65
+ '-i', videoPath,
66
+ '-i', audioPath,
67
+ '-c:v', 'libx264',
68
+ '-preset', preset,
69
+ '-crf', String(crf),
70
+ '-c:a', 'aac',
71
+ '-b:a', '192k',
72
+ ];
73
+
74
+ if (fps !== undefined) {
75
+ args.push('-r', String(fps));
76
+ }
77
+
78
+ args.push('-shortest', '-y', outputPath);
79
+
80
+ const result = spawnSync('ffmpeg', args, { stdio: 'inherit' });
81
+
82
+ if (result.error) {
83
+ throw new Error(`Failed to launch ffmpeg: ${result.error.message}`);
84
+ }
85
+ if (result.signal) {
86
+ throw new Error(`ffmpeg was killed by signal ${result.signal}`);
87
+ }
88
+ if (result.status !== 0) {
89
+ throw new Error(`ffmpeg failed with exit code ${result.status}`);
90
+ }
91
+
92
+ return outputPath;
93
+ }
@@ -0,0 +1,50 @@
1
+ import { test as base, expect } from '@playwright/test';
2
+ import type { Page } from '@playwright/test';
3
+ import { NarrationTimeline } from './narration.js';
4
+
5
+ type TimelineFactory = (title: string) => NarrationTimeline;
6
+
7
+ const defaultFactory: TimelineFactory = () => new NarrationTimeline();
8
+
9
+ export function createNarrationFixture(factory: TimelineFactory = defaultFactory) {
10
+ return async (
11
+ _context: Record<string, unknown>,
12
+ use: (timeline: NarrationTimeline) => Promise<void>,
13
+ testInfo: { title: string },
14
+ ) => {
15
+ const timeline = factory(testInfo.title);
16
+ timeline.start();
17
+ try {
18
+ await use(timeline);
19
+ } finally {
20
+ await timeline.flush(`narration-${testInfo.title}.json`);
21
+ }
22
+ };
23
+ }
24
+
25
+ export async function demoType(
26
+ page: Page,
27
+ selector: string,
28
+ text: string,
29
+ delay = 60,
30
+ ): Promise<void> {
31
+ await page.locator(selector).pressSequentially(text, { delay });
32
+ }
33
+
34
+ export const test = base.extend<{ narration: NarrationTimeline }>({
35
+ narration: async ({}, use, testInfo) => {
36
+ const timeline = new NarrationTimeline();
37
+ timeline.start();
38
+ try {
39
+ await use(timeline);
40
+ } finally {
41
+ const argoDir = process.env.ARGO_OUTPUT_DIR;
42
+ const outputPath = argoDir
43
+ ? `${argoDir}/.timing.json`
44
+ : `narration-${testInfo.title}.json`;
45
+ await timeline.flush(outputPath);
46
+ }
47
+ },
48
+ });
49
+
50
+ export { expect };
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Argo — Playwright demo recording with AI voiceover
2
+
3
+ // Config
4
+ export {
5
+ defineConfig,
6
+ loadConfig,
7
+ demosProject,
8
+ type ArgoConfig,
9
+ type UserConfig,
10
+ type TTSConfig,
11
+ type TTSEngine,
12
+ type VideoConfig,
13
+ type ExportConfig,
14
+ } from './config.js';
15
+
16
+ // Fixtures
17
+ export { test, expect, demoType } from './fixtures.js';
18
+
19
+ // Narration
20
+ export { NarrationTimeline } from './narration.js';
21
+
22
+ // Captions
23
+ export { showCaption, hideCaption, withCaption } from './captions.js';
24
+
25
+ // Overlays
26
+ export {
27
+ showOverlay,
28
+ hideOverlay,
29
+ withOverlay,
30
+ type OverlayCue,
31
+ type OverlayManifestEntry,
32
+ type Zone,
33
+ type TemplateType,
34
+ type MotionPreset,
35
+ } from './overlays/index.js';
36
+
37
+ // TTS
38
+ export { type TTSEngineOptions } from './tts/engine.js';
39
+
40
+ // Init
41
+ export { init } from './init.js';
package/src/init.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ async function writeIfMissing(filePath: string, content: string): Promise<boolean> {
5
+ try {
6
+ await access(filePath);
7
+ console.log(` skip ${filePath} (already exists)`);
8
+ return false;
9
+ } catch (err: any) {
10
+ if (err?.code !== 'ENOENT') {
11
+ throw new Error(`Cannot access ${filePath}: ${err.message}`);
12
+ }
13
+ await writeFile(filePath, content, 'utf-8');
14
+ console.log(` create ${filePath}`);
15
+ return true;
16
+ }
17
+ }
18
+
19
+ const EXAMPLE_DEMO = `import { test } from '@argo-video/cli';
20
+ import { showCaption, withCaption } from '@argo-video/cli';
21
+
22
+ test('example', async ({ page, narration }) => {
23
+ await page.goto('/');
24
+
25
+ narration.mark('welcome');
26
+ await showCaption(page, 'welcome', 'Welcome to our app', 3000);
27
+
28
+ narration.mark('action');
29
+ await withCaption(page, 'action', 'Watch how easy it is', async () => {
30
+ await page.click('button');
31
+ });
32
+
33
+ narration.mark('done');
34
+ await showCaption(page, 'done', 'All done!', 2000);
35
+ });
36
+ `;
37
+
38
+ const EXAMPLE_VOICEOVER = JSON.stringify(
39
+ [
40
+ { scene: 'welcome', text: 'Welcome to our app — let me show you around.' },
41
+ { scene: 'action', text: 'It only takes one click to get started.' },
42
+ { scene: 'done', text: "And that's it. You're all set.", voice: 'af_heart' },
43
+ ],
44
+ null,
45
+ 2,
46
+ ) + '\n';
47
+
48
+ const EXAMPLE_OVERLAYS = JSON.stringify(
49
+ [
50
+ {
51
+ scene: 'welcome',
52
+ type: 'lower-third',
53
+ text: 'Welcome to our app',
54
+ },
55
+ {
56
+ scene: 'action',
57
+ type: 'headline-card',
58
+ placement: 'top-left',
59
+ title: 'One-click setup',
60
+ body: 'Just press the button to get started.',
61
+ motion: 'slide-in',
62
+ },
63
+ ],
64
+ null,
65
+ 2,
66
+ ) + '\n';
67
+
68
+ const ARGO_CONFIG = `export default {
69
+ baseURL: 'http://localhost:3000',
70
+ demosDir: 'demos/',
71
+ outputDir: 'videos/',
72
+ tts: { defaultVoice: 'af_heart', defaultSpeed: 1.0 },
73
+ video: { width: 1920, height: 1080, fps: 30 },
74
+ export: { preset: 'slow', crf: 16 },
75
+ };
76
+ `;
77
+
78
+ const PLAYWRIGHT_CONFIG = `import { defineConfig } from '@playwright/test';
79
+
80
+ export default defineConfig({
81
+ preserveOutput: 'always',
82
+ projects: [
83
+ {
84
+ name: 'demos',
85
+ testDir: 'demos',
86
+ testMatch: '**/*.demo.ts',
87
+ use: {
88
+ baseURL: process.env.BASE_URL || 'http://localhost:3000',
89
+ viewport: { width: 1920, height: 1080 },
90
+ video: {
91
+ mode: 'on',
92
+ size: { width: 1920, height: 1080 },
93
+ },
94
+ },
95
+ },
96
+ ],
97
+ });
98
+ `;
99
+
100
+ export async function init(cwd: string = process.cwd()): Promise<void> {
101
+ const demosDir = join(cwd, 'demos');
102
+ await mkdir(demosDir, { recursive: true });
103
+
104
+ await writeIfMissing(join(demosDir, 'example.demo.ts'), EXAMPLE_DEMO);
105
+ await writeIfMissing(join(demosDir, 'example.voiceover.json'), EXAMPLE_VOICEOVER);
106
+ await writeIfMissing(join(demosDir, 'example.overlays.json'), EXAMPLE_OVERLAYS);
107
+ await writeIfMissing(join(cwd, 'argo.config.js'), ARGO_CONFIG);
108
+ await writeIfMissing(join(cwd, 'playwright.config.ts'), PLAYWRIGHT_CONFIG);
109
+
110
+ console.log('\nArgo initialized! Next steps:');
111
+ console.log(' 1. Edit demos/example.demo.ts');
112
+ console.log(' 2. Run: npx argo record example');
113
+ console.log(' 3. Run: npx argo pipeline example');
114
+ }
@@ -0,0 +1,31 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ export class NarrationTimeline {
5
+ private timings: Map<string, number> = new Map();
6
+ private startTime: number | null = null;
7
+
8
+ start(): void {
9
+ this.startTime = Date.now();
10
+ this.timings = new Map();
11
+ }
12
+
13
+ mark(scene: string): void {
14
+ if (this.startTime === null) {
15
+ throw new Error('Cannot mark before start() has been called');
16
+ }
17
+ if (this.timings.has(scene)) {
18
+ throw new Error(`Duplicate scene name: "${scene}"`);
19
+ }
20
+ this.timings.set(scene, Date.now() - this.startTime);
21
+ }
22
+
23
+ getTimings(): Record<string, number> {
24
+ return Object.fromEntries(this.timings);
25
+ }
26
+
27
+ async flush(outputPath: string): Promise<void> {
28
+ await mkdir(dirname(outputPath), { recursive: true });
29
+ await writeFile(outputPath, JSON.stringify(this.getTimings(), null, 2), 'utf-8');
30
+ }
31
+ }
@@ -0,0 +1,54 @@
1
+ import type { Page } from '@playwright/test';
2
+ import type { OverlayCue, Zone } from './types.js';
3
+ import { injectIntoZone, removeZone, ZONE_ID_PREFIX } from './zones.js';
4
+ import { renderTemplate } from './templates.js';
5
+ import { getMotionCSS, getMotionStyles } from './motion.js';
6
+
7
+ export type { OverlayCue, OverlayManifestEntry, Zone, TemplateType, MotionPreset } from './types.js';
8
+ export { renderTemplate } from './templates.js';
9
+
10
+ export async function showOverlay(
11
+ page: Page,
12
+ _scene: string,
13
+ cue: OverlayCue,
14
+ durationMs: number,
15
+ ): Promise<void> {
16
+ const zone: Zone = cue.placement ?? 'bottom-center';
17
+ const motion = cue.motion ?? 'none';
18
+ const { contentHtml, styles } = renderTemplate(cue);
19
+ const zoneId = ZONE_ID_PREFIX + zone;
20
+ const motionCSS = getMotionCSS(motion, zoneId);
21
+ const motionStyles = getMotionStyles(motion, zoneId);
22
+
23
+ await injectIntoZone(page, zone, contentHtml, { ...styles, ...motionStyles }, motionCSS);
24
+ await page.waitForTimeout(durationMs);
25
+ await removeZone(page, zone);
26
+ }
27
+
28
+ export async function hideOverlay(
29
+ page: Page,
30
+ zone: Zone = 'bottom-center',
31
+ ): Promise<void> {
32
+ await removeZone(page, zone);
33
+ }
34
+
35
+ export async function withOverlay(
36
+ page: Page,
37
+ _scene: string,
38
+ cue: OverlayCue,
39
+ action: () => Promise<void>,
40
+ ): Promise<void> {
41
+ const zone: Zone = cue.placement ?? 'bottom-center';
42
+ const motion = cue.motion ?? 'none';
43
+ const { contentHtml, styles } = renderTemplate(cue);
44
+ const zoneId = ZONE_ID_PREFIX + zone;
45
+ const motionCSS = getMotionCSS(motion, zoneId);
46
+ const motionStyles = getMotionStyles(motion, zoneId);
47
+
48
+ await injectIntoZone(page, zone, contentHtml, { ...styles, ...motionStyles }, motionCSS);
49
+ try {
50
+ await action();
51
+ } finally {
52
+ await removeZone(page, zone);
53
+ }
54
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import type { OverlayManifestEntry } from './types.js';
4
+ import { isValidTemplateType, isValidZone, isValidMotion } from './types.js';
5
+
6
+ export async function loadOverlayManifest(
7
+ manifestPath: string,
8
+ ): Promise<OverlayManifestEntry[] | null> {
9
+ if (!existsSync(manifestPath)) return null;
10
+
11
+ const raw = await readFile(manifestPath, 'utf-8');
12
+
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(raw);
16
+ } catch (err) {
17
+ throw new Error(
18
+ `Failed to parse overlay manifest ${manifestPath}: ${(err as Error).message}`,
19
+ );
20
+ }
21
+
22
+ if (!Array.isArray(parsed)) {
23
+ throw new Error(`Overlay manifest ${manifestPath} must contain a JSON array`);
24
+ }
25
+
26
+ const entries: OverlayManifestEntry[] = [];
27
+
28
+ for (let i = 0; i < parsed.length; i++) {
29
+ const entry = parsed[i];
30
+ const prefix = `Overlay manifest entry ${i}`;
31
+
32
+ if (!entry.scene || typeof entry.scene !== 'string') {
33
+ throw new Error(`${prefix}: missing required field "scene"`);
34
+ }
35
+ if (!entry.type || typeof entry.type !== 'string') {
36
+ throw new Error(`${prefix}: missing required field "type"`);
37
+ }
38
+ if (!isValidTemplateType(entry.type)) {
39
+ throw new Error(`${prefix}: unknown overlay type "${entry.type}"`);
40
+ }
41
+ if (entry.placement && !isValidZone(entry.placement)) {
42
+ throw new Error(`${prefix}: unknown placement "${entry.placement}"`);
43
+ }
44
+ if (entry.motion && !isValidMotion(entry.motion)) {
45
+ throw new Error(`${prefix}: unknown motion "${entry.motion}"`);
46
+ }
47
+
48
+ entries.push(entry as OverlayManifestEntry);
49
+ }
50
+
51
+ return entries;
52
+ }
53
+
54
+ export function hasImageAssets(entries: OverlayManifestEntry[]): boolean {
55
+ return entries.some((e) => e.type === 'image-card');
56
+ }
57
+
58
+ export function resolveAssetURLs(
59
+ entries: OverlayManifestEntry[],
60
+ assetBaseURL: string,
61
+ ): OverlayManifestEntry[] {
62
+ return entries.map((e) => {
63
+ if (e.type === 'image-card' && e.src && !e.src.startsWith('http')) {
64
+ return { ...e, src: `${assetBaseURL}/${e.src.replace(/^assets\//, '')}` };
65
+ }
66
+ return e;
67
+ });
68
+ }
@@ -0,0 +1,27 @@
1
+ import type { MotionPreset } from './types.js';
2
+
3
+ export function getMotionCSS(motion: MotionPreset, elementId: string): string {
4
+ const animName = `argo-${motion}-${elementId}`;
5
+ switch (motion) {
6
+ case 'fade-in':
7
+ return `@keyframes ${animName} { from { opacity: 0; } to { opacity: 1; } }`;
8
+ case 'slide-in':
9
+ return `@keyframes ${animName} { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }`;
10
+ case 'none':
11
+ default:
12
+ return '';
13
+ }
14
+ }
15
+
16
+ export function getMotionStyles(motion: MotionPreset, elementId: string): Record<string, string> {
17
+ const animName = `argo-${motion}-${elementId}`;
18
+ switch (motion) {
19
+ case 'fade-in':
20
+ return { animation: `${animName} 300ms ease-out forwards` };
21
+ case 'slide-in':
22
+ return { animation: `${animName} 400ms ease-out forwards` };
23
+ case 'none':
24
+ default:
25
+ return {};
26
+ }
27
+ }
@@ -0,0 +1,121 @@
1
+ import type { OverlayCue } from './types.js';
2
+
3
+ export interface TemplateResult {
4
+ contentHtml: string;
5
+ styles: Record<string, string>;
6
+ }
7
+
8
+ function escapeHtml(str: string): string {
9
+ return str
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;');
14
+ }
15
+
16
+ function lowerThird(text: string): TemplateResult {
17
+ return {
18
+ contentHtml: `<span>${escapeHtml(text)}</span>`,
19
+ styles: {
20
+ background: 'rgba(0, 0, 0, 0.85)',
21
+ color: '#fff',
22
+ padding: '16px 32px',
23
+ borderRadius: '12px',
24
+ fontSize: '28px',
25
+ fontWeight: '500',
26
+ textAlign: 'center',
27
+ maxWidth: '80vw',
28
+ letterSpacing: '0.01em',
29
+ lineHeight: '1.4',
30
+ boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
31
+ },
32
+ };
33
+ }
34
+
35
+ function headlineCard(title: string, kicker?: string, body?: string): TemplateResult {
36
+ const parts: string[] = [];
37
+ if (kicker) {
38
+ parts.push(
39
+ `<div style="font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:rgba(255,255,255,0.7);margin-bottom:8px">${escapeHtml(kicker)}</div>`,
40
+ );
41
+ }
42
+ parts.push(
43
+ `<div style="font-size:26px;font-weight:700;line-height:1.25;color:#fff">${escapeHtml(title)}</div>`,
44
+ );
45
+ if (body) {
46
+ parts.push(
47
+ `<div style="font-size:16px;line-height:1.5;color:rgba(255,255,255,0.85);margin-top:8px">${escapeHtml(body)}</div>`,
48
+ );
49
+ }
50
+ return {
51
+ contentHtml: parts.join(''),
52
+ styles: {
53
+ background: 'rgba(0, 0, 0, 0.7)',
54
+ backdropFilter: 'blur(16px)',
55
+ WebkitBackdropFilter: 'blur(16px)',
56
+ padding: '24px 28px',
57
+ borderRadius: '16px',
58
+ maxWidth: '420px',
59
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
60
+ },
61
+ };
62
+ }
63
+
64
+ function callout(text: string): TemplateResult {
65
+ return {
66
+ contentHtml: `<span>${escapeHtml(text)}</span>`,
67
+ styles: {
68
+ background: 'rgba(0, 0, 0, 0.8)',
69
+ color: '#fff',
70
+ padding: '10px 18px',
71
+ borderRadius: '20px',
72
+ fontSize: '16px',
73
+ fontWeight: '500',
74
+ lineHeight: '1.3',
75
+ maxWidth: '300px',
76
+ boxShadow: '0 2px 12px rgba(0, 0, 0, 0.3)',
77
+ },
78
+ };
79
+ }
80
+
81
+ function imageCard(src: string, title?: string, body?: string): TemplateResult {
82
+ const parts: string[] = [];
83
+ parts.push(
84
+ `<img src="${escapeHtml(src)}" style="max-width:100%;border-radius:8px;display:block" />`,
85
+ );
86
+ if (title) {
87
+ parts.push(
88
+ `<div style="font-size:18px;font-weight:600;color:#fff;margin-top:12px">${escapeHtml(title)}</div>`,
89
+ );
90
+ }
91
+ if (body) {
92
+ parts.push(
93
+ `<div style="font-size:14px;color:rgba(255,255,255,0.8);margin-top:4px;line-height:1.4">${escapeHtml(body)}</div>`,
94
+ );
95
+ }
96
+ return {
97
+ contentHtml: parts.join(''),
98
+ styles: {
99
+ background: 'rgba(0, 0, 0, 0.75)',
100
+ backdropFilter: 'blur(12px)',
101
+ WebkitBackdropFilter: 'blur(12px)',
102
+ padding: '16px',
103
+ borderRadius: '14px',
104
+ maxWidth: '360px',
105
+ boxShadow: '0 6px 24px rgba(0, 0, 0, 0.4)',
106
+ },
107
+ };
108
+ }
109
+
110
+ export function renderTemplate(cue: OverlayCue): TemplateResult {
111
+ switch (cue.type) {
112
+ case 'lower-third':
113
+ return lowerThird(cue.text);
114
+ case 'headline-card':
115
+ return headlineCard(cue.title, cue.kicker, cue.body);
116
+ case 'callout':
117
+ return callout(cue.text);
118
+ case 'image-card':
119
+ return imageCard(cue.src, cue.title, cue.body);
120
+ }
121
+ }
@@ -0,0 +1,73 @@
1
+ export const ZONES = [
2
+ 'bottom-center',
3
+ 'top-left',
4
+ 'top-right',
5
+ 'bottom-left',
6
+ 'bottom-right',
7
+ 'center',
8
+ ] as const;
9
+
10
+ export type Zone = (typeof ZONES)[number];
11
+
12
+ export const TEMPLATE_TYPES = [
13
+ 'lower-third',
14
+ 'headline-card',
15
+ 'callout',
16
+ 'image-card',
17
+ ] as const;
18
+
19
+ export type TemplateType = (typeof TEMPLATE_TYPES)[number];
20
+
21
+ export const MOTIONS = ['none', 'fade-in', 'slide-in'] as const;
22
+
23
+ export type MotionPreset = (typeof MOTIONS)[number];
24
+
25
+ export interface LowerThirdCue {
26
+ type: 'lower-third';
27
+ text: string;
28
+ placement?: Zone;
29
+ motion?: MotionPreset;
30
+ }
31
+
32
+ export interface HeadlineCardCue {
33
+ type: 'headline-card';
34
+ title: string;
35
+ kicker?: string;
36
+ body?: string;
37
+ placement?: Zone;
38
+ motion?: MotionPreset;
39
+ }
40
+
41
+ export interface CalloutCue {
42
+ type: 'callout';
43
+ text: string;
44
+ placement?: Zone;
45
+ motion?: MotionPreset;
46
+ }
47
+
48
+ export interface ImageCardCue {
49
+ type: 'image-card';
50
+ src: string;
51
+ title?: string;
52
+ body?: string;
53
+ placement?: Zone;
54
+ motion?: MotionPreset;
55
+ }
56
+
57
+ export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue;
58
+
59
+ export type OverlayManifestEntry = OverlayCue & {
60
+ scene: string;
61
+ };
62
+
63
+ export function isValidZone(value: string): value is Zone {
64
+ return (ZONES as readonly string[]).includes(value);
65
+ }
66
+
67
+ export function isValidTemplateType(value: string): value is TemplateType {
68
+ return (TEMPLATE_TYPES as readonly string[]).includes(value);
69
+ }
70
+
71
+ export function isValidMotion(value: string): value is MotionPreset {
72
+ return (MOTIONS as readonly string[]).includes(value);
73
+ }