@argo-video/cli 0.1.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/asset-server.d.ts +7 -0
- package/dist/asset-server.d.ts.map +1 -0
- package/dist/asset-server.js +66 -0
- package/dist/asset-server.js.map +1 -0
- package/dist/captions.d.ts +17 -0
- package/dist/captions.d.ts.map +1 -0
- package/dist/captions.js +23 -0
- package/dist/captions.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +87 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +74 -0
- package/dist/config.js.map +1 -0
- package/dist/export.d.ts +18 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +64 -0
- package/dist/export.js.map +1 -0
- package/dist/fixtures.d.ts +13 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +36 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.d.ts.map +1 -0
- package/{src/init.ts → dist/init.js} +39 -54
- package/dist/init.js.map +1 -0
- package/dist/narration.d.ts +9 -0
- package/dist/narration.d.ts.map +1 -0
- package/dist/narration.js +27 -0
- package/dist/narration.js.map +1 -0
- package/dist/overlays/index.d.ts +8 -0
- package/dist/overlays/index.d.ts.map +1 -0
- package/dist/overlays/index.js +34 -0
- package/dist/overlays/index.js.map +1 -0
- package/dist/overlays/manifest.d.ts +5 -0
- package/dist/overlays/manifest.d.ts.map +1 -0
- package/dist/overlays/manifest.js +52 -0
- package/dist/overlays/manifest.js.map +1 -0
- package/dist/overlays/motion.d.ts +4 -0
- package/dist/overlays/motion.d.ts.map +1 -0
- package/dist/overlays/motion.js +25 -0
- package/dist/overlays/motion.js.map +1 -0
- package/dist/overlays/templates.d.ts +7 -0
- package/dist/overlays/templates.d.ts.map +1 -0
- package/dist/overlays/templates.js +98 -0
- package/dist/overlays/templates.js.map +1 -0
- package/dist/overlays/types.d.ts +42 -0
- package/dist/overlays/types.d.ts.map +1 -0
- package/dist/overlays/types.js +25 -0
- package/dist/overlays/types.js.map +1 -0
- package/dist/overlays/zones.d.ts +15 -0
- package/dist/overlays/zones.d.ts.map +1 -0
- package/dist/overlays/zones.js +69 -0
- package/dist/overlays/zones.js.map +1 -0
- package/dist/pipeline.d.ts +3 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +93 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/record.d.ts +14 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +100 -0
- package/dist/record.js.map +1 -0
- package/dist/tts/align.d.ts +17 -0
- package/dist/tts/align.d.ts.map +1 -0
- package/dist/tts/align.js +40 -0
- package/dist/tts/align.js.map +1 -0
- package/dist/tts/cache.d.ts +31 -0
- package/dist/tts/cache.d.ts.map +1 -0
- package/dist/tts/cache.js +51 -0
- package/dist/tts/cache.js.map +1 -0
- package/dist/tts/engine.d.ts +41 -0
- package/dist/tts/engine.d.ts.map +1 -0
- package/dist/tts/engine.js +108 -0
- package/dist/tts/engine.js.map +1 -0
- package/dist/tts/generate.d.ts +20 -0
- package/dist/tts/generate.d.ts.map +1 -0
- package/dist/tts/generate.js +58 -0
- package/dist/tts/generate.js.map +1 -0
- package/dist/tts/kokoro.d.ts +13 -0
- package/dist/tts/kokoro.d.ts.map +1 -0
- package/dist/tts/kokoro.js +46 -0
- package/dist/tts/kokoro.js.map +1 -0
- package/package.json +13 -1
- package/.claude/settings.local.json +0 -34
- package/DESIGN.md +0 -261
- package/docs/enhancement-proposal.md +0 -262
- package/docs/superpowers/plans/2026-03-12-argo.md +0 -208
- package/docs/superpowers/plans/2026-03-12-editorial-overlay-system.md +0 -1560
- package/docs/superpowers/plans/2026-03-13-npm-rename-skill-showcase.md +0 -499
- package/docs/superpowers/specs/2026-03-13-npm-rename-skill-showcase-design.md +0 -109
- package/skills/argo-demo-creator.md +0 -355
- package/src/asset-server.ts +0 -81
- package/src/captions.ts +0 -36
- package/src/cli.ts +0 -97
- package/src/config.ts +0 -125
- package/src/export.ts +0 -93
- package/src/fixtures.ts +0 -50
- package/src/index.ts +0 -41
- package/src/narration.ts +0 -31
- package/src/overlays/index.ts +0 -54
- package/src/overlays/manifest.ts +0 -68
- package/src/overlays/motion.ts +0 -27
- package/src/overlays/templates.ts +0 -121
- package/src/overlays/types.ts +0 -73
- package/src/overlays/zones.ts +0 -82
- package/src/pipeline.ts +0 -120
- package/src/record.ts +0 -123
- package/src/tts/align.ts +0 -75
- package/src/tts/cache.ts +0 -65
- package/src/tts/engine.ts +0 -147
- package/src/tts/generate.ts +0 -83
- package/src/tts/kokoro.ts +0 -51
- package/tests/asset-server.test.ts +0 -67
- package/tests/captions.test.ts +0 -76
- package/tests/cli.test.ts +0 -131
- package/tests/config.test.ts +0 -150
- package/tests/e2e/fake-server.ts +0 -45
- package/tests/e2e/record.e2e.test.ts +0 -131
- package/tests/export.test.ts +0 -155
- package/tests/fixtures.test.ts +0 -74
- package/tests/init.test.ts +0 -77
- package/tests/narration.test.ts +0 -120
- package/tests/overlays/index.test.ts +0 -73
- package/tests/overlays/manifest.test.ts +0 -120
- package/tests/overlays/motion.test.ts +0 -34
- package/tests/overlays/templates.test.ts +0 -69
- package/tests/overlays/types.test.ts +0 -36
- package/tests/overlays/zones.test.ts +0 -49
- package/tests/pipeline.test.ts +0 -177
- package/tests/record.test.ts +0 -87
- package/tests/tts/align.test.ts +0 -118
- package/tests/tts/cache.test.ts +0 -110
- package/tests/tts/engine.test.ts +0 -204
- package/tests/tts/generate.test.ts +0 -177
- package/tests/tts/kokoro.test.ts +0 -25
- 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('<script>');
|
|
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, '&')
|
|
600
|
-
.replace(/</g, '<')
|
|
601
|
-
.replace(/>/g, '>')
|
|
602
|
-
.replace(/"/g, '"');
|
|
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 |
|