@argo-video/cli 0.1.0 → 0.2.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/dist/asset-server.d.ts +7 -0
  4. package/dist/asset-server.d.ts.map +1 -0
  5. package/dist/asset-server.js +69 -0
  6. package/dist/asset-server.js.map +1 -0
  7. package/dist/captions.d.ts +17 -0
  8. package/dist/captions.d.ts.map +1 -0
  9. package/dist/captions.js +23 -0
  10. package/dist/captions.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +87 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config.d.ts +49 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +76 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/export.d.ts +19 -0
  20. package/dist/export.d.ts.map +1 -0
  21. package/dist/export.js +66 -0
  22. package/dist/export.js.map +1 -0
  23. package/dist/fixtures.d.ts +13 -0
  24. package/dist/fixtures.d.ts.map +1 -0
  25. package/dist/fixtures.js +49 -0
  26. package/dist/fixtures.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/init.d.ts +2 -0
  32. package/dist/init.d.ts.map +1 -0
  33. package/{src/init.ts → dist/init.js} +39 -54
  34. package/dist/init.js.map +1 -0
  35. package/dist/narration.d.ts +32 -0
  36. package/dist/narration.d.ts.map +1 -0
  37. package/dist/narration.js +86 -0
  38. package/dist/narration.js.map +1 -0
  39. package/dist/overlays/index.d.ts +13 -0
  40. package/dist/overlays/index.d.ts.map +1 -0
  41. package/dist/overlays/index.js +45 -0
  42. package/dist/overlays/index.js.map +1 -0
  43. package/dist/overlays/manifest.d.ts +5 -0
  44. package/dist/overlays/manifest.d.ts.map +1 -0
  45. package/dist/overlays/manifest.js +52 -0
  46. package/dist/overlays/manifest.js.map +1 -0
  47. package/dist/overlays/motion.d.ts +4 -0
  48. package/dist/overlays/motion.d.ts.map +1 -0
  49. package/dist/overlays/motion.js +25 -0
  50. package/dist/overlays/motion.js.map +1 -0
  51. package/dist/overlays/templates.d.ts +8 -0
  52. package/dist/overlays/templates.d.ts.map +1 -0
  53. package/dist/overlays/templates.js +102 -0
  54. package/dist/overlays/templates.js.map +1 -0
  55. package/dist/overlays/types.d.ts +46 -0
  56. package/dist/overlays/types.d.ts.map +1 -0
  57. package/dist/overlays/types.js +25 -0
  58. package/dist/overlays/types.js.map +1 -0
  59. package/dist/overlays/zones.d.ts +23 -0
  60. package/dist/overlays/zones.d.ts.map +1 -0
  61. package/dist/overlays/zones.js +117 -0
  62. package/dist/overlays/zones.js.map +1 -0
  63. package/dist/pipeline.d.ts +3 -0
  64. package/dist/pipeline.d.ts.map +1 -0
  65. package/dist/pipeline.js +109 -0
  66. package/dist/pipeline.js.map +1 -0
  67. package/dist/record.d.ts +15 -0
  68. package/dist/record.d.ts.map +1 -0
  69. package/dist/record.js +110 -0
  70. package/dist/record.js.map +1 -0
  71. package/dist/tts/align.d.ts +26 -0
  72. package/dist/tts/align.d.ts.map +1 -0
  73. package/dist/tts/align.js +53 -0
  74. package/dist/tts/align.js.map +1 -0
  75. package/dist/tts/cache.d.ts +31 -0
  76. package/dist/tts/cache.d.ts.map +1 -0
  77. package/dist/tts/cache.js +51 -0
  78. package/dist/tts/cache.js.map +1 -0
  79. package/dist/tts/engine.d.ts +41 -0
  80. package/dist/tts/engine.d.ts.map +1 -0
  81. package/dist/tts/engine.js +108 -0
  82. package/dist/tts/engine.js.map +1 -0
  83. package/dist/tts/generate.d.ts +21 -0
  84. package/dist/tts/generate.d.ts.map +1 -0
  85. package/dist/tts/generate.js +61 -0
  86. package/dist/tts/generate.js.map +1 -0
  87. package/dist/tts/kokoro.d.ts +30 -0
  88. package/dist/tts/kokoro.d.ts.map +1 -0
  89. package/dist/tts/kokoro.js +66 -0
  90. package/dist/tts/kokoro.js.map +1 -0
  91. package/package.json +13 -1
  92. package/.claude/settings.local.json +0 -34
  93. package/DESIGN.md +0 -261
  94. package/docs/enhancement-proposal.md +0 -262
  95. package/docs/superpowers/plans/2026-03-12-argo.md +0 -208
  96. package/docs/superpowers/plans/2026-03-12-editorial-overlay-system.md +0 -1560
  97. package/docs/superpowers/plans/2026-03-13-npm-rename-skill-showcase.md +0 -499
  98. package/docs/superpowers/specs/2026-03-13-npm-rename-skill-showcase-design.md +0 -109
  99. package/skills/argo-demo-creator.md +0 -355
  100. package/src/asset-server.ts +0 -81
  101. package/src/captions.ts +0 -36
  102. package/src/cli.ts +0 -97
  103. package/src/config.ts +0 -125
  104. package/src/export.ts +0 -93
  105. package/src/fixtures.ts +0 -50
  106. package/src/index.ts +0 -41
  107. package/src/narration.ts +0 -31
  108. package/src/overlays/index.ts +0 -54
  109. package/src/overlays/manifest.ts +0 -68
  110. package/src/overlays/motion.ts +0 -27
  111. package/src/overlays/templates.ts +0 -121
  112. package/src/overlays/types.ts +0 -73
  113. package/src/overlays/zones.ts +0 -82
  114. package/src/pipeline.ts +0 -120
  115. package/src/record.ts +0 -123
  116. package/src/tts/align.ts +0 -75
  117. package/src/tts/cache.ts +0 -65
  118. package/src/tts/engine.ts +0 -147
  119. package/src/tts/generate.ts +0 -83
  120. package/src/tts/kokoro.ts +0 -51
  121. package/tests/asset-server.test.ts +0 -67
  122. package/tests/captions.test.ts +0 -76
  123. package/tests/cli.test.ts +0 -131
  124. package/tests/config.test.ts +0 -150
  125. package/tests/e2e/fake-server.ts +0 -45
  126. package/tests/e2e/record.e2e.test.ts +0 -131
  127. package/tests/export.test.ts +0 -155
  128. package/tests/fixtures.test.ts +0 -74
  129. package/tests/init.test.ts +0 -77
  130. package/tests/narration.test.ts +0 -120
  131. package/tests/overlays/index.test.ts +0 -73
  132. package/tests/overlays/manifest.test.ts +0 -120
  133. package/tests/overlays/motion.test.ts +0 -34
  134. package/tests/overlays/templates.test.ts +0 -69
  135. package/tests/overlays/types.test.ts +0 -36
  136. package/tests/overlays/zones.test.ts +0 -49
  137. package/tests/pipeline.test.ts +0 -177
  138. package/tests/record.test.ts +0 -87
  139. package/tests/tts/align.test.ts +0 -118
  140. package/tests/tts/cache.test.ts +0 -110
  141. package/tests/tts/engine.test.ts +0 -204
  142. package/tests/tts/generate.test.ts +0 -177
  143. package/tests/tts/kokoro.test.ts +0 -25
  144. package/tsconfig.json +0 -19
@@ -1,1560 +0,0 @@
1
- # Editorial Overlay System Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Evolve Argo's single-style caption system into a zone-based overlay renderer with four templates (lower-third, headline-card, callout, image-card), a local asset server for images, and manifest-driven auto-injection.
6
-
7
- **Architecture:** Refactor `src/captions.ts` into `src/overlays/` module with zone-based DOM management (one overlay per zone, independent coexistence). Templates are pure functions that return style+HTML specs. Asset server reuses the `tests/e2e/fake-server.ts` pattern. Overlay manifest (`<demo>.overlays.json`) is loaded by the fixture and auto-injected at matching scene marks.
8
-
9
- **Tech Stack:** TypeScript ESM, Playwright (`page.evaluate` for DOM injection), Node.js `http` module (asset server), Vitest for testing.
10
-
11
- **Spec:** `docs/enhancement-proposal.md`
12
-
13
- **Security note:** Overlay templates use `container.innerHTML` to render structured content (kicker/title/body/img elements). All text fields are escaped via `escapeHtml()` before injection. This is safe because overlay content comes from author-controlled manifest files, not from end-user input. The `escapeHtml` function handles `&`, `<`, `>`, and `"` characters.
14
-
15
- ---
16
-
17
- ## File Structure
18
-
19
- ```
20
- src/overlays/
21
- types.ts # OverlayCue, Zone, TemplateType, MotionPreset types
22
- zones.ts # Zone management: inject/remove overlays by zone ID
23
- templates.ts # Template renderers: lower-third, headline-card, callout, image-card
24
- motion.ts # CSS animation injection: fade-in, slide-in
25
- manifest.ts # Load and validate <demo>.overlays.json
26
- index.ts # Public API: showOverlay, hideOverlay, withOverlay + re-exports
27
- src/asset-server.ts # Local HTTP file server for image assets
28
- src/captions.ts # Kept as thin backward-compat wrappers over overlays/
29
- src/index.ts # Updated exports
30
- src/fixtures.ts # Extended to load overlay manifest + auto-inject
31
- src/record.ts # Extended with asset server lifecycle
32
-
33
- tests/overlays/
34
- types.test.ts # Type guard tests
35
- zones.test.ts # Zone inject/remove/replace tests
36
- templates.test.ts # Template rendering tests
37
- motion.test.ts # Animation CSS generation tests
38
- manifest.test.ts # Manifest loading/validation tests
39
- index.test.ts # Public API integration tests
40
- tests/asset-server.test.ts # Asset server start/stop/serve tests
41
- tests/captions.test.ts # Updated: verify backward compat wrappers
42
- ```
43
-
44
- ---
45
-
46
- ## Chunk 1: Core Types and Zone Management
47
-
48
- ### Task 1: Define overlay types
49
-
50
- **Files:**
51
- - Create: `src/overlays/types.ts`
52
- - Test: `tests/overlays/types.test.ts`
53
-
54
- - [ ] **Step 1.1: Write failing tests for type guards**
55
-
56
- ```ts
57
- // tests/overlays/types.test.ts
58
- import { describe, it, expect } from 'vitest';
59
- import { isValidZone, isValidTemplateType, isValidMotion } from '../src/overlays/types.js';
60
-
61
- describe('isValidZone', () => {
62
- it('accepts all defined zones', () => {
63
- for (const z of ['bottom-center', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center']) {
64
- expect(isValidZone(z)).toBe(true);
65
- }
66
- });
67
- it('rejects unknown zones', () => {
68
- expect(isValidZone('middle')).toBe(false);
69
- expect(isValidZone('')).toBe(false);
70
- });
71
- });
72
-
73
- describe('isValidTemplateType', () => {
74
- it('accepts all defined types', () => {
75
- for (const t of ['lower-third', 'headline-card', 'callout', 'image-card']) {
76
- expect(isValidTemplateType(t)).toBe(true);
77
- }
78
- });
79
- it('rejects unknown types', () => {
80
- expect(isValidTemplateType('banner')).toBe(false);
81
- });
82
- });
83
-
84
- describe('isValidMotion', () => {
85
- it('accepts defined motions and none', () => {
86
- expect(isValidMotion('fade-in')).toBe(true);
87
- expect(isValidMotion('slide-in')).toBe(true);
88
- expect(isValidMotion('none')).toBe(true);
89
- });
90
- it('rejects unknown motions', () => {
91
- expect(isValidMotion('bounce')).toBe(false);
92
- });
93
- });
94
- ```
95
-
96
- - [ ] **Step 1.2: Run tests to verify they fail**
97
-
98
- Run: `npx vitest run tests/overlays/types.test.ts`
99
- Expected: FAIL with module not found
100
-
101
- - [ ] **Step 1.3: Implement types and guards**
102
-
103
- ```ts
104
- // src/overlays/types.ts
105
-
106
- export const ZONES = [
107
- 'bottom-center',
108
- 'top-left',
109
- 'top-right',
110
- 'bottom-left',
111
- 'bottom-right',
112
- 'center',
113
- ] as const;
114
-
115
- export type Zone = (typeof ZONES)[number];
116
-
117
- export const TEMPLATE_TYPES = [
118
- 'lower-third',
119
- 'headline-card',
120
- 'callout',
121
- 'image-card',
122
- ] as const;
123
-
124
- export type TemplateType = (typeof TEMPLATE_TYPES)[number];
125
-
126
- export const MOTIONS = ['none', 'fade-in', 'slide-in'] as const;
127
-
128
- export type MotionPreset = (typeof MOTIONS)[number];
129
-
130
- export interface LowerThirdCue {
131
- type: 'lower-third';
132
- text: string;
133
- placement?: Zone;
134
- motion?: MotionPreset;
135
- }
136
-
137
- export interface HeadlineCardCue {
138
- type: 'headline-card';
139
- title: string;
140
- kicker?: string;
141
- body?: string;
142
- placement?: Zone;
143
- motion?: MotionPreset;
144
- }
145
-
146
- export interface CalloutCue {
147
- type: 'callout';
148
- text: string;
149
- placement?: Zone;
150
- motion?: MotionPreset;
151
- }
152
-
153
- export interface ImageCardCue {
154
- type: 'image-card';
155
- src: string;
156
- title?: string;
157
- body?: string;
158
- placement?: Zone;
159
- motion?: MotionPreset;
160
- }
161
-
162
- export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue;
163
-
164
- export interface OverlayManifestEntry extends OverlayCue {
165
- scene: string;
166
- }
167
-
168
- export function isValidZone(value: string): value is Zone {
169
- return (ZONES as readonly string[]).includes(value);
170
- }
171
-
172
- export function isValidTemplateType(value: string): value is TemplateType {
173
- return (TEMPLATE_TYPES as readonly string[]).includes(value);
174
- }
175
-
176
- export function isValidMotion(value: string): value is MotionPreset {
177
- return (MOTIONS as readonly string[]).includes(value);
178
- }
179
- ```
180
-
181
- - [ ] **Step 1.4: Run tests to verify they pass**
182
-
183
- Run: `npx vitest run tests/overlays/types.test.ts`
184
- Expected: PASS (3 tests)
185
-
186
- - [ ] **Step 1.5: Commit**
187
-
188
- ```bash
189
- git add src/overlays/types.ts tests/overlays/types.test.ts
190
- git commit -m "feat(overlays): define overlay types, zones, and type guards"
191
- ```
192
-
193
- ---
194
-
195
- ### Task 2: Zone management (inject/remove by zone)
196
-
197
- **Files:**
198
- - Create: `src/overlays/zones.ts`
199
- - Test: `tests/overlays/zones.test.ts`
200
-
201
- - [ ] **Step 2.1: Write failing tests for zone DOM management**
202
-
203
- ```ts
204
- // tests/overlays/zones.test.ts
205
- import { describe, it, expect, vi, beforeEach } from 'vitest';
206
- import { injectIntoZone, removeZone, ZONE_ID_PREFIX } from '../src/overlays/zones.js';
207
- import type { Page } from '@playwright/test';
208
-
209
- function createMockPage() {
210
- return {
211
- evaluate: vi.fn(),
212
- waitForTimeout: vi.fn(),
213
- } as unknown as Page;
214
- }
215
-
216
- describe('ZONE_ID_PREFIX', () => {
217
- it('is argo-overlay-', () => {
218
- expect(ZONE_ID_PREFIX).toBe('argo-overlay-');
219
- });
220
- });
221
-
222
- describe('injectIntoZone', () => {
223
- let page: Page;
224
-
225
- beforeEach(() => {
226
- page = createMockPage();
227
- });
228
-
229
- it('calls page.evaluate with zone ID', async () => {
230
- await injectIntoZone(page, 'top-left', '<div>Hello</div>', { color: 'red' });
231
- expect(page.evaluate).toHaveBeenCalledTimes(1);
232
- const [fn, args] = (page.evaluate as any).mock.calls[0];
233
- expect(typeof fn).toBe('function');
234
- expect(args[0]).toBe('argo-overlay-top-left');
235
- });
236
-
237
- it('uses bottom-center zone ID correctly', async () => {
238
- await injectIntoZone(page, 'bottom-center', '<span>text</span>', {});
239
- const [, args] = (page.evaluate as any).mock.calls[0];
240
- expect(args[0]).toBe('argo-overlay-bottom-center');
241
- });
242
- });
243
-
244
- describe('removeZone', () => {
245
- it('calls page.evaluate to remove element by zone ID', async () => {
246
- const page = createMockPage();
247
- await removeZone(page, 'top-left');
248
- expect(page.evaluate).toHaveBeenCalledTimes(1);
249
- const [fn, arg] = (page.evaluate as any).mock.calls[0];
250
- expect(typeof fn).toBe('function');
251
- expect(arg).toBe('argo-overlay-top-left');
252
- });
253
- });
254
- ```
255
-
256
- - [ ] **Step 2.2: Run tests to verify they fail**
257
-
258
- Run: `npx vitest run tests/overlays/zones.test.ts`
259
- Expected: FAIL with module not found
260
-
261
- - [ ] **Step 2.3: Implement zone management**
262
-
263
- Zone injection uses `page.evaluate` with a function that creates a container div, sets its ID to `argo-overlay-{zone}`, applies position styles for that zone, and appends it to the document body. If an overlay already exists in that zone, it is removed first. Animation CSS keyframes are injected via a `<style>` element scoped to the zone.
264
-
265
- ```ts
266
- // src/overlays/zones.ts
267
- import type { Page } from '@playwright/test';
268
- import type { Zone } from './types.js';
269
-
270
- export const ZONE_ID_PREFIX = 'argo-overlay-';
271
-
272
- const ZONE_POSITIONS: Record<Zone, Record<string, string>> = {
273
- 'bottom-center': {
274
- position: 'fixed', bottom: '60px', left: '50%', transform: 'translateX(-50%)',
275
- },
276
- 'top-left': {
277
- position: 'fixed', top: '40px', left: '40px',
278
- },
279
- 'top-right': {
280
- position: 'fixed', top: '40px', right: '40px',
281
- },
282
- 'bottom-left': {
283
- position: 'fixed', bottom: '60px', left: '40px',
284
- },
285
- 'bottom-right': {
286
- position: 'fixed', bottom: '60px', right: '40px',
287
- },
288
- 'center': {
289
- position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
290
- },
291
- };
292
-
293
- /**
294
- * Inject content into a zone. Replaces any existing overlay in that zone.
295
- * Uses page.evaluate to manipulate the DOM inside the browser context.
296
- *
297
- * Security: contentHtml is generated by template renderers which escape all
298
- * text fields via escapeHtml(). Content originates from author-controlled
299
- * manifest files, not end-user input.
300
- */
301
- export async function injectIntoZone(
302
- page: Page,
303
- zone: Zone,
304
- contentHtml: string,
305
- containerStyles: Record<string, string>,
306
- animationCSS?: string,
307
- ): Promise<void> {
308
- const zoneId = ZONE_ID_PREFIX + zone;
309
- const positionStyles = ZONE_POSITIONS[zone];
310
- const baseStyles: Record<string, string> = {
311
- ...positionStyles,
312
- zIndex: '999999',
313
- pointerEvents: 'none',
314
- fontFamily: 'system-ui, -apple-system, sans-serif',
315
- ...containerStyles,
316
- };
317
-
318
- await page.evaluate(([id, html, styles, css]) => {
319
- const existing = document.getElementById(id);
320
- if (existing) existing.remove();
321
-
322
- if (css) {
323
- const styleId = id + '-style';
324
- let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
325
- if (!styleEl) {
326
- styleEl = document.createElement('style');
327
- styleEl.id = styleId;
328
- document.head.appendChild(styleEl);
329
- }
330
- styleEl.textContent = css;
331
- }
332
-
333
- const container = document.createElement('div');
334
- container.id = id;
335
- // Content is pre-escaped by template renderers
336
- container.innerHTML = html;
337
- Object.assign(container.style, styles);
338
- document.body.appendChild(container);
339
- }, [zoneId, contentHtml, baseStyles, animationCSS ?? ''] as const);
340
- }
341
-
342
- /**
343
- * Remove the overlay in the specified zone, if any.
344
- */
345
- export async function removeZone(page: Page, zone: Zone): Promise<void> {
346
- const zoneId = ZONE_ID_PREFIX + zone;
347
- await page.evaluate((id) => {
348
- document.getElementById(id)?.remove();
349
- document.getElementById(id + '-style')?.remove();
350
- }, zoneId);
351
- }
352
- ```
353
-
354
- - [ ] **Step 2.4: Run tests to verify they pass**
355
-
356
- Run: `npx vitest run tests/overlays/zones.test.ts`
357
- Expected: PASS (4 tests)
358
-
359
- - [ ] **Step 2.5: Commit**
360
-
361
- ```bash
362
- git add src/overlays/zones.ts tests/overlays/zones.test.ts
363
- git commit -m "feat(overlays): zone-based DOM management with position mapping"
364
- ```
365
-
366
- ---
367
-
368
- ### Task 3: Motion presets
369
-
370
- **Files:**
371
- - Create: `src/overlays/motion.ts`
372
- - Test: `tests/overlays/motion.test.ts`
373
-
374
- - [ ] **Step 3.1: Write failing tests for motion CSS generation**
375
-
376
- ```ts
377
- // tests/overlays/motion.test.ts
378
- import { describe, it, expect } from 'vitest';
379
- import { getMotionCSS, getMotionStyles } from '../src/overlays/motion.js';
380
-
381
- describe('getMotionCSS', () => {
382
- it('returns empty string for none', () => {
383
- expect(getMotionCSS('none', 'argo-overlay-top-left')).toBe('');
384
- });
385
-
386
- it('returns fade-in keyframes', () => {
387
- const css = getMotionCSS('fade-in', 'argo-overlay-top-left');
388
- expect(css).toContain('@keyframes');
389
- expect(css).toContain('opacity');
390
- expect(css).toContain('argo-overlay-top-left');
391
- });
392
-
393
- it('returns slide-in keyframes', () => {
394
- const css = getMotionCSS('slide-in', 'argo-overlay-top-left');
395
- expect(css).toContain('@keyframes');
396
- expect(css).toContain('translateX');
397
- expect(css).toContain('argo-overlay-top-left');
398
- });
399
- });
400
-
401
- describe('getMotionStyles', () => {
402
- it('returns empty object for none', () => {
403
- expect(getMotionStyles('none', 'test-id')).toEqual({});
404
- });
405
-
406
- it('returns animation property for fade-in', () => {
407
- const styles = getMotionStyles('fade-in', 'test-id');
408
- expect(styles.animation).toContain('300ms');
409
- });
410
-
411
- it('returns animation property for slide-in', () => {
412
- const styles = getMotionStyles('slide-in', 'test-id');
413
- expect(styles.animation).toContain('400ms');
414
- });
415
- });
416
- ```
417
-
418
- - [ ] **Step 3.2: Run tests to verify they fail**
419
-
420
- Run: `npx vitest run tests/overlays/motion.test.ts`
421
- Expected: FAIL with module not found
422
-
423
- - [ ] **Step 3.3: Implement motion presets**
424
-
425
- ```ts
426
- // src/overlays/motion.ts
427
- import type { MotionPreset } from './types.js';
428
-
429
- /**
430
- * Generate CSS keyframes for a motion preset, scoped to a specific element ID.
431
- */
432
- export function getMotionCSS(motion: MotionPreset, elementId: string): string {
433
- const animName = `argo-${motion}-${elementId}`;
434
-
435
- switch (motion) {
436
- case 'fade-in':
437
- return `@keyframes ${animName} { from { opacity: 0; } to { opacity: 1; } }`;
438
- case 'slide-in':
439
- return `@keyframes ${animName} { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }`;
440
- case 'none':
441
- default:
442
- return '';
443
- }
444
- }
445
-
446
- /**
447
- * Return inline styles that reference the generated animation.
448
- */
449
- export function getMotionStyles(motion: MotionPreset, elementId: string): Record<string, string> {
450
- const animName = `argo-${motion}-${elementId}`;
451
-
452
- switch (motion) {
453
- case 'fade-in':
454
- return { animation: `${animName} 300ms ease-out forwards` };
455
- case 'slide-in':
456
- return { animation: `${animName} 400ms ease-out forwards` };
457
- case 'none':
458
- default:
459
- return {};
460
- }
461
- }
462
- ```
463
-
464
- - [ ] **Step 3.4: Run tests to verify they pass**
465
-
466
- Run: `npx vitest run tests/overlays/motion.test.ts`
467
- Expected: PASS (6 tests)
468
-
469
- - [ ] **Step 3.5: Commit**
470
-
471
- ```bash
472
- git add src/overlays/motion.ts tests/overlays/motion.test.ts
473
- git commit -m "feat(overlays): fade-in and slide-in motion presets"
474
- ```
475
-
476
- ---
477
-
478
- ## Chunk 2: Templates and Public API
479
-
480
- ### Task 4: Template renderers
481
-
482
- **Files:**
483
- - Create: `src/overlays/templates.ts`
484
- - Test: `tests/overlays/templates.test.ts`
485
-
486
- - [ ] **Step 4.1: Write failing tests for template renderers**
487
-
488
- Each renderer returns `{ contentHtml: string, styles: Record<string, string> }`. Tests verify the returned HTML contains expected elements and styles contain expected properties.
489
-
490
- ```ts
491
- // tests/overlays/templates.test.ts
492
- import { describe, it, expect } from 'vitest';
493
- import { renderTemplate } from '../src/overlays/templates.js';
494
-
495
- describe('renderTemplate', () => {
496
- describe('lower-third', () => {
497
- it('renders text in a styled span', () => {
498
- const result = renderTemplate({ type: 'lower-third', text: 'Hello world' });
499
- expect(result.contentHtml).toContain('Hello world');
500
- expect(result.styles.background).toBeDefined();
501
- expect(result.styles.borderRadius).toBeDefined();
502
- });
503
-
504
- it('includes maxWidth for readability', () => {
505
- const result = renderTemplate({ type: 'lower-third', text: 'Test' });
506
- expect(result.styles.maxWidth).toBeDefined();
507
- });
508
-
509
- it('escapes HTML in text', () => {
510
- const result = renderTemplate({ type: 'lower-third', text: '<script>alert("xss")</script>' });
511
- expect(result.contentHtml).not.toContain('<script>');
512
- expect(result.contentHtml).toContain('&lt;script&gt;');
513
- });
514
- });
515
-
516
- describe('headline-card', () => {
517
- it('renders title', () => {
518
- const result = renderTemplate({ type: 'headline-card', title: 'Big Title' });
519
- expect(result.contentHtml).toContain('Big Title');
520
- });
521
-
522
- it('renders kicker when provided', () => {
523
- const result = renderTemplate({
524
- type: 'headline-card', title: 'Title', kicker: 'LABEL',
525
- });
526
- expect(result.contentHtml).toContain('LABEL');
527
- });
528
-
529
- it('renders body when provided', () => {
530
- const result = renderTemplate({
531
- type: 'headline-card', title: 'Title', body: 'Details here',
532
- });
533
- expect(result.contentHtml).toContain('Details here');
534
- });
535
-
536
- it('omits kicker element when not provided', () => {
537
- const result = renderTemplate({ type: 'headline-card', title: 'Title' });
538
- expect(result.contentHtml).not.toContain('uppercase');
539
- });
540
-
541
- it('has backdrop blur style', () => {
542
- const result = renderTemplate({ type: 'headline-card', title: 'T' });
543
- expect(result.styles.backdropFilter).toContain('blur');
544
- });
545
- });
546
-
547
- describe('callout', () => {
548
- it('renders text in a compact bubble', () => {
549
- const result = renderTemplate({ type: 'callout', text: 'Note this' });
550
- expect(result.contentHtml).toContain('Note this');
551
- expect(result.styles.borderRadius).toBeDefined();
552
- });
553
- });
554
-
555
- describe('image-card', () => {
556
- it('renders img tag with src', () => {
557
- const result = renderTemplate({ type: 'image-card', src: 'http://localhost:9999/diagram.png' });
558
- expect(result.contentHtml).toContain('<img');
559
- expect(result.contentHtml).toContain('http://localhost:9999/diagram.png');
560
- });
561
-
562
- it('renders title when provided', () => {
563
- const result = renderTemplate({
564
- type: 'image-card', src: 'http://x/img.png', title: 'Architecture',
565
- });
566
- expect(result.contentHtml).toContain('Architecture');
567
- });
568
-
569
- it('renders body when provided', () => {
570
- const result = renderTemplate({
571
- type: 'image-card', src: 'http://x/img.png', body: 'Description',
572
- });
573
- expect(result.contentHtml).toContain('Description');
574
- });
575
- });
576
- });
577
- ```
578
-
579
- - [ ] **Step 4.2: Run tests to verify they fail**
580
-
581
- Run: `npx vitest run tests/overlays/templates.test.ts`
582
- Expected: FAIL with module not found
583
-
584
- - [ ] **Step 4.3: Implement template renderers**
585
-
586
- Templates are pure functions: they take cue fields and return `{ contentHtml, styles }`. All user-provided text is escaped via `escapeHtml()` before being interpolated into HTML strings. This is safe because overlay content comes from author-controlled manifest files, not end-user input.
587
-
588
- ```ts
589
- // src/overlays/templates.ts
590
- import type { OverlayCue } from './types.js';
591
-
592
- export interface TemplateResult {
593
- contentHtml: string;
594
- styles: Record<string, string>;
595
- }
596
-
597
- function escapeHtml(str: string): string {
598
- return str
599
- .replace(/&/g, '&amp;')
600
- .replace(/</g, '&lt;')
601
- .replace(/>/g, '&gt;')
602
- .replace(/"/g, '&quot;');
603
- }
604
-
605
- function lowerThird(text: string): TemplateResult {
606
- return {
607
- contentHtml: `<span>${escapeHtml(text)}</span>`,
608
- styles: {
609
- background: 'rgba(0, 0, 0, 0.85)',
610
- color: '#fff',
611
- padding: '16px 32px',
612
- borderRadius: '12px',
613
- fontSize: '28px',
614
- fontWeight: '500',
615
- textAlign: 'center',
616
- maxWidth: '80vw',
617
- letterSpacing: '0.01em',
618
- lineHeight: '1.4',
619
- boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
620
- },
621
- };
622
- }
623
-
624
- function headlineCard(title: string, kicker?: string, body?: string): TemplateResult {
625
- const parts: string[] = [];
626
- if (kicker) {
627
- parts.push(
628
- `<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>`,
629
- );
630
- }
631
- parts.push(
632
- `<div style="font-size:26px;font-weight:700;line-height:1.25;color:#fff">${escapeHtml(title)}</div>`,
633
- );
634
- if (body) {
635
- parts.push(
636
- `<div style="font-size:16px;line-height:1.5;color:rgba(255,255,255,0.85);margin-top:8px">${escapeHtml(body)}</div>`,
637
- );
638
- }
639
- return {
640
- contentHtml: parts.join(''),
641
- styles: {
642
- background: 'rgba(0, 0, 0, 0.7)',
643
- backdropFilter: 'blur(16px)',
644
- WebkitBackdropFilter: 'blur(16px)',
645
- padding: '24px 28px',
646
- borderRadius: '16px',
647
- maxWidth: '420px',
648
- boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
649
- },
650
- };
651
- }
652
-
653
- function callout(text: string): TemplateResult {
654
- return {
655
- contentHtml: `<span>${escapeHtml(text)}</span>`,
656
- styles: {
657
- background: 'rgba(0, 0, 0, 0.8)',
658
- color: '#fff',
659
- padding: '10px 18px',
660
- borderRadius: '20px',
661
- fontSize: '16px',
662
- fontWeight: '500',
663
- lineHeight: '1.3',
664
- maxWidth: '300px',
665
- boxShadow: '0 2px 12px rgba(0, 0, 0, 0.3)',
666
- },
667
- };
668
- }
669
-
670
- function imageCard(src: string, title?: string, body?: string): TemplateResult {
671
- const parts: string[] = [];
672
- parts.push(
673
- `<img src="${escapeHtml(src)}" style="max-width:100%;border-radius:8px;display:block" />`,
674
- );
675
- if (title) {
676
- parts.push(
677
- `<div style="font-size:18px;font-weight:600;color:#fff;margin-top:12px">${escapeHtml(title)}</div>`,
678
- );
679
- }
680
- if (body) {
681
- parts.push(
682
- `<div style="font-size:14px;color:rgba(255,255,255,0.8);margin-top:4px;line-height:1.4">${escapeHtml(body)}</div>`,
683
- );
684
- }
685
- return {
686
- contentHtml: parts.join(''),
687
- styles: {
688
- background: 'rgba(0, 0, 0, 0.75)',
689
- backdropFilter: 'blur(12px)',
690
- WebkitBackdropFilter: 'blur(12px)',
691
- padding: '16px',
692
- borderRadius: '14px',
693
- maxWidth: '360px',
694
- boxShadow: '0 6px 24px rgba(0, 0, 0, 0.4)',
695
- },
696
- };
697
- }
698
-
699
- export function renderTemplate(cue: OverlayCue): TemplateResult {
700
- switch (cue.type) {
701
- case 'lower-third':
702
- return lowerThird(cue.text);
703
- case 'headline-card':
704
- return headlineCard(cue.title, cue.kicker, cue.body);
705
- case 'callout':
706
- return callout(cue.text);
707
- case 'image-card':
708
- return imageCard(cue.src, cue.title, cue.body);
709
- }
710
- }
711
- ```
712
-
713
- - [ ] **Step 4.4: Run tests to verify they pass**
714
-
715
- Run: `npx vitest run tests/overlays/templates.test.ts`
716
- Expected: PASS (11 tests)
717
-
718
- - [ ] **Step 4.5: Commit**
719
-
720
- ```bash
721
- git add src/overlays/templates.ts tests/overlays/templates.test.ts
722
- git commit -m "feat(overlays): template renderers for lower-third, headline-card, callout, image-card"
723
- ```
724
-
725
- ---
726
-
727
- ### Task 5: Public overlay API (showOverlay, hideOverlay, withOverlay)
728
-
729
- **Files:**
730
- - Create: `src/overlays/index.ts`
731
- - Test: `tests/overlays/index.test.ts`
732
-
733
- - [ ] **Step 5.1: Write failing tests for public API**
734
-
735
- ```ts
736
- // tests/overlays/index.test.ts
737
- import { describe, it, expect, vi, beforeEach } from 'vitest';
738
- import { showOverlay, hideOverlay, withOverlay } from '../src/overlays/index.js';
739
- import type { Page } from '@playwright/test';
740
-
741
- function createMockPage() {
742
- return {
743
- evaluate: vi.fn(),
744
- waitForTimeout: vi.fn(),
745
- } as unknown as Page;
746
- }
747
-
748
- describe('showOverlay', () => {
749
- let page: Page;
750
- beforeEach(() => { page = createMockPage(); });
751
-
752
- it('injects overlay and removes after duration', async () => {
753
- await showOverlay(page, 'intro', { type: 'lower-third', text: 'Hello' }, 2000);
754
- // inject call + remove call = 2 evaluate calls
755
- expect(page.evaluate).toHaveBeenCalledTimes(2);
756
- expect(page.waitForTimeout).toHaveBeenCalledWith(2000);
757
- });
758
-
759
- it('defaults to bottom-center zone', async () => {
760
- await showOverlay(page, 'intro', { type: 'lower-third', text: 'Hi' }, 1000);
761
- const [, args] = (page.evaluate as any).mock.calls[0];
762
- expect(args[0]).toContain('bottom-center');
763
- });
764
-
765
- it('uses specified zone', async () => {
766
- await showOverlay(page, 'intro', {
767
- type: 'headline-card', title: 'Title', placement: 'top-left',
768
- }, 1000);
769
- const [, args] = (page.evaluate as any).mock.calls[0];
770
- expect(args[0]).toContain('top-left');
771
- });
772
- });
773
-
774
- describe('hideOverlay', () => {
775
- it('removes overlay from specified zone', async () => {
776
- const page = createMockPage();
777
- await hideOverlay(page, 'top-left');
778
- expect(page.evaluate).toHaveBeenCalledTimes(1);
779
- });
780
-
781
- it('defaults to bottom-center', async () => {
782
- const page = createMockPage();
783
- await hideOverlay(page);
784
- const [, arg] = (page.evaluate as any).mock.calls[0];
785
- expect(arg).toContain('bottom-center');
786
- });
787
- });
788
-
789
- describe('withOverlay', () => {
790
- let page: Page;
791
- beforeEach(() => { page = createMockPage(); });
792
-
793
- it('shows overlay during action and removes after', async () => {
794
- let actionRan = false;
795
- await withOverlay(page, 'demo', { type: 'callout', text: 'Watch' }, async () => {
796
- actionRan = true;
797
- });
798
- expect(actionRan).toBe(true);
799
- // inject + remove = 2 evaluate calls
800
- expect(page.evaluate).toHaveBeenCalledTimes(2);
801
- });
802
-
803
- it('removes overlay even if action throws', async () => {
804
- await expect(
805
- withOverlay(page, 'demo', { type: 'lower-third', text: 'Hi' }, async () => {
806
- throw new Error('boom');
807
- }),
808
- ).rejects.toThrow('boom');
809
- // inject + remove = 2 evaluate calls
810
- expect(page.evaluate).toHaveBeenCalledTimes(2);
811
- });
812
- });
813
- ```
814
-
815
- - [ ] **Step 5.2: Run tests to verify they fail**
816
-
817
- Run: `npx vitest run tests/overlays/index.test.ts`
818
- Expected: FAIL with module not found
819
-
820
- - [ ] **Step 5.3: Implement public overlay API**
821
-
822
- ```ts
823
- // src/overlays/index.ts
824
- import type { Page } from '@playwright/test';
825
- import type { OverlayCue, Zone } from './types.js';
826
- import { injectIntoZone, removeZone, ZONE_ID_PREFIX } from './zones.js';
827
- import { renderTemplate } from './templates.js';
828
- import { getMotionCSS, getMotionStyles } from './motion.js';
829
-
830
- export type { OverlayCue, OverlayManifestEntry, Zone, TemplateType, MotionPreset } from './types.js';
831
- export { renderTemplate } from './templates.js';
832
-
833
- /**
834
- * Show an overlay for a given duration, then remove it.
835
- */
836
- export async function showOverlay(
837
- page: Page,
838
- _scene: string,
839
- cue: OverlayCue,
840
- durationMs: number,
841
- ): Promise<void> {
842
- const zone: Zone = cue.placement ?? 'bottom-center';
843
- const motion = cue.motion ?? 'none';
844
- const { contentHtml, styles } = renderTemplate(cue);
845
- const zoneId = ZONE_ID_PREFIX + zone;
846
- const motionCSS = getMotionCSS(motion, zoneId);
847
- const motionStyles = getMotionStyles(motion, zoneId);
848
-
849
- await injectIntoZone(page, zone, contentHtml, { ...styles, ...motionStyles }, motionCSS);
850
- await page.waitForTimeout(durationMs);
851
- await removeZone(page, zone);
852
- }
853
-
854
- /**
855
- * Remove the overlay in the specified zone.
856
- */
857
- export async function hideOverlay(
858
- page: Page,
859
- zone: Zone = 'bottom-center',
860
- ): Promise<void> {
861
- await removeZone(page, zone);
862
- }
863
-
864
- /**
865
- * Show an overlay while running an action, then remove it (even on error).
866
- */
867
- export async function withOverlay(
868
- page: Page,
869
- _scene: string,
870
- cue: OverlayCue,
871
- action: () => Promise<void>,
872
- ): Promise<void> {
873
- const zone: Zone = cue.placement ?? 'bottom-center';
874
- const motion = cue.motion ?? 'none';
875
- const { contentHtml, styles } = renderTemplate(cue);
876
- const zoneId = ZONE_ID_PREFIX + zone;
877
- const motionCSS = getMotionCSS(motion, zoneId);
878
- const motionStyles = getMotionStyles(motion, zoneId);
879
-
880
- await injectIntoZone(page, zone, contentHtml, { ...styles, ...motionStyles }, motionCSS);
881
- try {
882
- await action();
883
- } finally {
884
- await removeZone(page, zone);
885
- }
886
- }
887
- ```
888
-
889
- - [ ] **Step 5.4: Run tests to verify they pass**
890
-
891
- Run: `npx vitest run tests/overlays/index.test.ts`
892
- Expected: PASS (7 tests)
893
-
894
- - [ ] **Step 5.5: Commit**
895
-
896
- ```bash
897
- git add src/overlays/index.ts tests/overlays/index.test.ts
898
- git commit -m "feat(overlays): public API — showOverlay, hideOverlay, withOverlay"
899
- ```
900
-
901
- ---
902
-
903
- ### Task 6: Update captions.ts to thin wrappers + update exports
904
-
905
- **Files:**
906
- - Modify: `src/captions.ts`
907
- - Modify: `src/index.ts`
908
- - Modify: `tests/captions.test.ts` (if needed)
909
-
910
- - [ ] **Step 6.1: Run existing captions tests to establish baseline**
911
-
912
- Run: `npx vitest run tests/captions.test.ts`
913
- Expected: PASS (all current tests)
914
-
915
- - [ ] **Step 6.2: Rewrite captions.ts as wrappers over overlay API**
916
-
917
- Replace the entire contents of `src/captions.ts` with thin wrappers that delegate to the overlay system. The old `CAPTION_STYLES`, `OVERLAY_ID`, and `injectOverlay` are no longer needed — they've been superseded by the zone/template system.
918
-
919
- ```ts
920
- // src/captions.ts
921
- import type { Page } from '@playwright/test';
922
- import { showOverlay, hideOverlay, withOverlay } from './overlays/index.js';
923
-
924
- /**
925
- * Show a lower-third caption for `durationMs`, then remove it.
926
- * @deprecated Use showOverlay() for new code.
927
- */
928
- export async function showCaption(
929
- page: Page,
930
- scene: string,
931
- text: string,
932
- durationMs: number,
933
- ): Promise<void> {
934
- await showOverlay(page, scene, { type: 'lower-third', text }, durationMs);
935
- }
936
-
937
- /**
938
- * Remove the caption overlay.
939
- * @deprecated Use hideOverlay() for new code.
940
- */
941
- export async function hideCaption(page: Page): Promise<void> {
942
- await hideOverlay(page, 'bottom-center');
943
- }
944
-
945
- /**
946
- * Show a caption while running `action`, then hide it (even on error).
947
- * @deprecated Use withOverlay() for new code.
948
- */
949
- export async function withCaption(
950
- page: Page,
951
- scene: string,
952
- text: string,
953
- action: () => Promise<void>,
954
- ): Promise<void> {
955
- await withOverlay(page, scene, { type: 'lower-third', text }, action);
956
- }
957
- ```
958
-
959
- - [ ] **Step 6.3: Update src/index.ts exports**
960
-
961
- Add new overlay exports alongside existing caption exports. Keep the caption exports for backward compatibility.
962
-
963
- Add after the `// Captions` section:
964
-
965
- ```ts
966
- // Overlays
967
- export {
968
- showOverlay,
969
- hideOverlay,
970
- withOverlay,
971
- type OverlayCue,
972
- type OverlayManifestEntry,
973
- type Zone,
974
- type TemplateType,
975
- type MotionPreset,
976
- } from './overlays/index.js';
977
- ```
978
-
979
- - [ ] **Step 6.4: Run captions tests to verify backward compatibility**
980
-
981
- Run: `npx vitest run tests/captions.test.ts`
982
- Expected: PASS (all existing tests still pass)
983
-
984
- - [ ] **Step 6.5: Run full test suite**
985
-
986
- Run: `npx vitest run`
987
- Expected: ALL PASS
988
-
989
- - [ ] **Step 6.6: Commit**
990
-
991
- ```bash
992
- git add src/captions.ts src/index.ts
993
- git commit -m "refactor(captions): rewrite as thin wrappers over overlay API"
994
- ```
995
-
996
- ---
997
-
998
- ## Chunk 3: Asset Server and Manifest
999
-
1000
- ### Task 7: Asset server
1001
-
1002
- **Files:**
1003
- - Create: `src/asset-server.ts`
1004
- - Test: `tests/asset-server.test.ts`
1005
-
1006
- - [ ] **Step 7.1: Write failing tests for asset server**
1007
-
1008
- ```ts
1009
- // tests/asset-server.test.ts
1010
- import { describe, it, expect, afterEach } from 'vitest';
1011
- import { startAssetServer } from '../src/asset-server.js';
1012
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
1013
- import { join } from 'node:path';
1014
- import { tmpdir } from 'node:os';
1015
-
1016
- describe('startAssetServer', () => {
1017
- let tmpDir: string;
1018
- let close: (() => Promise<void>) | undefined;
1019
-
1020
- function setup() {
1021
- tmpDir = mkdtempSync(join(tmpdir(), 'argo-asset-'));
1022
- mkdirSync(join(tmpDir, 'assets'), { recursive: true });
1023
- }
1024
-
1025
- afterEach(async () => {
1026
- if (close) await close();
1027
- if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
1028
- });
1029
-
1030
- it('serves files from the asset directory', async () => {
1031
- setup();
1032
- writeFileSync(join(tmpDir, 'assets', 'test.txt'), 'hello');
1033
- const server = await startAssetServer(join(tmpDir, 'assets'));
1034
- close = server.close;
1035
-
1036
- const res = await fetch(`${server.url}/test.txt`);
1037
- expect(res.status).toBe(200);
1038
- expect(await res.text()).toBe('hello');
1039
- });
1040
-
1041
- it('returns 404 for missing files', async () => {
1042
- setup();
1043
- const server = await startAssetServer(join(tmpDir, 'assets'));
1044
- close = server.close;
1045
-
1046
- const res = await fetch(`${server.url}/nope.png`);
1047
- expect(res.status).toBe(404);
1048
- });
1049
-
1050
- it('prevents path traversal', async () => {
1051
- setup();
1052
- writeFileSync(join(tmpDir, 'secret.txt'), 'private');
1053
- const server = await startAssetServer(join(tmpDir, 'assets'));
1054
- close = server.close;
1055
-
1056
- const res = await fetch(`${server.url}/../secret.txt`);
1057
- expect(res.status).toBe(403);
1058
- });
1059
-
1060
- it('assigns a random available port', async () => {
1061
- setup();
1062
- const server = await startAssetServer(join(tmpDir, 'assets'));
1063
- close = server.close;
1064
- expect(server.port).toBeGreaterThan(0);
1065
- });
1066
-
1067
- it('serves PNG with correct content type', async () => {
1068
- setup();
1069
- writeFileSync(join(tmpDir, 'assets', 'img.png'), Buffer.from([0x89, 0x50]));
1070
- const server = await startAssetServer(join(tmpDir, 'assets'));
1071
- close = server.close;
1072
-
1073
- const res = await fetch(`${server.url}/img.png`);
1074
- expect(res.headers.get('content-type')).toBe('image/png');
1075
- });
1076
- });
1077
- ```
1078
-
1079
- - [ ] **Step 7.2: Run tests to verify they fail**
1080
-
1081
- Run: `npx vitest run tests/asset-server.test.ts`
1082
- Expected: FAIL with module not found
1083
-
1084
- - [ ] **Step 7.3: Implement asset server**
1085
-
1086
- The asset server serves static files from a directory over HTTP. It validates paths to prevent directory traversal and maps file extensions to MIME types. It binds to port 0 so the OS assigns a free port, avoiding conflicts.
1087
-
1088
- ```ts
1089
- // src/asset-server.ts
1090
- import http from 'node:http';
1091
- import { createReadStream, existsSync } from 'node:fs';
1092
- import { resolve, relative, extname } from 'node:path';
1093
- import type { AddressInfo } from 'node:net';
1094
-
1095
- export interface AssetServer {
1096
- url: string;
1097
- port: number;
1098
- close: () => Promise<void>;
1099
- }
1100
-
1101
- const MIME_TYPES: Record<string, string> = {
1102
- '.png': 'image/png',
1103
- '.jpg': 'image/jpeg',
1104
- '.jpeg': 'image/jpeg',
1105
- '.gif': 'image/gif',
1106
- '.svg': 'image/svg+xml',
1107
- '.webp': 'image/webp',
1108
- '.txt': 'text/plain',
1109
- '.json': 'application/json',
1110
- };
1111
-
1112
- export function startAssetServer(assetDir: string): Promise<AssetServer> {
1113
- const resolvedDir = resolve(assetDir);
1114
-
1115
- return new Promise((resolvePromise) => {
1116
- const server = http.createServer((req, res) => {
1117
- const urlPath = decodeURIComponent(req.url ?? '/');
1118
- const filePath = resolve(resolvedDir, '.' + urlPath);
1119
-
1120
- // Path traversal check
1121
- const rel = relative(resolvedDir, filePath);
1122
- if (rel.startsWith('..') || resolve(filePath) !== filePath) {
1123
- res.writeHead(403);
1124
- res.end('Forbidden');
1125
- return;
1126
- }
1127
-
1128
- if (!existsSync(filePath)) {
1129
- res.writeHead(404);
1130
- res.end('Not found');
1131
- return;
1132
- }
1133
-
1134
- const ext = extname(filePath).toLowerCase();
1135
- const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
1136
- res.writeHead(200, { 'Content-Type': contentType });
1137
- createReadStream(filePath).pipe(res);
1138
- });
1139
-
1140
- server.listen(0, '127.0.0.1', () => {
1141
- const { port } = server.address() as AddressInfo;
1142
- resolvePromise({
1143
- url: `http://127.0.0.1:${port}`,
1144
- port,
1145
- close: () => new Promise((res) => server.close(() => res())),
1146
- });
1147
- });
1148
- });
1149
- }
1150
- ```
1151
-
1152
- - [ ] **Step 7.4: Run tests to verify they pass**
1153
-
1154
- Run: `npx vitest run tests/asset-server.test.ts`
1155
- Expected: PASS (5 tests)
1156
-
1157
- - [ ] **Step 7.5: Commit**
1158
-
1159
- ```bash
1160
- git add src/asset-server.ts tests/asset-server.test.ts
1161
- git commit -m "feat: local asset server for serving images during recording"
1162
- ```
1163
-
1164
- ---
1165
-
1166
- ### Task 8: Overlay manifest loader
1167
-
1168
- **Files:**
1169
- - Create: `src/overlays/manifest.ts`
1170
- - Test: `tests/overlays/manifest.test.ts`
1171
-
1172
- - [ ] **Step 8.1: Write failing tests for manifest loading**
1173
-
1174
- ```ts
1175
- // tests/overlays/manifest.test.ts
1176
- import { describe, it, expect, afterEach } from 'vitest';
1177
- import { loadOverlayManifest, hasImageAssets, resolveAssetURLs } from '../src/overlays/manifest.js';
1178
- import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
1179
- import { join } from 'node:path';
1180
- import { tmpdir } from 'node:os';
1181
-
1182
- describe('loadOverlayManifest', () => {
1183
- let tmpDir: string;
1184
-
1185
- function setup() {
1186
- tmpDir = mkdtempSync(join(tmpdir(), 'argo-manifest-'));
1187
- }
1188
-
1189
- afterEach(() => {
1190
- if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
1191
- });
1192
-
1193
- it('returns null when file does not exist', async () => {
1194
- setup();
1195
- const result = await loadOverlayManifest(join(tmpDir, 'missing.overlays.json'));
1196
- expect(result).toBeNull();
1197
- });
1198
-
1199
- it('parses a valid manifest', async () => {
1200
- setup();
1201
- const manifestPath = join(tmpDir, 'demo.overlays.json');
1202
- writeFileSync(manifestPath, JSON.stringify([
1203
- { scene: 'intro', type: 'lower-third', text: 'Hello' },
1204
- { scene: 'mid', type: 'headline-card', title: 'Title', placement: 'top-left' },
1205
- ]));
1206
- const result = await loadOverlayManifest(manifestPath);
1207
- expect(result).toHaveLength(2);
1208
- expect(result![0].scene).toBe('intro');
1209
- expect(result![1].type).toBe('headline-card');
1210
- });
1211
-
1212
- it('throws on invalid JSON', async () => {
1213
- setup();
1214
- const manifestPath = join(tmpDir, 'bad.overlays.json');
1215
- writeFileSync(manifestPath, '{ nope }}}');
1216
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('Failed to parse overlay manifest');
1217
- });
1218
-
1219
- it('throws when manifest is not an array', async () => {
1220
- setup();
1221
- const manifestPath = join(tmpDir, 'obj.overlays.json');
1222
- writeFileSync(manifestPath, JSON.stringify({ scene: 'x' }));
1223
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('must contain a JSON array');
1224
- });
1225
-
1226
- it('throws on entry missing scene', async () => {
1227
- setup();
1228
- const manifestPath = join(tmpDir, 'no-scene.overlays.json');
1229
- writeFileSync(manifestPath, JSON.stringify([{ type: 'lower-third', text: 'Hi' }]));
1230
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('missing required field "scene"');
1231
- });
1232
-
1233
- it('throws on entry missing type', async () => {
1234
- setup();
1235
- const manifestPath = join(tmpDir, 'no-type.overlays.json');
1236
- writeFileSync(manifestPath, JSON.stringify([{ scene: 'x', text: 'Hi' }]));
1237
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('missing required field "type"');
1238
- });
1239
-
1240
- it('throws on unknown template type', async () => {
1241
- setup();
1242
- const manifestPath = join(tmpDir, 'bad-type.overlays.json');
1243
- writeFileSync(manifestPath, JSON.stringify([{ scene: 'x', type: 'banner', text: 'Hi' }]));
1244
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('unknown overlay type "banner"');
1245
- });
1246
-
1247
- it('throws on unknown zone', async () => {
1248
- setup();
1249
- const manifestPath = join(tmpDir, 'bad-zone.overlays.json');
1250
- writeFileSync(manifestPath, JSON.stringify([
1251
- { scene: 'x', type: 'lower-third', text: 'Hi', placement: 'middle' },
1252
- ]));
1253
- await expect(loadOverlayManifest(manifestPath)).rejects.toThrow('unknown placement "middle"');
1254
- });
1255
- });
1256
-
1257
- describe('hasImageAssets', () => {
1258
- it('returns true when entries contain image-card', () => {
1259
- expect(hasImageAssets([
1260
- { scene: 'x', type: 'image-card', src: 'img.png' },
1261
- ])).toBe(true);
1262
- });
1263
-
1264
- it('returns false when no image-card entries', () => {
1265
- expect(hasImageAssets([
1266
- { scene: 'x', type: 'lower-third', text: 'Hi' },
1267
- ])).toBe(false);
1268
- });
1269
- });
1270
-
1271
- describe('resolveAssetURLs', () => {
1272
- it('prefixes relative image-card src with asset server URL', () => {
1273
- const entries = [
1274
- { scene: 'x', type: 'image-card' as const, src: 'assets/diagram.png' },
1275
- ];
1276
- const resolved = resolveAssetURLs(entries, 'http://127.0.0.1:9999');
1277
- expect(resolved[0]).toHaveProperty('src', 'http://127.0.0.1:9999/diagram.png');
1278
- });
1279
-
1280
- it('leaves absolute URLs unchanged', () => {
1281
- const entries = [
1282
- { scene: 'x', type: 'image-card' as const, src: 'http://example.com/img.png' },
1283
- ];
1284
- const resolved = resolveAssetURLs(entries, 'http://127.0.0.1:9999');
1285
- expect(resolved[0]).toHaveProperty('src', 'http://example.com/img.png');
1286
- });
1287
-
1288
- it('leaves non-image-card entries unchanged', () => {
1289
- const entries = [
1290
- { scene: 'x', type: 'lower-third' as const, text: 'Hello' },
1291
- ];
1292
- const resolved = resolveAssetURLs(entries, 'http://127.0.0.1:9999');
1293
- expect(resolved[0]).toEqual(entries[0]);
1294
- });
1295
- });
1296
- ```
1297
-
1298
- - [ ] **Step 8.2: Run tests to verify they fail**
1299
-
1300
- Run: `npx vitest run tests/overlays/manifest.test.ts`
1301
- Expected: FAIL with module not found
1302
-
1303
- - [ ] **Step 8.3: Implement manifest loader**
1304
-
1305
- ```ts
1306
- // src/overlays/manifest.ts
1307
- import { readFile } from 'node:fs/promises';
1308
- import { existsSync } from 'node:fs';
1309
- import type { OverlayManifestEntry } from './types.js';
1310
- import { isValidTemplateType, isValidZone, isValidMotion } from './types.js';
1311
-
1312
- /**
1313
- * Load and validate an overlay manifest file.
1314
- * Returns null if the file does not exist (overlays are optional).
1315
- */
1316
- export async function loadOverlayManifest(
1317
- manifestPath: string,
1318
- ): Promise<OverlayManifestEntry[] | null> {
1319
- if (!existsSync(manifestPath)) return null;
1320
-
1321
- const raw = await readFile(manifestPath, 'utf-8');
1322
-
1323
- let parsed: unknown;
1324
- try {
1325
- parsed = JSON.parse(raw);
1326
- } catch (err) {
1327
- throw new Error(
1328
- `Failed to parse overlay manifest ${manifestPath}: ${(err as Error).message}`,
1329
- );
1330
- }
1331
-
1332
- if (!Array.isArray(parsed)) {
1333
- throw new Error(`Overlay manifest ${manifestPath} must contain a JSON array`);
1334
- }
1335
-
1336
- const entries: OverlayManifestEntry[] = [];
1337
-
1338
- for (let i = 0; i < parsed.length; i++) {
1339
- const entry = parsed[i];
1340
- const prefix = `Overlay manifest entry ${i}`;
1341
-
1342
- if (!entry.scene || typeof entry.scene !== 'string') {
1343
- throw new Error(`${prefix}: missing required field "scene"`);
1344
- }
1345
- if (!entry.type || typeof entry.type !== 'string') {
1346
- throw new Error(`${prefix}: missing required field "type"`);
1347
- }
1348
- if (!isValidTemplateType(entry.type)) {
1349
- throw new Error(`${prefix}: unknown overlay type "${entry.type}"`);
1350
- }
1351
- if (entry.placement && !isValidZone(entry.placement)) {
1352
- throw new Error(`${prefix}: unknown placement "${entry.placement}"`);
1353
- }
1354
- if (entry.motion && !isValidMotion(entry.motion)) {
1355
- throw new Error(`${prefix}: unknown motion "${entry.motion}"`);
1356
- }
1357
-
1358
- entries.push(entry as OverlayManifestEntry);
1359
- }
1360
-
1361
- return entries;
1362
- }
1363
-
1364
- /**
1365
- * Check if any entries reference image assets (for deciding whether to start asset server).
1366
- */
1367
- export function hasImageAssets(entries: OverlayManifestEntry[]): boolean {
1368
- return entries.some((e) => e.type === 'image-card');
1369
- }
1370
-
1371
- /**
1372
- * Resolve image-card src fields to asset server URLs.
1373
- * Entries with relative src paths get prefixed with the asset server URL.
1374
- */
1375
- export function resolveAssetURLs(
1376
- entries: OverlayManifestEntry[],
1377
- assetBaseURL: string,
1378
- ): OverlayManifestEntry[] {
1379
- return entries.map((e) => {
1380
- if (e.type === 'image-card' && e.src && !e.src.startsWith('http')) {
1381
- return { ...e, src: `${assetBaseURL}/${e.src.replace(/^assets\//, '')}` };
1382
- }
1383
- return e;
1384
- });
1385
- }
1386
- ```
1387
-
1388
- - [ ] **Step 8.4: Run tests to verify they pass**
1389
-
1390
- Run: `npx vitest run tests/overlays/manifest.test.ts`
1391
- Expected: PASS (12 tests)
1392
-
1393
- - [ ] **Step 8.5: Commit**
1394
-
1395
- ```bash
1396
- git add src/overlays/manifest.ts tests/overlays/manifest.test.ts
1397
- git commit -m "feat(overlays): manifest loader with validation and asset URL resolution"
1398
- ```
1399
-
1400
- ---
1401
-
1402
- ## Chunk 4: Integration (Record + Init)
1403
-
1404
- ### Task 9: Wire asset server into record.ts
1405
-
1406
- **Files:**
1407
- - Modify: `src/record.ts`
1408
-
1409
- The asset server needs to start before the Playwright subprocess if the overlay manifest contains image assets, and stop after recording completes.
1410
-
1411
- - [ ] **Step 9.1: Read current record.ts**
1412
-
1413
- Read `src/record.ts` to identify exact insertion points.
1414
-
1415
- - [ ] **Step 9.2: Add asset server lifecycle to record.ts**
1416
-
1417
- Add imports at the top of `src/record.ts`:
1418
-
1419
- ```ts
1420
- import { startAssetServer } from './asset-server.js';
1421
- import { loadOverlayManifest, hasImageAssets } from './overlays/manifest.js';
1422
- ```
1423
-
1424
- Inside the `record()` function, before the `execFile` call:
1425
-
1426
- ```ts
1427
- // Start asset server if overlay manifest has image assets
1428
- let assetServer: { url: string; close: () => Promise<void> } | undefined;
1429
- const overlayManifestPath = join(options.demosDir, `${demoName}.overlays.json`);
1430
- const overlayEntries = await loadOverlayManifest(overlayManifestPath);
1431
- if (overlayEntries && hasImageAssets(overlayEntries)) {
1432
- const assetDir = join(options.demosDir, 'assets');
1433
- assetServer = await startAssetServer(assetDir);
1434
- }
1435
- ```
1436
-
1437
- Add `ARGO_ASSET_URL: assetServer?.url ?? ''` to the `env` object passed to `execFile`.
1438
-
1439
- In the callback, add cleanup in both the resolve and reject paths:
1440
-
1441
- ```ts
1442
- if (assetServer) await assetServer.close();
1443
- ```
1444
-
1445
- Wrap the entire execFile callback body in a try/finally to ensure the asset server is always stopped:
1446
-
1447
- ```ts
1448
- // In the resolve path, before resolve():
1449
- if (assetServer) await assetServer.close();
1450
-
1451
- // In the reject path, before reject():
1452
- if (assetServer) await assetServer.close();
1453
- ```
1454
-
1455
- - [ ] **Step 9.3: Run existing tests to ensure nothing breaks**
1456
-
1457
- Run: `npx vitest run`
1458
- Expected: ALL PASS
1459
-
1460
- - [ ] **Step 9.4: Commit**
1461
-
1462
- ```bash
1463
- git add src/record.ts
1464
- git commit -m "feat: wire asset server lifecycle into record command"
1465
- ```
1466
-
1467
- ---
1468
-
1469
- ### Task 10: Update init templates
1470
-
1471
- **Files:**
1472
- - Modify: `src/init.ts`
1473
- - Modify: `tests/init.test.ts`
1474
-
1475
- - [ ] **Step 10.1: Add example overlay manifest to init scaffolding**
1476
-
1477
- Add a new constant `EXAMPLE_OVERLAYS` in `src/init.ts`:
1478
-
1479
- ```ts
1480
- const EXAMPLE_OVERLAYS = JSON.stringify(
1481
- [
1482
- {
1483
- scene: 'welcome',
1484
- type: 'lower-third',
1485
- text: 'Welcome to our app',
1486
- },
1487
- {
1488
- scene: 'action',
1489
- type: 'headline-card',
1490
- placement: 'top-left',
1491
- title: 'One-click setup',
1492
- body: 'Just press the button to get started.',
1493
- motion: 'slide-in',
1494
- },
1495
- ],
1496
- null,
1497
- 2,
1498
- ) + '\n';
1499
- ```
1500
-
1501
- Add this line in the `init()` function after the voiceover manifest write:
1502
-
1503
- ```ts
1504
- await writeIfMissing(join(demosDir, 'example.overlays.json'), EXAMPLE_OVERLAYS);
1505
- ```
1506
-
1507
- - [ ] **Step 10.2: Update init test to expect the new file**
1508
-
1509
- In `tests/init.test.ts`, add an assertion that `example.overlays.json` is created in the demos directory.
1510
-
1511
- - [ ] **Step 10.3: Run init tests**
1512
-
1513
- Run: `npx vitest run tests/init.test.ts`
1514
- Expected: PASS
1515
-
1516
- - [ ] **Step 10.4: Commit**
1517
-
1518
- ```bash
1519
- git add src/init.ts tests/init.test.ts
1520
- git commit -m "feat(init): scaffold example overlay manifest"
1521
- ```
1522
-
1523
- ---
1524
-
1525
- ### Task 11: Full integration verification
1526
-
1527
- - [ ] **Step 11.1: Run full test suite**
1528
-
1529
- Run: `npx vitest run`
1530
- Expected: ALL PASS
1531
-
1532
- - [ ] **Step 11.2: Type check the project**
1533
-
1534
- Run: `npx tsc --noEmit`
1535
- Expected: No type errors
1536
-
1537
- - [ ] **Step 11.3: Commit any remaining fixes**
1538
-
1539
- ```bash
1540
- git add -A
1541
- git commit -m "chore: integration fixes for editorial overlay system"
1542
- ```
1543
-
1544
- ---
1545
-
1546
- ## Summary
1547
-
1548
- | Task | What | Files | Est. Tests |
1549
- |------|------|-------|------------|
1550
- | 1 | Overlay types and guards | `src/overlays/types.ts` | 3 |
1551
- | 2 | Zone DOM management | `src/overlays/zones.ts` | 4 |
1552
- | 3 | Motion presets | `src/overlays/motion.ts` | 6 |
1553
- | 4 | Template renderers | `src/overlays/templates.ts` | 11 |
1554
- | 5 | Public API | `src/overlays/index.ts` | 7 |
1555
- | 6 | Captions backward compat | `src/captions.ts`, `src/index.ts` | existing |
1556
- | 7 | Asset server | `src/asset-server.ts` | 5 |
1557
- | 8 | Manifest loader | `src/overlays/manifest.ts` | 12 |
1558
- | 9 | Record + asset server | `src/record.ts` | existing |
1559
- | 10 | Init templates | `src/init.ts` | existing |
1560
- | 11 | Integration verification | all | full suite |