@argo-video/cli 0.9.1 → 0.10.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 (68) hide show
  1. package/README.md +34 -33
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +25 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/init.d.ts.map +1 -1
  6. package/dist/init.js +29 -47
  7. package/dist/init.js.map +1 -1
  8. package/dist/overlays/index.d.ts +7 -2
  9. package/dist/overlays/index.d.ts.map +1 -1
  10. package/dist/overlays/index.js +49 -4
  11. package/dist/overlays/index.js.map +1 -1
  12. package/dist/overlays/manifest-loader.d.ts +4 -0
  13. package/dist/overlays/manifest-loader.d.ts.map +1 -0
  14. package/dist/overlays/manifest-loader.js +22 -0
  15. package/dist/overlays/manifest-loader.js.map +1 -0
  16. package/dist/overlays/manifest.d.ts.map +1 -1
  17. package/dist/overlays/manifest.js +4 -19
  18. package/dist/overlays/manifest.js.map +1 -1
  19. package/dist/overlays/types.d.ts +10 -0
  20. package/dist/overlays/types.d.ts.map +1 -1
  21. package/dist/overlays/types.js.map +1 -1
  22. package/dist/parse-playwright.d.ts +15 -0
  23. package/dist/parse-playwright.d.ts.map +1 -1
  24. package/dist/parse-playwright.js +17 -0
  25. package/dist/parse-playwright.js.map +1 -1
  26. package/dist/pipeline.d.ts.map +1 -1
  27. package/dist/pipeline.js +36 -3
  28. package/dist/pipeline.js.map +1 -1
  29. package/dist/preview.d.ts +29 -0
  30. package/dist/preview.d.ts.map +1 -0
  31. package/dist/preview.js +2137 -0
  32. package/dist/preview.js.map +1 -0
  33. package/dist/record.d.ts.map +1 -1
  34. package/dist/record.js +12 -4
  35. package/dist/record.js.map +1 -1
  36. package/dist/tts/engine.d.ts +8 -0
  37. package/dist/tts/engine.d.ts.map +1 -1
  38. package/dist/tts/engine.js.map +1 -1
  39. package/dist/tts/engines/elevenlabs.d.ts +2 -1
  40. package/dist/tts/engines/elevenlabs.d.ts.map +1 -1
  41. package/dist/tts/engines/elevenlabs.js +3 -0
  42. package/dist/tts/engines/elevenlabs.js.map +1 -1
  43. package/dist/tts/engines/gemini.d.ts +2 -1
  44. package/dist/tts/engines/gemini.d.ts.map +1 -1
  45. package/dist/tts/engines/gemini.js +3 -0
  46. package/dist/tts/engines/gemini.js.map +1 -1
  47. package/dist/tts/engines/kokoro.d.ts +2 -1
  48. package/dist/tts/engines/kokoro.d.ts.map +1 -1
  49. package/dist/tts/engines/kokoro.js +3 -0
  50. package/dist/tts/engines/kokoro.js.map +1 -1
  51. package/dist/tts/engines/mlx-audio.d.ts +2 -1
  52. package/dist/tts/engines/mlx-audio.d.ts.map +1 -1
  53. package/dist/tts/engines/mlx-audio.js +6 -0
  54. package/dist/tts/engines/mlx-audio.js.map +1 -1
  55. package/dist/tts/engines/openai.d.ts +7 -2
  56. package/dist/tts/engines/openai.d.ts.map +1 -1
  57. package/dist/tts/engines/openai.js +13 -2
  58. package/dist/tts/engines/openai.js.map +1 -1
  59. package/dist/tts/engines/sarvam.d.ts +2 -1
  60. package/dist/tts/engines/sarvam.d.ts.map +1 -1
  61. package/dist/tts/engines/sarvam.js +3 -0
  62. package/dist/tts/engines/sarvam.js.map +1 -1
  63. package/dist/validate.d.ts.map +1 -1
  64. package/dist/validate.js +36 -54
  65. package/dist/validate.js.map +1 -1
  66. package/package.json +1 -1
  67. package/scripts/setup-mlx-audio.sh +1 -1
  68. package/scripts/voice-clone-preview.sh +1 -1
@@ -0,0 +1,2137 @@
1
+ /**
2
+ * argo preview — browser-based replay viewer for iterating on voiceover,
3
+ * overlays, and timing without re-recording.
4
+ *
5
+ * Serves a local web page that plays a seekable preview video (preferring MP4
6
+ * over the raw Playwright WebM), overlays audio clips at scene timestamps,
7
+ * renders overlay cues on a DOM layer, and lets the user edit voiceover text
8
+ * + overlay props inline with per-scene TTS regen.
9
+ */
10
+ import { execFile } from 'node:child_process';
11
+ import { createServer } from 'node:http';
12
+ import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream } from 'node:fs';
13
+ import { dirname, extname, join, relative, resolve } from 'node:path';
14
+ import { renderTemplate } from './overlays/templates.js';
15
+ import { alignClips, schedulePlacements } from './tts/align.js';
16
+ import { ClipCache } from './tts/cache.js';
17
+ import { createWavBuffer, parseWavHeader } from './tts/engine.js';
18
+ const MIME_TYPES = {
19
+ '.html': 'text/html',
20
+ '.js': 'text/javascript',
21
+ '.css': 'text/css',
22
+ '.json': 'application/json',
23
+ '.webm': 'video/webm',
24
+ '.wav': 'audio/wav',
25
+ '.mp4': 'video/mp4',
26
+ '.zip': 'application/zip',
27
+ };
28
+ function readJsonFile(filePath, fallback) {
29
+ if (!existsSync(filePath))
30
+ return fallback;
31
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
32
+ }
33
+ function setManifestField(target, key, value) {
34
+ if (value === undefined || value === null || value === '') {
35
+ if (key in target) {
36
+ delete target[key];
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ if (target[key] === value)
42
+ return false;
43
+ target[key] = value;
44
+ return true;
45
+ }
46
+ function updatePreviewVoiceoverEntry(target, entry) {
47
+ let changed = false;
48
+ changed = setManifestField(target, 'text', entry.text) || changed;
49
+ changed = setManifestField(target, 'voice', entry.voice) || changed;
50
+ changed = setManifestField(target, 'speed', entry.speed) || changed;
51
+ changed = setManifestField(target, 'lang', entry.lang) || changed;
52
+ changed = setManifestField(target, '_hint', entry._hint) || changed;
53
+ return changed;
54
+ }
55
+ function updatePreviewOverlayEntry(target, overlay) {
56
+ if (!overlay) {
57
+ if ('overlay' in target) {
58
+ delete target.overlay;
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ const overlayTarget = (target.overlay && typeof target.overlay === 'object')
64
+ ? target.overlay
65
+ : (target.overlay = {});
66
+ let changed = false;
67
+ changed = setManifestField(overlayTarget, 'type', overlay.type) || changed;
68
+ changed = setManifestField(overlayTarget, 'motion', overlay.motion) || changed;
69
+ changed = setManifestField(overlayTarget, 'autoBackground', overlay.autoBackground) || changed;
70
+ if (overlay.placement === 'bottom-center') {
71
+ if (overlayTarget.placement !== undefined && overlayTarget.placement !== 'bottom-center') {
72
+ delete overlayTarget.placement;
73
+ changed = true;
74
+ }
75
+ }
76
+ else {
77
+ changed = setManifestField(overlayTarget, 'placement', overlay.placement) || changed;
78
+ }
79
+ if (overlay.type === 'lower-third' || overlay.type === 'callout') {
80
+ changed = setManifestField(overlayTarget, 'text', overlay.text) || changed;
81
+ changed = setManifestField(overlayTarget, 'title', undefined) || changed;
82
+ changed = setManifestField(overlayTarget, 'body', undefined) || changed;
83
+ changed = setManifestField(overlayTarget, 'kicker', undefined) || changed;
84
+ changed = setManifestField(overlayTarget, 'src', undefined) || changed;
85
+ }
86
+ else {
87
+ changed = setManifestField(overlayTarget, 'text', undefined) || changed;
88
+ changed = setManifestField(overlayTarget, 'title', 'title' in overlay ? overlay.title : undefined) || changed;
89
+ changed = setManifestField(overlayTarget, 'body', 'body' in overlay ? overlay.body : undefined) || changed;
90
+ changed = setManifestField(overlayTarget, 'kicker', 'kicker' in overlay ? overlay.kicker : undefined) || changed;
91
+ changed = setManifestField(overlayTarget, 'src', 'src' in overlay ? overlay.src : undefined) || changed;
92
+ }
93
+ return changed;
94
+ }
95
+ function buildRenderedOverlays(overlays) {
96
+ const renderedOverlays = {};
97
+ for (const entry of overlays) {
98
+ const { scene, ...cue } = entry;
99
+ const zone = cue.placement ?? 'bottom-center';
100
+ const { contentHtml, styles } = renderTemplate(cue, 'dark');
101
+ renderedOverlays[scene] = { html: contentHtml, styles, zone };
102
+ }
103
+ return renderedOverlays;
104
+ }
105
+ function buildPreviewSceneReport(timing, sceneDurations, persisted) {
106
+ const scheduled = Object.entries(timing)
107
+ .filter(([scene]) => sceneDurations[scene] && sceneDurations[scene] > 0)
108
+ .map(([scene, startMs]) => ({ scene, startMs, durationMs: sceneDurations[scene] }));
109
+ if (scheduled.length === 0)
110
+ return null;
111
+ const placements = schedulePlacements(scheduled);
112
+ return createSceneReportFromPlacements(placements, persisted);
113
+ }
114
+ function createSceneReportFromPlacements(placements, persisted) {
115
+ const lastEndMs = placements.length > 0 ? placements[placements.length - 1].endMs : 0;
116
+ const baseDurationMs = persisted?.totalDurationMs ?? lastEndMs;
117
+ return {
118
+ totalDurationMs: Math.max(baseDurationMs, lastEndMs),
119
+ overflowMs: Math.max(persisted?.overflowMs ?? 0, lastEndMs - baseDurationMs),
120
+ scenes: placements.map((placement) => ({
121
+ scene: placement.scene,
122
+ startMs: placement.startMs,
123
+ endMs: placement.endMs,
124
+ durationMs: placement.endMs - placement.startMs,
125
+ })),
126
+ };
127
+ }
128
+ function loadPreviewData(demoName, argoDir, demosDir) {
129
+ const demoDir = join(argoDir, demoName);
130
+ // Required files
131
+ const timingPath = join(demoDir, '.timing.json');
132
+ if (!existsSync(timingPath)) {
133
+ throw new Error(`No timing data found at ${timingPath}. Run 'argo pipeline ${demoName}' first.`);
134
+ }
135
+ const timing = readJsonFile(timingPath, {});
136
+ // Unified scenes manifest
137
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
138
+ const scenes = readJsonFile(scenesPath, []);
139
+ // Derive voiceover and overlay arrays from unified entries
140
+ const voiceover = scenes.map((s) => ({
141
+ scene: s.scene,
142
+ text: s.text,
143
+ voice: s.voice,
144
+ speed: s.speed,
145
+ lang: s.lang,
146
+ _hint: s._hint,
147
+ }));
148
+ const overlays = scenes
149
+ .filter((s) => s.overlay)
150
+ .map((s) => ({ scene: s.scene, ...s.overlay }));
151
+ // Scene durations
152
+ const sdPath = join(demoDir, '.scene-durations.json');
153
+ const sceneDurations = readJsonFile(sdPath, {});
154
+ // Use persisted report metadata, but derive scene placements from the current
155
+ // timing + scene durations so preview stays in sync after per-scene regen.
156
+ const reportPath = join(demoDir, 'scene-report.json');
157
+ const persistedReport = readJsonFile(reportPath, null);
158
+ const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
159
+ const renderedOverlays = buildRenderedOverlays(overlays);
160
+ // Pipeline metadata (from last recording)
161
+ const projectRoot = dirname(resolve(argoDir));
162
+ const metaCandidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.meta.json`));
163
+ const metaPath = metaCandidates.find(p => existsSync(p));
164
+ const pipelineMeta = metaPath ? readJsonFile(metaPath, {}) : null;
165
+ return { demoName, timing, voiceover, overlays, sceneDurations, sceneReport, renderedOverlays, pipelineMeta };
166
+ }
167
+ /** List WAV clip files available for a demo. */
168
+ function listClips(argoDir, demoName) {
169
+ const clipsDir = join(argoDir, demoName, 'clips');
170
+ if (!existsSync(clipsDir))
171
+ return [];
172
+ return readdirSync(clipsDir).filter((f) => f.endsWith('.wav'));
173
+ }
174
+ function getPreviewHtml(data) {
175
+ return PREVIEW_HTML.replace('__PREVIEW_DATA__', JSON.stringify(data));
176
+ }
177
+ function resolveClipPath(clipsDir, clipFile) {
178
+ const decoded = decodeURIComponent(clipFile);
179
+ const candidate = resolve(clipsDir, decoded);
180
+ const rel = relative(clipsDir, candidate);
181
+ if (rel.startsWith('..') || rel.includes(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
182
+ return null;
183
+ }
184
+ return candidate;
185
+ }
186
+ function readClipInfo(clipPath, scene) {
187
+ const wavBuf = readFileSync(clipPath);
188
+ const header = parseWavHeader(wavBuf);
189
+ const sampleCount = header.dataSize / 4;
190
+ const samples = new Float32Array(sampleCount);
191
+ for (let i = 0; i < sampleCount && header.dataOffset + i * 4 + 3 < wavBuf.length; i++) {
192
+ samples[i] = wavBuf.readFloatLE(header.dataOffset + i * 4);
193
+ }
194
+ return {
195
+ scene,
196
+ durationMs: header.durationMs,
197
+ samples,
198
+ };
199
+ }
200
+ function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
201
+ const demoDir = join(argoDir, demoName);
202
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
203
+ const timingPath = join(demoDir, '.timing.json');
204
+ const persistedReportPath = join(demoDir, 'scene-report.json');
205
+ const projectRoot = dirname(resolve(argoDir));
206
+ const cache = new ClipCache(projectRoot);
207
+ const timing = readJsonFile(timingPath, {});
208
+ const persistedReport = readJsonFile(persistedReportPath, null);
209
+ const scenesRaw = readJsonFile(scenesPath, []);
210
+ const manifest = scenesRaw.map((s) => ({
211
+ scene: s.scene,
212
+ text: s.text,
213
+ voice: s.voice,
214
+ speed: s.speed,
215
+ lang: s.lang,
216
+ _hint: s._hint,
217
+ }));
218
+ const clips = [];
219
+ const sceneDurations = {};
220
+ for (const entry of manifest) {
221
+ const cacheEntry = {
222
+ scene: entry.scene,
223
+ text: entry.text,
224
+ voice: entry.voice ?? defaults?.voice,
225
+ speed: entry.speed ?? defaults?.speed,
226
+ lang: entry.lang,
227
+ };
228
+ const clipPath = cache.getClipPath(demoName, cacheEntry);
229
+ if (!existsSync(clipPath)) {
230
+ throw new Error(`Expected regenerated clip for scene "${entry.scene}" at ${clipPath}, but it was not found. ` +
231
+ `Try running: argo tts generate ${scenesPath}`);
232
+ }
233
+ const clipInfo = readClipInfo(clipPath, entry.scene);
234
+ clips.push(clipInfo);
235
+ sceneDurations[entry.scene] = clipInfo.durationMs;
236
+ }
237
+ writeFileSync(join(demoDir, '.scene-durations.json'), JSON.stringify(sceneDurations, null, 2), 'utf-8');
238
+ const baseReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
239
+ const totalDurationMs = baseReport?.totalDurationMs ?? 0;
240
+ const aligned = alignClips(timing, clips, totalDurationMs);
241
+ writeFileSync(join(demoDir, 'narration-aligned.wav'), createWavBuffer(aligned.samples, 24_000));
242
+ return {
243
+ sceneDurations,
244
+ sceneReport: createSceneReportFromPlacements(aligned.placements, persistedReport),
245
+ };
246
+ }
247
+ async function runPreviewTtsGenerate(manifestPath) {
248
+ await new Promise((resolve, reject) => {
249
+ execFile('npx', ['argo', 'tts', 'generate', manifestPath], {
250
+ env: process.env,
251
+ }, (err, stdout, stderr) => {
252
+ if (err) {
253
+ reject(new Error(`TTS regen failed: ${stderr || stdout}`));
254
+ }
255
+ else {
256
+ resolve();
257
+ }
258
+ });
259
+ });
260
+ }
261
+ export async function startPreviewServer(options) {
262
+ const argoDir = options.argoDir ?? '.argo';
263
+ const demosDir = options.demosDir ?? 'demos';
264
+ const port = options.port ?? 0; // 0 = auto-assign
265
+ const demoName = options.demoName;
266
+ const demoDir = join(argoDir, demoName);
267
+ // Prefer exported MP4 (has keyframes for seeking) over raw WebM (no cue points)
268
+ const webmPath = join(demoDir, 'video.webm');
269
+ const projectRoot = dirname(resolve(argoDir));
270
+ const mp4Candidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.mp4`));
271
+ const videoPath = mp4Candidates.find(p => existsSync(p)) ?? webmPath;
272
+ if (!existsSync(videoPath)) {
273
+ throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' first.`);
274
+ }
275
+ const videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
276
+ const server = createServer(async (req, res) => {
277
+ const url = req.url ?? '/';
278
+ try {
279
+ // --- API routes ---
280
+ if (url === '/api/data') {
281
+ const data = loadPreviewData(demoName, argoDir, demosDir);
282
+ res.writeHead(200, { 'Content-Type': 'application/json' });
283
+ res.end(JSON.stringify(data));
284
+ return;
285
+ }
286
+ if (url === '/api/clips') {
287
+ const clips = listClips(argoDir, demoName);
288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify(clips));
290
+ return;
291
+ }
292
+ // Save voiceover fields into unified .scenes.json
293
+ if (url === '/api/voiceover' && req.method === 'POST') {
294
+ const chunks = [];
295
+ for await (const chunk of req)
296
+ chunks.push(chunk);
297
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
298
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
299
+ const scenes = readJsonFile(scenesPath, []);
300
+ let changed = false;
301
+ for (const vo of body) {
302
+ const existing = scenes.find((s) => s.scene === vo.scene);
303
+ if (existing) {
304
+ changed = updatePreviewVoiceoverEntry(existing, vo) || changed;
305
+ }
306
+ }
307
+ if (changed) {
308
+ writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
309
+ }
310
+ res.writeHead(200, { 'Content-Type': 'application/json' });
311
+ res.end(JSON.stringify({ ok: true, changed }));
312
+ return;
313
+ }
314
+ // Render overlay templates without saving to disk (for live preview)
315
+ if (url === '/api/render-overlays' && req.method === 'POST') {
316
+ const chunks = [];
317
+ for await (const chunk of req)
318
+ chunks.push(chunk);
319
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
320
+ const renderedOverlays = buildRenderedOverlays(body);
321
+ res.writeHead(200, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ ok: true, renderedOverlays }));
323
+ return;
324
+ }
325
+ // Save overlay fields into unified .scenes.json
326
+ if (url === '/api/overlays' && req.method === 'POST') {
327
+ const chunks = [];
328
+ for await (const chunk of req)
329
+ chunks.push(chunk);
330
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
331
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
332
+ const scenes = readJsonFile(scenesPath, []);
333
+ // Build a map of posted overlays keyed by scene
334
+ const ovByScene = new Map();
335
+ for (const ov of body)
336
+ ovByScene.set(ov.scene, ov);
337
+ let changed = false;
338
+ for (const entry of scenes) {
339
+ const posted = ovByScene.get(entry.scene);
340
+ changed = updatePreviewOverlayEntry(entry, posted) || changed;
341
+ }
342
+ if (changed) {
343
+ writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
344
+ }
345
+ // Reload and re-render overlays
346
+ const data = loadPreviewData(demoName, argoDir, demosDir);
347
+ res.writeHead(200, { 'Content-Type': 'application/json' });
348
+ res.end(JSON.stringify({ ok: true, changed, renderedOverlays: data.renderedOverlays }));
349
+ return;
350
+ }
351
+ // Regenerate a single TTS clip
352
+ if (url === '/api/regen-clip' && req.method === 'POST') {
353
+ const chunks = [];
354
+ for await (const chunk of req)
355
+ chunks.push(chunk);
356
+ const { scene } = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
357
+ const manifestPath = join(demosDir, `${demoName}.scenes.json`);
358
+ const regenerateTts = options.regenerateTts ?? ((args) => runPreviewTtsGenerate(args.manifestPath));
359
+ await regenerateTts({ manifestPath, scene });
360
+ const refreshed = refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, options.ttsDefaults);
361
+ res.writeHead(200, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({
363
+ ok: true,
364
+ scene,
365
+ durationMs: refreshed.sceneDurations[scene] ?? 0,
366
+ sceneDurations: refreshed.sceneDurations,
367
+ sceneReport: refreshed.sceneReport,
368
+ }));
369
+ return;
370
+ }
371
+ // Re-record: run the full pipeline
372
+ if (url === '/api/rerecord' && req.method === 'POST') {
373
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
374
+ try {
375
+ await new Promise((resolve, reject) => {
376
+ const child = execFile('npx', ['argo', 'pipeline', demoName], {
377
+ env: process.env,
378
+ }, (err, stdout, stderr) => {
379
+ if (err)
380
+ reject(new Error(stderr || stdout || err.message));
381
+ else
382
+ resolve();
383
+ });
384
+ });
385
+ res.end(JSON.stringify({ ok: true }));
386
+ }
387
+ catch (err) {
388
+ res.end(JSON.stringify({ ok: false, error: err.message }));
389
+ }
390
+ return;
391
+ }
392
+ // --- Static file serving ---
393
+ // Serve video with Range request support (required for seeking)
394
+ if (url === '/video' || url === '/video.webm') {
395
+ serveFileWithRanges(req, res, videoPath, videoMime);
396
+ return;
397
+ }
398
+ // Serve narration-aligned.wav
399
+ if (url === '/narration-aligned.wav') {
400
+ serveFile(res, join(demoDir, 'narration-aligned.wav'));
401
+ return;
402
+ }
403
+ // Serve individual clips: /clips/scene-name.wav
404
+ if (url.startsWith('/clips/')) {
405
+ const clipFile = url.slice('/clips/'.length);
406
+ const clipsDir = join(demoDir, 'clips');
407
+ const clipPath = resolveClipPath(clipsDir, clipFile);
408
+ if (clipPath && existsSync(clipPath)) {
409
+ serveFile(res, clipPath);
410
+ }
411
+ else {
412
+ res.writeHead(404);
413
+ res.end('Clip not found');
414
+ }
415
+ return;
416
+ }
417
+ // Serve trace.zip (for Playwright trace viewer link)
418
+ if (url === '/trace.zip') {
419
+ const tracePath = join(demoDir, 'trace.zip');
420
+ if (existsSync(tracePath)) {
421
+ serveFile(res, tracePath);
422
+ }
423
+ else {
424
+ res.writeHead(404);
425
+ res.end('No trace captured');
426
+ }
427
+ return;
428
+ }
429
+ // Root — serve the preview HTML
430
+ if (url === '/' || url === '/index.html') {
431
+ const data = loadPreviewData(demoName, argoDir, demosDir);
432
+ const html = getPreviewHtml(data);
433
+ res.writeHead(200, { 'Content-Type': 'text/html' });
434
+ res.end(html);
435
+ return;
436
+ }
437
+ res.writeHead(404);
438
+ res.end('Not found');
439
+ }
440
+ catch (err) {
441
+ res.writeHead(500, { 'Content-Type': 'application/json' });
442
+ res.end(JSON.stringify({ error: err.message }));
443
+ }
444
+ });
445
+ return new Promise((resolve, reject) => {
446
+ server.once('error', reject);
447
+ server.listen(port, '127.0.0.1', () => {
448
+ server.off('error', reject);
449
+ const addr = server.address();
450
+ const assignedPort = typeof addr === 'object' && addr ? addr.port : port;
451
+ const serverUrl = `http://127.0.0.1:${assignedPort}`;
452
+ resolve({
453
+ url: serverUrl,
454
+ close: () => server.close(),
455
+ });
456
+ });
457
+ });
458
+ }
459
+ function serveFileWithRanges(req, res, filePath, mime) {
460
+ if (!existsSync(filePath)) {
461
+ res.writeHead(404);
462
+ res.end('Not found');
463
+ return;
464
+ }
465
+ const stat = statSync(filePath);
466
+ const total = stat.size;
467
+ const range = req.headers.range;
468
+ if (range) {
469
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
470
+ if (!match) {
471
+ res.writeHead(416, { 'Content-Range': `bytes */${total}` });
472
+ res.end();
473
+ return;
474
+ }
475
+ let start;
476
+ let end;
477
+ if (match[1] === '' && match[2] === '') {
478
+ res.writeHead(416, { 'Content-Range': `bytes */${total}` });
479
+ res.end();
480
+ return;
481
+ }
482
+ if (match[1] === '') {
483
+ const suffixLength = Number.parseInt(match[2], 10);
484
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
485
+ res.writeHead(416, { 'Content-Range': `bytes */${total}` });
486
+ res.end();
487
+ return;
488
+ }
489
+ start = Math.max(0, total - suffixLength);
490
+ end = total - 1;
491
+ }
492
+ else {
493
+ start = Number.parseInt(match[1], 10);
494
+ end = match[2] ? Number.parseInt(match[2], 10) : total - 1;
495
+ }
496
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
497
+ res.writeHead(416, { 'Content-Range': `bytes */${total}` });
498
+ res.end();
499
+ return;
500
+ }
501
+ end = Math.min(end, total - 1);
502
+ const chunkSize = end - start + 1;
503
+ const stream = createReadStream(filePath, { start, end });
504
+ stream.on('error', () => {
505
+ if (!res.headersSent) {
506
+ res.writeHead(500);
507
+ }
508
+ res.end('Failed to read file');
509
+ });
510
+ res.writeHead(206, {
511
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
512
+ 'Accept-Ranges': 'bytes',
513
+ 'Content-Length': chunkSize,
514
+ 'Content-Type': mime,
515
+ });
516
+ stream.pipe(res);
517
+ }
518
+ else {
519
+ res.writeHead(200, {
520
+ 'Content-Length': total,
521
+ 'Content-Type': mime,
522
+ 'Accept-Ranges': 'bytes',
523
+ });
524
+ createReadStream(filePath).pipe(res);
525
+ }
526
+ }
527
+ function serveFile(res, filePath) {
528
+ if (!existsSync(filePath)) {
529
+ res.writeHead(404);
530
+ res.end('Not found');
531
+ return;
532
+ }
533
+ const ext = extname(filePath);
534
+ const mime = MIME_TYPES[ext] ?? 'application/octet-stream';
535
+ const content = readFileSync(filePath);
536
+ res.writeHead(200, { 'Content-Type': mime, 'Content-Length': content.length });
537
+ res.end(content);
538
+ }
539
+ // ─── Inline HTML for the preview viewer ────────────────────────────────────
540
+ const PREVIEW_HTML = `<!DOCTYPE html>
541
+ <html lang="en">
542
+ <head>
543
+ <meta charset="utf-8">
544
+ <meta name="viewport" content="width=device-width, initial-scale=1">
545
+ <title>Argo Preview</title>
546
+ <style>
547
+ :root {
548
+ --bg: #0c0c0c;
549
+ --surface: #161616;
550
+ --surface2: #1e1e1e;
551
+ --surface3: #262626;
552
+ --border: #2a2a2a;
553
+ --border-subtle: #222;
554
+ --text: #e8e8e8;
555
+ --text-muted: #777;
556
+ --text-dim: #555;
557
+ --accent: #6366f1;
558
+ --accent-hover: #818cf8;
559
+ --accent-glow: rgba(99,102,241,0.15);
560
+ --accent-glow-strong: rgba(99,102,241,0.3);
561
+ --success: #22c55e;
562
+ --success-glow: rgba(34,197,94,0.15);
563
+ --warning: #f59e0b;
564
+ --error: #ef4444;
565
+ --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
566
+ --sans: system-ui, -apple-system, sans-serif;
567
+ --radius: 6px;
568
+ --transition: 0.15s ease;
569
+ }
570
+ * { box-sizing: border-box; margin: 0; padding: 0; }
571
+ body {
572
+ font-family: var(--sans);
573
+ background: var(--bg);
574
+ color: var(--text);
575
+ height: 100vh;
576
+ display: grid;
577
+ grid-template-columns: 1fr 380px;
578
+ grid-template-rows: auto 1fr;
579
+ gap: 0;
580
+ overflow: hidden;
581
+ }
582
+
583
+ /* Header */
584
+ header {
585
+ grid-column: 1 / -1;
586
+ display: flex;
587
+ align-items: center;
588
+ gap: 16px;
589
+ padding: 12px 20px;
590
+ background: var(--surface);
591
+ border-bottom: 1px solid var(--border);
592
+ }
593
+ header h1 { font-size: 16px; font-weight: 600; }
594
+ header .demo-name { color: var(--accent); }
595
+ header .actions { margin-left: auto; display: flex; gap: 8px; }
596
+ header .trace-link {
597
+ font-size: 12px;
598
+ color: var(--text-muted);
599
+ text-decoration: none;
600
+ padding: 4px 10px;
601
+ border: 1px solid var(--border);
602
+ border-radius: 6px;
603
+ }
604
+ header .trace-link:hover { color: var(--text); border-color: var(--text-muted); }
605
+
606
+ /* Toggle switches */
607
+ .toggle-switch {
608
+ position: relative;
609
+ width: 32px;
610
+ height: 18px;
611
+ cursor: pointer;
612
+ flex-shrink: 0;
613
+ }
614
+ .toggle-switch input { display: none; }
615
+ .toggle-switch .slider {
616
+ position: absolute;
617
+ inset: 0;
618
+ background: var(--surface3);
619
+ border-radius: 9px;
620
+ transition: background var(--transition);
621
+ }
622
+ .toggle-switch .slider::after {
623
+ content: '';
624
+ position: absolute;
625
+ top: 2px;
626
+ left: 2px;
627
+ width: 14px;
628
+ height: 14px;
629
+ background: var(--text-muted);
630
+ border-radius: 50%;
631
+ transition: transform var(--transition), background var(--transition);
632
+ }
633
+ .toggle-switch input:checked + .slider { background: var(--accent); }
634
+ .toggle-switch input:checked + .slider::after { transform: translateX(14px); background: white; }
635
+ .toggle-label {
636
+ font-size: 12px;
637
+ color: var(--text-muted);
638
+ }
639
+
640
+ /* Main viewer */
641
+ .viewer {
642
+ display: flex;
643
+ flex-direction: column;
644
+ overflow: hidden;
645
+ position: relative;
646
+ }
647
+ .video-container {
648
+ flex: 1;
649
+ position: relative;
650
+ background: #000;
651
+ display: flex;
652
+ align-items: center;
653
+ justify-content: center;
654
+ min-height: 0;
655
+ }
656
+ .video-container video {
657
+ max-width: 100%;
658
+ max-height: 100%;
659
+ display: block;
660
+ cursor: pointer;
661
+ }
662
+
663
+ /* Overlay preview layer — positioned over the video */
664
+ .overlay-layer {
665
+ position: absolute;
666
+ top: 0; left: 0; right: 0; bottom: 0;
667
+ pointer-events: none;
668
+ }
669
+ .overlay-cue {
670
+ position: absolute;
671
+ z-index: 10;
672
+ pointer-events: none;
673
+ font-family: system-ui, -apple-system, sans-serif;
674
+ opacity: 0;
675
+ transition: opacity 0.3s ease;
676
+ }
677
+ .overlay-cue.visible { opacity: 1; }
678
+ .overlay-cue .preview-badge {
679
+ position: absolute;
680
+ top: -8px;
681
+ right: -8px;
682
+ font-family: var(--mono);
683
+ font-size: 9px;
684
+ font-weight: 600;
685
+ letter-spacing: 0.06em;
686
+ color: #fff;
687
+ background: var(--accent);
688
+ padding: 2px 6px;
689
+ border-radius: 3px;
690
+ line-height: 1;
691
+ opacity: 0.85;
692
+ }
693
+
694
+ /* Zone positioning */
695
+ .overlay-cue[data-zone="bottom-center"] { bottom: 60px; left: 50%; transform: translateX(-50%); }
696
+ .overlay-cue[data-zone="top-left"] { top: 40px; left: 40px; }
697
+ .overlay-cue[data-zone="top-right"] { top: 40px; right: 40px; }
698
+ .overlay-cue[data-zone="bottom-left"] { bottom: 60px; left: 40px; }
699
+ .overlay-cue[data-zone="bottom-right"] { bottom: 60px; right: 40px; }
700
+ .overlay-cue[data-zone="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
701
+
702
+ /* Timeline bar */
703
+ .timeline {
704
+ background: var(--surface);
705
+ border-top: 1px solid var(--border);
706
+ padding: 12px 20px;
707
+ }
708
+ .timeline-bar {
709
+ position: relative;
710
+ height: 32px;
711
+ background: var(--surface2);
712
+ border-radius: 6px;
713
+ cursor: pointer;
714
+ overflow: visible;
715
+ }
716
+ .timeline-progress {
717
+ position: absolute;
718
+ top: 0; left: 0; bottom: 0;
719
+ background: var(--accent);
720
+ border-radius: 6px 0 0 6px;
721
+ opacity: 0.3;
722
+ pointer-events: none;
723
+ }
724
+ .timeline-scene {
725
+ position: absolute;
726
+ top: 0;
727
+ height: 100%;
728
+ display: flex;
729
+ align-items: center;
730
+ padding: 0 8px;
731
+ font-size: 11px;
732
+ font-family: var(--mono);
733
+ font-weight: 500;
734
+ color: var(--text-muted);
735
+ border-left: 2px solid var(--accent);
736
+ cursor: pointer;
737
+ white-space: nowrap;
738
+ overflow: hidden;
739
+ text-overflow: ellipsis;
740
+ background: var(--accent-glow);
741
+ transition: background var(--transition);
742
+ }
743
+ .timeline-scene:nth-child(odd) { background: rgba(99,102,241,0.08); }
744
+ .timeline-scene:nth-child(even) { background: rgba(99,102,241,0.12); }
745
+ .timeline-scene:hover { color: var(--text); background: var(--accent-glow-strong); }
746
+ .timeline-scene.active { color: var(--text); background: var(--accent-glow-strong); }
747
+ .timeline-scene .has-overlay {
748
+ display: inline-block;
749
+ width: 4px;
750
+ height: 4px;
751
+ border-radius: 50%;
752
+ background: var(--accent);
753
+ margin-left: 4px;
754
+ vertical-align: middle;
755
+ }
756
+ .timeline-playhead {
757
+ position: absolute;
758
+ top: 0;
759
+ bottom: 0;
760
+ width: 2px;
761
+ background: var(--text);
762
+ z-index: 5;
763
+ pointer-events: none;
764
+ transition: left 0.05s linear;
765
+ }
766
+ .timeline-time {
767
+ display: flex;
768
+ justify-content: space-between;
769
+ font-size: 11px;
770
+ font-family: var(--mono);
771
+ color: var(--text-muted);
772
+ margin-top: 4px;
773
+ padding: 0 4px;
774
+ }
775
+
776
+ /* Audio controls */
777
+ .audio-controls {
778
+ display: flex;
779
+ align-items: center;
780
+ gap: 12px;
781
+ margin-top: 8px;
782
+ }
783
+ .audio-controls .toggle-group {
784
+ display: flex;
785
+ align-items: center;
786
+ gap: 6px;
787
+ }
788
+
789
+ /* Sidebar */
790
+ .sidebar {
791
+ background: var(--surface);
792
+ border-left: 1px solid var(--border);
793
+ overflow-y: auto;
794
+ display: flex;
795
+ flex-direction: column;
796
+ }
797
+ .sidebar-tabs {
798
+ display: flex;
799
+ border-bottom: 1px solid var(--border);
800
+ }
801
+ .sidebar-tab {
802
+ flex: 1;
803
+ padding: 10px 16px;
804
+ font-size: 12px;
805
+ font-weight: 600;
806
+ color: var(--text-muted);
807
+ text-transform: uppercase;
808
+ letter-spacing: 0.05em;
809
+ background: none;
810
+ border: none;
811
+ border-bottom: 2px solid transparent;
812
+ cursor: pointer;
813
+ transition: color var(--transition), border-color var(--transition);
814
+ }
815
+ .sidebar-tab:hover { color: var(--text); }
816
+ .sidebar-tab.active { color: var(--text); border-bottom-color: var(--accent); }
817
+ .sidebar-panel { overflow-y: auto; flex: 1; }
818
+ .scene-card {
819
+ padding: 14px 16px;
820
+ border-bottom: 1px solid var(--border);
821
+ cursor: pointer;
822
+ transition: background var(--transition), border-color var(--transition);
823
+ border-left: 3px solid transparent;
824
+ }
825
+ .scene-card:hover { background: var(--surface2); }
826
+ .scene-card.active { background: var(--accent-glow); border-left-color: var(--accent); }
827
+ .scene-card.modified { border-left-color: var(--warning); }
828
+ .scene-card.active.modified { border-left-color: var(--warning); }
829
+ .scene-card .scene-body { display: none; }
830
+ .scene-card.expanded .scene-body { display: block; }
831
+ .scene-card .scene-name .expand-icon {
832
+ margin-left: auto;
833
+ font-size: 10px;
834
+ color: var(--text-dim);
835
+ transition: transform var(--transition);
836
+ cursor: pointer;
837
+ padding: 4px 6px;
838
+ border-radius: 4px;
839
+ }
840
+ .scene-card .scene-name .expand-icon:hover {
841
+ color: var(--text);
842
+ background: var(--surface3);
843
+ }
844
+ .scene-card.expanded .scene-name .expand-icon { transform: rotate(90deg); }
845
+ .scene-card .scene-name {
846
+ font-family: var(--mono);
847
+ font-size: 13px;
848
+ font-weight: 600;
849
+ margin-bottom: 6px;
850
+ display: flex;
851
+ align-items: center;
852
+ gap: 8px;
853
+ }
854
+ .scene-card .scene-time {
855
+ font-family: var(--mono);
856
+ font-size: 11px;
857
+ color: var(--text-muted);
858
+ font-weight: 400;
859
+ }
860
+ .scene-card .scene-duration {
861
+ font-family: var(--mono);
862
+ font-size: 11px;
863
+ color: var(--text-muted);
864
+ background: var(--surface2);
865
+ padding: 2px 6px;
866
+ border-radius: 4px;
867
+ }
868
+
869
+ /* Editable fields */
870
+ .field-group { margin-top: 8px; }
871
+ .field-group label {
872
+ display: block;
873
+ font-size: 11px;
874
+ color: var(--text-muted);
875
+ margin-bottom: 3px;
876
+ text-transform: uppercase;
877
+ letter-spacing: 0.04em;
878
+ }
879
+ .field-group textarea, .field-group input, .field-group select {
880
+ width: 100%;
881
+ background: var(--surface2);
882
+ border: 1px solid var(--border);
883
+ border-radius: 6px;
884
+ color: var(--text);
885
+ padding: 6px 10px;
886
+ font-size: 13px;
887
+ font-family: inherit;
888
+ resize: vertical;
889
+ }
890
+ .field-group textarea { min-height: 50px; }
891
+ .field-group textarea:focus, .field-group input:focus, .field-group select:focus {
892
+ outline: none;
893
+ border-color: var(--accent);
894
+ box-shadow: 0 0 0 2px var(--accent-glow);
895
+ }
896
+ .hint-text {
897
+ font-size: 11px;
898
+ color: var(--text-muted);
899
+ font-style: italic;
900
+ margin-top: 2px;
901
+ }
902
+ .scene-scrub input[type="range"] {
903
+ -webkit-appearance: none;
904
+ width: 100%;
905
+ height: 4px;
906
+ background: var(--surface3);
907
+ border-radius: 2px;
908
+ border: 0;
909
+ padding: 0;
910
+ outline: none;
911
+ }
912
+ .scene-scrub input[type="range"]::-webkit-slider-thumb {
913
+ -webkit-appearance: none;
914
+ width: 12px;
915
+ height: 12px;
916
+ border-radius: 50%;
917
+ background: var(--accent);
918
+ cursor: pointer;
919
+ border: 2px solid var(--surface);
920
+ }
921
+ .scene-scrub input[type="range"]::-webkit-slider-thumb:hover {
922
+ background: var(--accent-hover);
923
+ transform: scale(1.2);
924
+ }
925
+ .scene-scrub-meta {
926
+ display: flex;
927
+ justify-content: space-between;
928
+ font-family: var(--mono);
929
+ font-size: 11px;
930
+ color: var(--text-muted);
931
+ margin-top: 4px;
932
+ }
933
+
934
+ /* Buttons */
935
+ .btn {
936
+ display: inline-flex;
937
+ align-items: center;
938
+ gap: 4px;
939
+ padding: 5px 12px;
940
+ font-size: 12px;
941
+ font-weight: 500;
942
+ border: 1px solid var(--border);
943
+ border-radius: var(--radius);
944
+ background: var(--surface2);
945
+ color: var(--text);
946
+ cursor: pointer;
947
+ transition: all var(--transition);
948
+ }
949
+ .btn:hover:not(:disabled) { border-color: var(--text-muted); transform: translateY(-1px); }
950
+ .btn:active:not(:disabled) { transform: translateY(0); }
951
+ .btn-accent {
952
+ background: var(--accent);
953
+ border-color: var(--accent);
954
+ color: white;
955
+ }
956
+ .btn-accent:hover { opacity: 0.9; }
957
+ .btn-save {
958
+ background: var(--success);
959
+ border-color: var(--success);
960
+ color: #000;
961
+ font-weight: 600;
962
+ }
963
+ .btn-save:hover:not(:disabled) { background: #16a34a; }
964
+ .btn-save.dirty {
965
+ background: var(--warning);
966
+ border-color: var(--warning);
967
+ color: #000;
968
+ animation: pulse-save 2s ease-in-out infinite;
969
+ }
970
+ @keyframes pulse-save {
971
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
972
+ 50% { box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.3); }
973
+ }
974
+ .btn-save.saved {
975
+ background: transparent;
976
+ border-color: var(--success);
977
+ color: var(--success);
978
+ }
979
+ .btn-undo {
980
+ background: transparent;
981
+ border-color: var(--warning);
982
+ color: var(--warning);
983
+ font-size: 11px;
984
+ padding: 4px 10px;
985
+ }
986
+ .btn-undo:hover:not(:disabled) {
987
+ background: rgba(245, 158, 11, 0.1);
988
+ }
989
+ .btn-rerecord {
990
+ background: transparent;
991
+ border-color: var(--accent);
992
+ color: var(--accent);
993
+ font-weight: 500;
994
+ }
995
+ .btn-rerecord:hover:not(:disabled) {
996
+ background: var(--accent-glow);
997
+ }
998
+ .btn-rerecord:disabled {
999
+ opacity: 0.4;
1000
+ cursor: not-allowed;
1001
+ }
1002
+ .btn-play {
1003
+ width: 32px;
1004
+ height: 32px;
1005
+ border-radius: 50%;
1006
+ padding: 0;
1007
+ display: flex;
1008
+ align-items: center;
1009
+ justify-content: center;
1010
+ }
1011
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
1012
+ .btn-row { display: flex; gap: 6px; margin-top: 8px; }
1013
+ .btn-group {
1014
+ display: inline-flex;
1015
+ gap: 0;
1016
+ }
1017
+ .btn-group .btn {
1018
+ border-radius: 0;
1019
+ }
1020
+ .btn-group .btn:first-child { border-radius: var(--radius) 0 0 var(--radius); }
1021
+ .btn-group .btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
1022
+ .btn-group .btn + .btn { border-left: 0; }
1023
+
1024
+ /* Status indicator */
1025
+ .status {
1026
+ padding: 8px 16px;
1027
+ font-family: var(--mono);
1028
+ font-size: 12px;
1029
+ color: var(--text-muted);
1030
+ border-top: 1px solid var(--border);
1031
+ margin-top: auto;
1032
+ }
1033
+ .status.saving { color: var(--warning); }
1034
+ .status.saved { color: var(--success); }
1035
+ .status.error { color: var(--error); }
1036
+
1037
+ /* Overlay type selector */
1038
+ .overlay-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1039
+ .overlay-section .section-title {
1040
+ font-size: 11px;
1041
+ color: var(--text-muted);
1042
+ text-transform: uppercase;
1043
+ letter-spacing: 0.04em;
1044
+ margin-bottom: 6px;
1045
+ }
1046
+ </style>
1047
+ </head>
1048
+ <body>
1049
+
1050
+ <header>
1051
+ <h1>Argo Preview — <span class="demo-name" id="demo-name"></span></h1>
1052
+ <div class="actions">
1053
+ <a class="trace-link" id="trace-link" href="https://trace.playwright.dev" target="_blank">Open Trace Viewer</a>
1054
+ <button class="btn btn-save" id="btn-save" title="Save all changes">Save</button>
1055
+ <button class="btn btn-rerecord" id="btn-rerecord" title="Re-record with current manifest">Re-record</button>
1056
+ </div>
1057
+ </header>
1058
+
1059
+ <div class="viewer">
1060
+ <div class="video-container">
1061
+ <video id="video" src="/video" preload="auto" muted playsinline></video>
1062
+ <div class="overlay-layer" id="overlay-layer"></div>
1063
+ </div>
1064
+
1065
+ <div class="timeline">
1066
+ <div class="timeline-bar" id="timeline-bar">
1067
+ <div class="timeline-progress" id="timeline-progress"></div>
1068
+ <div class="timeline-playhead" id="timeline-playhead"></div>
1069
+ </div>
1070
+ <div class="timeline-time">
1071
+ <span id="time-current">0:00</span>
1072
+ <span id="time-total">0:00</span>
1073
+ </div>
1074
+ <div class="audio-controls">
1075
+ <button class="btn btn-play" id="btn-play" title="Play/Pause">
1076
+ <svg id="icon-play" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,1 13,8 3,15"/></svg>
1077
+ <svg id="icon-pause" width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="display:none"><rect x="2" y="1" width="4" height="14"/><rect x="10" y="1" width="4" height="14"/></svg>
1078
+ </button>
1079
+ <div class="toggle-group">
1080
+ <label class="toggle-switch" title="Audio">
1081
+ <input type="checkbox" id="cb-audio" checked>
1082
+ <span class="slider"></span>
1083
+ </label>
1084
+ <span class="toggle-label">Audio</span>
1085
+ </div>
1086
+ <div class="toggle-group">
1087
+ <label class="toggle-switch" title="Overlays">
1088
+ <input type="checkbox" id="cb-overlays" checked>
1089
+ <span class="slider"></span>
1090
+ </label>
1091
+ <span class="toggle-label">Overlays</span>
1092
+ </div>
1093
+ </div>
1094
+ </div>
1095
+ </div>
1096
+
1097
+ <div class="sidebar">
1098
+ <div class="sidebar-tabs">
1099
+ <button class="sidebar-tab active" data-tab="scenes">Scenes</button>
1100
+ <button class="sidebar-tab" data-tab="metadata">Metadata</button>
1101
+ </div>
1102
+ <div class="sidebar-panel" id="panel-scenes">
1103
+ <div id="scene-list"></div>
1104
+ </div>
1105
+ <div class="sidebar-panel" id="panel-metadata" style="display:none">
1106
+ <div id="metadata-content" style="padding:16px;font-family:var(--mono);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;"></div>
1107
+ </div>
1108
+ <div class="status" id="status">Ready</div>
1109
+ </div>
1110
+
1111
+ <script>
1112
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
1113
+ const DATA = __PREVIEW_DATA__;
1114
+ const video = document.getElementById('video');
1115
+ const overlayLayer = document.getElementById('overlay-layer');
1116
+ const timelineBar = document.getElementById('timeline-bar');
1117
+ const timelineProgress = document.getElementById('timeline-progress');
1118
+ const sceneList = document.getElementById('scene-list');
1119
+ const statusEl = document.getElementById('status');
1120
+
1121
+ document.getElementById('demo-name').textContent = DATA.demoName;
1122
+
1123
+ // Audio context for playing clips alongside video
1124
+ let audioCtx = null;
1125
+ let alignedAudioBuffer = null;
1126
+ let audioSource = null;
1127
+ let scenePlaybackEndMs = null;
1128
+ let latestSeekRequest = 0;
1129
+ const scrubState = new Map();
1130
+
1131
+ async function initAudio() {
1132
+ if (!audioCtx) audioCtx = new AudioContext();
1133
+ try {
1134
+ const resp = await fetch('/narration-aligned.wav');
1135
+ const buf = await resp.arrayBuffer();
1136
+ alignedAudioBuffer = await audioCtx.decodeAudioData(buf);
1137
+ } catch (e) {
1138
+ console.warn('Could not load aligned audio:', e);
1139
+ }
1140
+ }
1141
+
1142
+ // ─── Scene data ────────────────────────────────────────────────────────────
1143
+ // Sort scenes by timing
1144
+ const scenes = Object.entries(DATA.timing)
1145
+ .sort((a, b) => a[1] - b[1])
1146
+ .map(([name, startMs]) => ({
1147
+ name,
1148
+ startMs,
1149
+ vo: DATA.voiceover.find(v => v.scene === name),
1150
+ overlay: DATA.overlays.find(o => o.scene === name),
1151
+ rendered: DATA.renderedOverlays[name],
1152
+ report: DATA.sceneReport?.scenes?.find(s => s.scene === name),
1153
+ }));
1154
+
1155
+ let activeScene = null;
1156
+
1157
+ // ─── Timeline ──────────────────────────────────────────────────────────────
1158
+ video.addEventListener('loadedmetadata', () => {
1159
+ const totalMs = video.duration * 1000;
1160
+ document.getElementById('time-total').textContent = formatTime(totalMs);
1161
+
1162
+ // Render scene markers on timeline
1163
+ scenes.forEach((s, i) => {
1164
+ const pct = (s.startMs / totalMs) * 100;
1165
+ const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
1166
+ const widthPct = ((nextStart - s.startMs) / totalMs) * 100;
1167
+
1168
+ const marker = document.createElement('div');
1169
+ marker.className = 'timeline-scene';
1170
+ marker.style.left = pct + '%';
1171
+ marker.style.width = Math.max(widthPct, 2) + '%';
1172
+ const hasOverlay = DATA.overlays.find(o => o.scene === s.name);
1173
+ // s.name is already escaped via esc() — safe for innerHTML
1174
+ marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
1175
+ marker.dataset.scene = s.name;
1176
+ marker.addEventListener('click', (e) => {
1177
+ e.stopPropagation();
1178
+ seekToScene(s);
1179
+ });
1180
+ timelineBar.appendChild(marker);
1181
+ });
1182
+
1183
+ // Create overlay DOM elements
1184
+ renderOverlayElements();
1185
+ });
1186
+
1187
+ video.addEventListener('timeupdate', () => {
1188
+ const totalMs = video.duration * 1000;
1189
+ const currentMs = video.currentTime * 1000;
1190
+ if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
1191
+ const stopAt = scenePlaybackEndMs;
1192
+ scenePlaybackEndMs = null;
1193
+ video.currentTime = stopAt / 1000;
1194
+ video.pause();
1195
+ stopAudio();
1196
+ }
1197
+ timelineProgress.style.width = ((currentMs / totalMs) * 100) + '%';
1198
+ document.getElementById('timeline-playhead').style.left = ((currentMs / totalMs) * 100) + '%';
1199
+ document.getElementById('time-current').textContent = formatTime(currentMs);
1200
+ updateSceneScrubUI(currentMs);
1201
+
1202
+ // Update active scene
1203
+ let current = null;
1204
+ for (let i = scenes.length - 1; i >= 0; i--) {
1205
+ if (currentMs >= scenes[i].startMs) {
1206
+ current = scenes[i];
1207
+ break;
1208
+ }
1209
+ }
1210
+ if (current !== activeScene) {
1211
+ activeScene = current;
1212
+ updateActiveSceneUI();
1213
+ updateOverlayVisibility(currentMs);
1214
+ }
1215
+ });
1216
+
1217
+ // Click on timeline bar to seek
1218
+ timelineBar.addEventListener('click', (e) => {
1219
+ const rect = timelineBar.getBoundingClientRect();
1220
+ const pct = (e.clientX - rect.left) / rect.width;
1221
+ const seekTime = pct * video.duration;
1222
+ scenePlaybackEndMs = null;
1223
+ void seekAbsoluteMs(seekTime * 1000);
1224
+ });
1225
+
1226
+ // Play/pause icon toggling
1227
+ function showPlayIcon() {
1228
+ const p = document.getElementById('icon-play');
1229
+ const s = document.getElementById('icon-pause');
1230
+ if (p) p.style.display = '';
1231
+ if (s) s.style.display = 'none';
1232
+ }
1233
+ function showPauseIcon() {
1234
+ const p = document.getElementById('icon-play');
1235
+ const s = document.getElementById('icon-pause');
1236
+ if (p) p.style.display = 'none';
1237
+ if (s) s.style.display = '';
1238
+ }
1239
+
1240
+ // Play/pause toggle (shared by button and video click)
1241
+ async function togglePlayPause() {
1242
+ if (video.paused) {
1243
+ await video.play();
1244
+ if (document.getElementById('cb-audio').checked) await playAudio();
1245
+ showPauseIcon();
1246
+ } else {
1247
+ video.pause();
1248
+ stopAudio();
1249
+ showPlayIcon();
1250
+ }
1251
+ }
1252
+ function pausePreview() {
1253
+ if (!video.paused) {
1254
+ video.pause();
1255
+ stopAudio();
1256
+ showPlayIcon();
1257
+ }
1258
+ scenePlaybackEndMs = null;
1259
+ }
1260
+
1261
+ document.getElementById('btn-play').addEventListener('click', togglePlayPause);
1262
+ video.addEventListener('click', togglePlayPause);
1263
+
1264
+ video.addEventListener('pause', () => {
1265
+ if (!video.ended) {
1266
+ stopAudio();
1267
+ showPlayIcon();
1268
+ }
1269
+ });
1270
+
1271
+ video.addEventListener('ended', () => {
1272
+ stopAudio();
1273
+ showPlayIcon();
1274
+ });
1275
+
1276
+ // Audio checkbox
1277
+ document.getElementById('cb-audio').addEventListener('change', async (e) => {
1278
+ if (e.target.checked && !video.paused) {
1279
+ await playAudio();
1280
+ } else {
1281
+ stopAudio();
1282
+ }
1283
+ });
1284
+
1285
+ async function playAudio() {
1286
+ if (!audioCtx || !alignedAudioBuffer) await initAudio();
1287
+ if (!audioCtx || !alignedAudioBuffer) return;
1288
+ if (audioCtx.state === 'suspended') {
1289
+ await audioCtx.resume();
1290
+ }
1291
+ stopAudio();
1292
+ audioSource = audioCtx.createBufferSource();
1293
+ audioSource.buffer = alignedAudioBuffer;
1294
+ audioSource.connect(audioCtx.destination);
1295
+ audioSource.start(0, video.currentTime);
1296
+ }
1297
+
1298
+ function stopAudio() {
1299
+ if (audioSource) {
1300
+ try { audioSource.stop(); } catch {}
1301
+ audioSource = null;
1302
+ }
1303
+ }
1304
+
1305
+ function syncAudio() {
1306
+ if (!video.paused && document.getElementById('cb-audio').checked) {
1307
+ void playAudio();
1308
+ }
1309
+ }
1310
+
1311
+ // ─── Overlay rendering ────────────────────────────────────────────────────
1312
+ function renderOverlayElements() {
1313
+ overlayLayer.innerHTML = '';
1314
+ for (const s of scenes) {
1315
+ if (!s.rendered) continue;
1316
+ const el = document.createElement('div');
1317
+ el.className = 'overlay-cue';
1318
+ el.dataset.scene = s.name;
1319
+ el.dataset.zone = s.rendered.zone;
1320
+ el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
1321
+ Object.assign(el.style, s.rendered.styles);
1322
+ overlayLayer.appendChild(el);
1323
+ }
1324
+ }
1325
+
1326
+ function updateOverlayVisibility(currentMs) {
1327
+ if (!document.getElementById('cb-overlays').checked) {
1328
+ overlayLayer.querySelectorAll('.overlay-cue').forEach(el => el.classList.remove('visible'));
1329
+ return;
1330
+ }
1331
+
1332
+ for (const s of scenes) {
1333
+ const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
1334
+ if (!el) continue;
1335
+
1336
+ // Show overlay only during this scene's own duration (not bleeding into next scene)
1337
+ const { startMs, endMs } = getSceneBounds(s);
1338
+ const isActive = currentMs >= startMs && currentMs < endMs;
1339
+ el.classList.toggle('visible', isActive);
1340
+ }
1341
+ }
1342
+
1343
+ document.getElementById('cb-overlays').addEventListener('change', () => {
1344
+ updateOverlayVisibility(video.currentTime * 1000);
1345
+ });
1346
+
1347
+ // ─── Scene list (sidebar) ──────────────────────────────────────────────────
1348
+ function renderSceneList() {
1349
+ sceneList.innerHTML = '';
1350
+ for (const s of scenes) {
1351
+ const card = document.createElement('div');
1352
+ card.className = 'scene-card';
1353
+ card.dataset.scene = s.name;
1354
+
1355
+ const durationMs = DATA.sceneDurations[s.name] ?? s.report?.durationMs ?? 0;
1356
+
1357
+ // scene-name and scene-duration use esc() — safe for innerHTML
1358
+ card.innerHTML = \`
1359
+ <div class="scene-name">
1360
+ \${esc(s.name)}
1361
+ <span class="scene-time">\${formatTime(s.startMs)}</span>
1362
+ \${durationMs ? '<span class="scene-duration">' + (durationMs / 1000).toFixed(1) + 's</span>' : ''}
1363
+ <span class="expand-icon">&#9654;</span>
1364
+ </div>
1365
+ <div class="scene-body">
1366
+ <div class="field-group">
1367
+ <label>Voiceover text</label>
1368
+ <textarea data-field="text" data-scene="\${esc(s.name)}">\${esc(s.vo?.text ?? '')}</textarea>
1369
+ \${s.vo?._hint ? '<div class="hint-text">hint: ' + esc(s.vo._hint) + '</div>' : ''}
1370
+ </div>
1371
+ <div class="field-group" style="display:flex;gap:8px">
1372
+ <div style="flex:1">
1373
+ <label>Voice</label>
1374
+ <input data-field="voice" data-scene="\${esc(s.name)}" value="\${esc(s.vo?.voice ?? '')}" placeholder="default">
1375
+ </div>
1376
+ <div style="flex:1">
1377
+ <label>Speed</label>
1378
+ <input data-field="speed" data-scene="\${esc(s.name)}" type="number" step="0.1" min="0.5" max="2" value="\${s.vo?.speed ?? ''}\" placeholder="1.0">
1379
+ </div>
1380
+ </div>
1381
+ \${renderOverlayFields(s)}
1382
+ <div class="btn-row">
1383
+ <button class="btn btn-undo" data-scene="\${esc(s.name)}" onclick="undoScene('\${esc(s.name)}')" style="display:none" title="Revert to last saved state">Undo</button>
1384
+ <span class="btn-group"><button class="btn" onclick="previewScene('\${esc(s.name)}')" title="Play this scene">&#9654;</button><button class="btn" onclick="pausePreview()" title="Pause">&#9646;&#9646;</button></span>
1385
+ <span class="btn-group"><button class="btn" onclick="nudgeScene('\${esc(s.name)}', -250)">-250ms</button><button class="btn" onclick="nudgeScene('\${esc(s.name)}', 250)">+250ms</button></span>
1386
+ <button class="btn btn-accent" onclick="regenClip('\${esc(s.name)}', this)">Regen TTS</button>
1387
+ </div>
1388
+ <div class="field-group scene-scrub">
1389
+ <label>Scene scrub</label>
1390
+ <input
1391
+ type="range"
1392
+ min="0"
1393
+ max="\${durationMs}"
1394
+ step="25"
1395
+ value="0"
1396
+ data-field="scene-scrub"
1397
+ data-scene="\${esc(s.name)}"
1398
+ \${durationMs ? '' : 'disabled'}
1399
+ >
1400
+ <div class="scene-scrub-meta">
1401
+ <span data-scene-scrub-current="\${esc(s.name)}">0.0s</span>
1402
+ <span data-scene-scrub-total="\${esc(s.name)}">\${(durationMs / 1000).toFixed(1)}s</span>
1403
+ </div>
1404
+ </div>
1405
+ </div>
1406
+ \`;
1407
+
1408
+ // Click on scene header row toggles expand/collapse + seeks
1409
+ card.addEventListener('click', (e) => {
1410
+ if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT' ||
1411
+ e.target.tagName === 'BUTTON' || e.target.tagName === 'SELECT') return;
1412
+ if (e.target.closest('.scene-body')) return;
1413
+ const willCollapse = card.classList.contains('expanded');
1414
+ card.classList.toggle('expanded');
1415
+ if (willCollapse) {
1416
+ manuallyCollapsed.add(s.name);
1417
+ } else {
1418
+ manuallyCollapsed.delete(s.name);
1419
+ seekToScene(s);
1420
+ }
1421
+ });
1422
+
1423
+ const scrub = card.querySelector('[data-field="scene-scrub"]');
1424
+ if (scrub) {
1425
+ scrub.addEventListener('input', (event) => {
1426
+ handleSceneScrubInput(s.name, event.target.value);
1427
+ });
1428
+ scrub.addEventListener('change', (event) => {
1429
+ handleSceneScrubCommit(s.name, event.target.value);
1430
+ });
1431
+ }
1432
+
1433
+ sceneList.appendChild(card);
1434
+
1435
+ // Wire overlay listeners AFTER appendChild so document.querySelector can find the card
1436
+ wireOverlayListeners(s.name);
1437
+ }
1438
+ }
1439
+
1440
+ function renderDynamicOverlayFields(sceneName, type, ov) {
1441
+ if (!type) return '';
1442
+ let fields = '';
1443
+ if (type === 'lower-third' || type === 'callout') {
1444
+ fields += \`
1445
+ <div class="field-group">
1446
+ <label>Text</label>
1447
+ <input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.text ?? '')}">
1448
+ </div>\`;
1449
+ } else if (type === 'headline-card') {
1450
+ fields += \`
1451
+ <div class="field-group">
1452
+ <label>Title</label>
1453
+ <input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.title ?? '')}">
1454
+ </div>
1455
+ <div class="field-group">
1456
+ <label>Body</label>
1457
+ <input data-field="overlay-body" data-scene="\${esc(sceneName)}" value="\${esc(ov?.body ?? '')}" placeholder="optional">
1458
+ </div>
1459
+ <div class="field-group">
1460
+ <label>Kicker</label>
1461
+ <input data-field="overlay-kicker" data-scene="\${esc(sceneName)}" value="\${esc(ov?.kicker ?? '')}" placeholder="optional">
1462
+ </div>\`;
1463
+ } else if (type === 'image-card') {
1464
+ fields += \`
1465
+ <div class="field-group">
1466
+ <label>Title</label>
1467
+ <input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.title ?? '')}" placeholder="optional">
1468
+ </div>
1469
+ <div class="field-group">
1470
+ <label>Body</label>
1471
+ <input data-field="overlay-body" data-scene="\${esc(sceneName)}" value="\${esc(ov?.body ?? '')}" placeholder="optional">
1472
+ </div>
1473
+ <div class="field-group">
1474
+ <label>Src</label>
1475
+ <input data-field="overlay-src" data-scene="\${esc(sceneName)}" value="\${esc(ov?.src ?? '')}" placeholder="assets/example.png">
1476
+ </div>\`;
1477
+ }
1478
+ return fields;
1479
+ }
1480
+
1481
+ function renderOverlayFields(s) {
1482
+ const ov = s.overlay;
1483
+ const type = ov?.type ?? '';
1484
+ return \`
1485
+ <div class="overlay-section">
1486
+ <div class="section-title">Overlay</div>
1487
+ <div class="field-group" style="display:flex;gap:8px">
1488
+ <div style="flex:1">
1489
+ <label>Type</label>
1490
+ <select data-field="overlay-type" data-scene="\${esc(s.name)}">
1491
+ <option value="">none</option>
1492
+ <option value="lower-third" \${type === 'lower-third' ? 'selected' : ''}>lower-third</option>
1493
+ <option value="headline-card" \${type === 'headline-card' ? 'selected' : ''}>headline-card</option>
1494
+ <option value="callout" \${type === 'callout' ? 'selected' : ''}>callout</option>
1495
+ <option value="image-card" \${type === 'image-card' ? 'selected' : ''}>image-card</option>
1496
+ </select>
1497
+ </div>
1498
+ \${type ? \`<div style="flex:1">
1499
+ <label>Zone</label>
1500
+ <select data-field="overlay-placement" data-scene="\${esc(s.name)}">
1501
+ <option value="bottom-center" \${(ov?.placement ?? 'bottom-center') === 'bottom-center' ? 'selected' : ''}>bottom-center</option>
1502
+ <option value="top-left" \${ov?.placement === 'top-left' ? 'selected' : ''}>top-left</option>
1503
+ <option value="top-right" \${ov?.placement === 'top-right' ? 'selected' : ''}>top-right</option>
1504
+ <option value="bottom-left" \${ov?.placement === 'bottom-left' ? 'selected' : ''}>bottom-left</option>
1505
+ <option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
1506
+ <option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
1507
+ </select>
1508
+ </div>\` : ''}
1509
+ </div>
1510
+ \${type ? \`<div class="field-group">
1511
+ <label>Motion</label>
1512
+ <select data-field="overlay-motion" data-scene="\${esc(s.name)}">
1513
+ <option value="none" \${(ov?.motion ?? 'none') === 'none' ? 'selected' : ''}>none</option>
1514
+ <option value="fade-in" \${ov?.motion === 'fade-in' ? 'selected' : ''}>fade-in</option>
1515
+ <option value="slide-in" \${ov?.motion === 'slide-in' ? 'selected' : ''}>slide-in</option>
1516
+ </select>
1517
+ </div>\` : ''}
1518
+ <div class="overlay-fields-dynamic" data-scene="\${esc(s.name)}">
1519
+ \${renderDynamicOverlayFields(s.name, type, ov)}
1520
+ </div>
1521
+ </div>
1522
+ \`;
1523
+ }
1524
+
1525
+ function updateOverlayFieldsForScene(sceneName) {
1526
+ const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
1527
+ const type = typeEl?.value ?? '';
1528
+ const s = scenes.find(sc => sc.name === sceneName);
1529
+ const ov = s?.overlay;
1530
+ const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
1531
+ if (!container) return;
1532
+ // Re-render the dynamic fields — values come from esc() so safe for innerHTML
1533
+ container.innerHTML = renderDynamicOverlayFields(sceneName, type, ov);
1534
+ // Re-render the full overlay section to show/hide zone+motion
1535
+ const section = container.closest('.overlay-section');
1536
+ if (section) {
1537
+ // Temporarily build a fake scene object with updated overlay type for re-render
1538
+ const fakeOv = type ? { ...(ov ?? {}), type } : null;
1539
+ const fakeScene = { name: sceneName, overlay: fakeOv };
1540
+ section.outerHTML = renderOverlayFields(fakeScene);
1541
+ // Re-wire event listeners for the new overlay fields
1542
+ wireOverlayListeners(sceneName);
1543
+ }
1544
+ }
1545
+
1546
+ function wireOverlayListeners(sceneName) {
1547
+ const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
1548
+ if (!card) return;
1549
+ let debounceTimer;
1550
+ card.querySelectorAll('[data-field^="overlay"]').forEach(input => {
1551
+ const handler = () => {
1552
+ markDirty();
1553
+ clearTimeout(debounceTimer);
1554
+ debounceTimer = setTimeout(() => previewOverlays(), 300);
1555
+ };
1556
+ input.addEventListener('input', handler);
1557
+ input.addEventListener('change', handler);
1558
+ });
1559
+ // Re-wire the type change listener
1560
+ const typeSelect = card.querySelector('select[data-field="overlay-type"]');
1561
+ if (typeSelect) {
1562
+ typeSelect.addEventListener('change', () => updateOverlayFieldsForScene(sceneName));
1563
+ }
1564
+ }
1565
+
1566
+ const manuallyCollapsed = new Set();
1567
+
1568
+ // ─── Actions ───────────────────────────────────────────────────────────────
1569
+ function seekToScene(s) {
1570
+ scenePlaybackEndMs = null;
1571
+ void seekAbsoluteMs(getSceneBounds(s).startMs);
1572
+ activeScene = s;
1573
+ updateActiveSceneUI();
1574
+ }
1575
+
1576
+ function updateActiveSceneUI() {
1577
+ document.querySelectorAll('.scene-card').forEach(c => c.classList.remove('active'));
1578
+ document.querySelectorAll('.timeline-scene').forEach(m => m.classList.remove('active'));
1579
+ if (activeScene) {
1580
+ const card = document.querySelector('.scene-card[data-scene="' + activeScene.name + '"]');
1581
+ if (card) {
1582
+ card.classList.add('active');
1583
+ // Auto-expand active scene (unless user manually collapsed it), collapse others
1584
+ document.querySelectorAll('.scene-card.expanded').forEach(c => {
1585
+ if (c !== card) c.classList.remove('expanded');
1586
+ });
1587
+ if (!manuallyCollapsed.has(activeScene.name)) {
1588
+ card.classList.add('expanded');
1589
+ }
1590
+ card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1591
+ }
1592
+ const marker = document.querySelector('.timeline-scene[data-scene="' + activeScene.name + '"]');
1593
+ if (marker) marker.classList.add('active');
1594
+ }
1595
+ }
1596
+
1597
+ function getSceneBounds(s) {
1598
+ const startMs = s.report?.startMs ?? s.startMs;
1599
+ const endMs = s.report?.endMs ?? (startMs + (DATA.sceneDurations[s.name] ?? 0));
1600
+ return {
1601
+ startMs,
1602
+ endMs,
1603
+ durationMs: Math.max(0, endMs - startMs),
1604
+ };
1605
+ }
1606
+
1607
+ async function seekAbsoluteMs(absoluteMs) {
1608
+ const targetMs = Math.max(0, absoluteMs);
1609
+ const targetSec = targetMs / 1000;
1610
+ const requestId = ++latestSeekRequest;
1611
+
1612
+
1613
+ if (video.readyState < 1) {
1614
+ await new Promise(resolve => video.addEventListener('loadedmetadata', resolve, { once: true }));
1615
+ }
1616
+
1617
+ if (Math.abs(video.currentTime - targetSec) > 0.01 || video.seeking) {
1618
+ await new Promise(resolve => {
1619
+ const onSeeked = () => resolve();
1620
+ video.addEventListener('seeked', onSeeked, { once: true });
1621
+ video.currentTime = targetSec;
1622
+ });
1623
+ } else {
1624
+ }
1625
+
1626
+ if (requestId !== latestSeekRequest) {
1627
+ return;
1628
+ }
1629
+
1630
+ const totalMs = video.duration * 1000;
1631
+ if (totalMs > 0) {
1632
+ const pct = (targetMs / totalMs) * 100;
1633
+ timelineProgress.style.width = pct + '%';
1634
+ document.getElementById('timeline-playhead').style.left = pct + '%';
1635
+ }
1636
+ document.getElementById('time-current').textContent = formatTime(targetMs);
1637
+ updateOverlayVisibility(targetMs);
1638
+ updateSceneScrubUI(targetMs);
1639
+ syncAudio();
1640
+ }
1641
+
1642
+ function updateSceneScrubUI(currentMs = video.currentTime * 1000) {
1643
+ for (const s of scenes) {
1644
+ const { startMs, durationMs } = getSceneBounds(s);
1645
+ const localMs = Math.max(0, Math.min(durationMs, currentMs - startMs));
1646
+ const scrub = document.querySelector('[data-field="scene-scrub"][data-scene="' + s.name + '"]');
1647
+ if (scrub) {
1648
+ scrub.max = String(durationMs);
1649
+ scrub.value = String(localMs);
1650
+ scrub.disabled = durationMs <= 0;
1651
+ }
1652
+ const currentLabel = document.querySelector('[data-scene-scrub-current="' + s.name + '"]');
1653
+ const totalLabel = document.querySelector('[data-scene-scrub-total="' + s.name + '"]');
1654
+ if (currentLabel) currentLabel.textContent = formatSeconds(localMs);
1655
+ if (totalLabel) totalLabel.textContent = formatSeconds(durationMs);
1656
+ }
1657
+ }
1658
+
1659
+ async function handleSceneScrubInput(sceneName, rawValue) {
1660
+ const s = scenes.find((scene) => scene.name === sceneName);
1661
+ if (!s) return;
1662
+ if (!scrubState.has(sceneName)) {
1663
+ scrubState.set(sceneName, { resumeAfter: !video.paused });
1664
+ video.pause();
1665
+ }
1666
+ const { startMs, durationMs } = getSceneBounds(s);
1667
+ const offsetMs = Math.max(0, Math.min(durationMs, Number(rawValue) || 0));
1668
+ scenePlaybackEndMs = null;
1669
+ activeScene = s;
1670
+ updateActiveSceneUI();
1671
+ await seekAbsoluteMs(startMs + offsetMs);
1672
+ }
1673
+
1674
+ async function handleSceneScrubCommit(sceneName, rawValue) {
1675
+ await handleSceneScrubInput(sceneName, rawValue);
1676
+ const state = scrubState.get(sceneName);
1677
+ scrubState.delete(sceneName);
1678
+ if (state?.resumeAfter) {
1679
+ void video.play().then(async () => {
1680
+ if (document.getElementById('cb-audio').checked) {
1681
+ await playAudio();
1682
+ }
1683
+ showPauseIcon();
1684
+ });
1685
+ }
1686
+ }
1687
+
1688
+ function nudgeScene(sceneName, deltaMs) {
1689
+ const s = scenes.find(s => s.name === sceneName);
1690
+ if (!s) return;
1691
+ const scrub = document.querySelector('[data-field="scene-scrub"][data-scene="' + sceneName + '"]');
1692
+ const currentMs = scrub ? Number(scrub.value) || 0 : 0;
1693
+ void handleSceneScrubCommit(sceneName, currentMs + deltaMs);
1694
+ }
1695
+
1696
+ async function previewScene(sceneName) {
1697
+ await initAudio();
1698
+ const s = scenes.find(s => s.name === sceneName);
1699
+ if (!s) return;
1700
+ const { startMs, endMs, durationMs } = getSceneBounds(s);
1701
+ if (!durationMs) return;
1702
+ // Pause first to prevent timeupdate race, then seek, then play
1703
+ video.pause();
1704
+ stopAudio();
1705
+ scenePlaybackEndMs = null;
1706
+ await seekAbsoluteMs(startMs);
1707
+ // Verify seek landed — some browsers reset on play()
1708
+ if (Math.abs(video.currentTime - startMs / 1000) > 0.1) {
1709
+ video.currentTime = startMs / 1000;
1710
+ await new Promise(r => video.addEventListener('seeked', r, { once: true }));
1711
+ }
1712
+ activeScene = s;
1713
+ updateActiveSceneUI();
1714
+ scenePlaybackEndMs = endMs;
1715
+ await video.play();
1716
+ if (document.getElementById('cb-audio').checked) await playAudio();
1717
+ showPauseIcon();
1718
+ }
1719
+
1720
+ async function regenClip(sceneName, btn) {
1721
+ btn.disabled = true;
1722
+ btn.textContent = 'Generating...';
1723
+ setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
1724
+
1725
+ try {
1726
+ // Save current voiceover state first
1727
+ await saveVoiceover();
1728
+
1729
+ const resp = await fetch('/api/regen-clip', {
1730
+ method: 'POST',
1731
+ headers: { 'Content-Type': 'application/json' },
1732
+ body: JSON.stringify({ scene: sceneName }),
1733
+ });
1734
+ const result = await resp.json();
1735
+ if (!resp.ok) throw new Error(result.error);
1736
+
1737
+ // Update local duration data
1738
+ if (result.sceneDurations) DATA.sceneDurations = result.sceneDurations;
1739
+ if (result.sceneReport) DATA.sceneReport = result.sceneReport;
1740
+
1741
+ // Reload aligned audio
1742
+ await initAudio();
1743
+ // Update scene objects
1744
+ for (const s of scenes) {
1745
+ s.vo = DATA.voiceover.find(v => v.scene === s.name);
1746
+ s.overlay = DATA.overlays.find(o => o.scene === s.name);
1747
+ s.rendered = DATA.renderedOverlays[s.name];
1748
+ s.report = DATA.sceneReport?.scenes?.find(r => r.scene === s.name);
1749
+ }
1750
+ updateSceneDuration(sceneName);
1751
+ updateSceneScrubUI(video.currentTime * 1000);
1752
+
1753
+ setStatus('TTS regenerated for ' + sceneName, 'saved');
1754
+ } catch (err) {
1755
+ setStatus('Regen failed: ' + err.message, 'error');
1756
+ } finally {
1757
+ btn.disabled = false;
1758
+ btn.textContent = 'Regen TTS';
1759
+ }
1760
+ }
1761
+
1762
+ function collectVoiceover() {
1763
+ return scenes.map(s => {
1764
+ const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
1765
+ const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
1766
+ const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
1767
+ const entry = { ...(s.vo ?? { scene: s.name }), scene: s.name, text: textEl?.value ?? '' };
1768
+ if (voiceEl?.value) entry.voice = voiceEl.value;
1769
+ else delete entry.voice;
1770
+
1771
+ const speed = speedEl?.value ? parseFloat(speedEl.value) : undefined;
1772
+ if (Number.isFinite(speed)) entry.speed = speed;
1773
+ else delete entry.speed;
1774
+
1775
+ return entry;
1776
+ });
1777
+ }
1778
+
1779
+ function collectOverlays() {
1780
+ return scenes
1781
+ .map(s => {
1782
+ const typeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-type"]');
1783
+ const placeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-placement"]');
1784
+ const motionEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-motion"]');
1785
+ const textEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-text"]');
1786
+ const bodyEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-body"]');
1787
+ const kickerEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-kicker"]');
1788
+ const srcEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-src"]');
1789
+ const type = typeEl?.value;
1790
+ if (!type) return null;
1791
+ const entry = {
1792
+ ...(s.overlay ?? {}),
1793
+ scene: s.name,
1794
+ type,
1795
+ placement: placeEl?.value ?? 'bottom-center',
1796
+ };
1797
+ if (motionEl?.value && motionEl.value !== 'none') entry.motion = motionEl.value;
1798
+ else delete entry.motion;
1799
+
1800
+ delete entry.text;
1801
+ delete entry.title;
1802
+ delete entry.body;
1803
+ delete entry.kicker;
1804
+ delete entry.src;
1805
+
1806
+ if (type === 'lower-third' || type === 'callout') {
1807
+ entry.text = textEl?.value ?? '';
1808
+ } else {
1809
+ entry.title = textEl?.value ?? '';
1810
+ if (bodyEl?.value) entry.body = bodyEl.value;
1811
+ if (type === 'headline-card' && kickerEl?.value) entry.kicker = kickerEl.value;
1812
+ if (type === 'image-card' && srcEl?.value) entry.src = srcEl.value;
1813
+ }
1814
+ return entry;
1815
+ })
1816
+ .filter(Boolean);
1817
+ }
1818
+
1819
+ async function saveVoiceover() {
1820
+ const vo = collectVoiceover();
1821
+ await fetch('/api/voiceover', {
1822
+ method: 'POST',
1823
+ headers: { 'Content-Type': 'application/json' },
1824
+ body: JSON.stringify(vo),
1825
+ });
1826
+ }
1827
+
1828
+ // Render-only preview (no disk write) — called on every overlay field edit
1829
+ async function previewOverlays() {
1830
+ const ov = collectOverlays();
1831
+ const resp = await fetch('/api/render-overlays', {
1832
+ method: 'POST',
1833
+ headers: { 'Content-Type': 'application/json' },
1834
+ body: JSON.stringify(ov),
1835
+ });
1836
+ const result = await resp.json();
1837
+ if (result.renderedOverlays) {
1838
+ DATA.renderedOverlays = result.renderedOverlays;
1839
+ DATA.overlays = ov;
1840
+ for (const s of scenes) {
1841
+ s.overlay = DATA.overlays.find(o => o.scene === s.name);
1842
+ s.rendered = DATA.renderedOverlays[s.name];
1843
+ }
1844
+ renderOverlayElements();
1845
+ updateOverlayVisibility(video.currentTime * 1000);
1846
+ }
1847
+ }
1848
+
1849
+ // Persist to disk — called only by Save button
1850
+ async function saveOverlays() {
1851
+ const ov = collectOverlays();
1852
+ const resp = await fetch('/api/overlays', {
1853
+ method: 'POST',
1854
+ headers: { 'Content-Type': 'application/json' },
1855
+ body: JSON.stringify(ov),
1856
+ });
1857
+ const result = await resp.json();
1858
+ if (result.renderedOverlays) {
1859
+ DATA.renderedOverlays = result.renderedOverlays;
1860
+ DATA.overlays = ov;
1861
+ for (const s of scenes) {
1862
+ s.overlay = DATA.overlays.find(o => o.scene === s.name);
1863
+ s.rendered = DATA.renderedOverlays[s.name];
1864
+ }
1865
+ renderOverlayElements();
1866
+ updateOverlayVisibility(video.currentTime * 1000);
1867
+ }
1868
+ }
1869
+
1870
+ // ─── Scene snapshots (for per-scene undo) ──────────────────────────────────
1871
+ const sceneSnapshots = new Map();
1872
+
1873
+ function snapshotAllScenes() {
1874
+ for (const s of scenes) {
1875
+ sceneSnapshots.set(s.name, {
1876
+ text: s.vo?.text ?? '',
1877
+ voice: s.vo?.voice ?? '',
1878
+ speed: s.vo?.speed ?? '',
1879
+ overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
1880
+ });
1881
+ }
1882
+ }
1883
+
1884
+ function getSceneSnapshot(sceneName) {
1885
+ return sceneSnapshots.get(sceneName);
1886
+ }
1887
+
1888
+ function isSceneModified(sceneName) {
1889
+ const snap = getSceneSnapshot(sceneName);
1890
+ if (!snap) return false;
1891
+ const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
1892
+ if (!card) return false;
1893
+ const text = card.querySelector('[data-field="text"]')?.value ?? '';
1894
+ const voice = card.querySelector('[data-field="voice"]')?.value ?? '';
1895
+ const speed = card.querySelector('[data-field="speed"]')?.value ?? '';
1896
+ if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed)) return true;
1897
+ // Check overlay fields
1898
+ const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
1899
+ const snapType = snap.overlay?.type ?? '';
1900
+ if (type !== snapType) return true;
1901
+ if (type) {
1902
+ const placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? '';
1903
+ const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? '';
1904
+ const overlayText = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
1905
+ const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
1906
+ const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
1907
+ const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
1908
+ const so = snap.overlay || {};
1909
+ if (placement !== (so.placement ?? 'bottom-center')) return true;
1910
+ if (motion !== (so.motion ?? 'none')) return true;
1911
+ const snapText = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
1912
+ if (overlayText !== snapText) return true;
1913
+ if (body !== (so.body ?? '')) return true;
1914
+ if (kicker !== (so.kicker ?? '')) return true;
1915
+ if (src !== (so.src ?? '')) return true;
1916
+ }
1917
+ return false;
1918
+ }
1919
+
1920
+ function updateUndoButton(sceneName) {
1921
+ const btn = document.querySelector('.btn-undo[data-scene="' + sceneName + '"]');
1922
+ const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
1923
+ const modified = isSceneModified(sceneName);
1924
+ if (btn) btn.style.display = modified ? '' : 'none';
1925
+ if (card) card.classList.toggle('modified', modified);
1926
+ }
1927
+
1928
+ function updateAllUndoButtons() {
1929
+ for (const s of scenes) updateUndoButton(s.name);
1930
+ }
1931
+
1932
+ function undoScene(sceneName) {
1933
+ const snap = getSceneSnapshot(sceneName);
1934
+ if (!snap) return;
1935
+ const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
1936
+ if (!card) return;
1937
+ // Restore voiceover fields
1938
+ const textEl = card.querySelector('[data-field="text"]');
1939
+ if (textEl) textEl.value = snap.text;
1940
+ const voiceEl = card.querySelector('[data-field="voice"]');
1941
+ if (voiceEl) voiceEl.value = snap.voice;
1942
+ const speedEl = card.querySelector('[data-field="speed"]');
1943
+ if (speedEl) speedEl.value = snap.speed;
1944
+ // Restore overlay type (triggers field re-render)
1945
+ const typeEl = card.querySelector('[data-field="overlay-type"]');
1946
+ if (typeEl) {
1947
+ typeEl.value = snap.overlay?.type ?? '';
1948
+ updateOverlayFieldsForScene(sceneName);
1949
+ }
1950
+ // Restore overlay field values after re-render
1951
+ setTimeout(() => {
1952
+ const so = snap.overlay || {};
1953
+ const textField = card.querySelector('[data-field="overlay-text"]');
1954
+ if (textField) {
1955
+ textField.value = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
1956
+ }
1957
+ const bodyField = card.querySelector('[data-field="overlay-body"]');
1958
+ if (bodyField) bodyField.value = so.body ?? '';
1959
+ const kickerField = card.querySelector('[data-field="overlay-kicker"]');
1960
+ if (kickerField) kickerField.value = so.kicker ?? '';
1961
+ const srcField = card.querySelector('[data-field="overlay-src"]');
1962
+ if (srcField) srcField.value = so.src ?? '';
1963
+ const placementField = card.querySelector('[data-field="overlay-placement"]');
1964
+ if (placementField) placementField.value = so.placement ?? 'bottom-center';
1965
+ const motionField = card.querySelector('[data-field="overlay-motion"]');
1966
+ if (motionField) motionField.value = so.motion ?? 'none';
1967
+ // Re-render overlay preview
1968
+ previewOverlays();
1969
+ updateUndoButton(sceneName);
1970
+ // Check if all scenes are back to saved state
1971
+ const anyModified = scenes.some(s => isSceneModified(s.name));
1972
+ if (!anyModified) clearDirty();
1973
+ }, 0);
1974
+ }
1975
+
1976
+ // ─── Dirty state ───────────────────────────────────────────────────────────
1977
+ let isDirty = false;
1978
+
1979
+ function markDirty() {
1980
+ isDirty = true;
1981
+ const saveBtn = document.getElementById('btn-save');
1982
+ saveBtn.classList.add('dirty');
1983
+ saveBtn.textContent = '\\u25cf Save';
1984
+ updateAllUndoButtons();
1985
+ }
1986
+
1987
+ function clearDirty() {
1988
+ isDirty = false;
1989
+ const saveBtn = document.getElementById('btn-save');
1990
+ saveBtn.classList.remove('dirty');
1991
+ saveBtn.textContent = 'Save';
1992
+ snapshotAllScenes();
1993
+ updateAllUndoButtons();
1994
+ }
1995
+
1996
+ // Save button
1997
+ document.getElementById('btn-save').addEventListener('click', async () => {
1998
+ const saveBtn = document.getElementById('btn-save');
1999
+ setStatus('Saving...', 'saving');
2000
+ try {
2001
+ await saveVoiceover();
2002
+ await saveOverlays();
2003
+ clearDirty();
2004
+ setStatus('All changes saved', 'saved');
2005
+ saveBtn.textContent = '\\u2713 Saved';
2006
+ saveBtn.classList.add('saved');
2007
+ setTimeout(() => {
2008
+ if (!isDirty) {
2009
+ saveBtn.textContent = 'Save';
2010
+ saveBtn.classList.remove('saved');
2011
+ }
2012
+ }, 2000);
2013
+ } catch (err) {
2014
+ setStatus('Save failed: ' + err.message, 'error');
2015
+ }
2016
+ });
2017
+
2018
+ // Re-record button
2019
+ document.getElementById('btn-rerecord').addEventListener('click', async () => {
2020
+ if (isDirty && !confirm('You have unsaved changes. Save before re-recording?')) return;
2021
+ if (isDirty) {
2022
+ await saveVoiceover();
2023
+ await saveOverlays();
2024
+ clearDirty();
2025
+ }
2026
+ const btn = document.getElementById('btn-rerecord');
2027
+ btn.disabled = true;
2028
+ btn.textContent = 'Recording...';
2029
+ setStatus('Re-recording pipeline...', 'saving');
2030
+ try {
2031
+ const resp = await fetch('/api/rerecord', { method: 'POST' });
2032
+ const result = await resp.json();
2033
+ if (!result.ok) throw new Error(result.error);
2034
+ setStatus('Re-record complete! Reloading...', 'saved');
2035
+ setTimeout(() => location.reload(), 1500);
2036
+ } catch (err) {
2037
+ setStatus('Re-record failed: ' + err.message, 'error');
2038
+ btn.disabled = false;
2039
+ btn.textContent = 'Re-record';
2040
+ }
2041
+ });
2042
+
2043
+ // ─── Helpers ───────────────────────────────────────────────────────────────
2044
+ function formatTime(ms) {
2045
+ const s = Math.floor(ms / 1000);
2046
+ const m = Math.floor(s / 60);
2047
+ return m + ':' + String(s % 60).padStart(2, '0');
2048
+ }
2049
+
2050
+ function formatSeconds(ms) {
2051
+ return (Math.max(0, ms) / 1000).toFixed(1) + 's';
2052
+ }
2053
+
2054
+ function esc(str) {
2055
+ if (!str) return '';
2056
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2057
+ }
2058
+
2059
+ function setStatus(msg, cls) {
2060
+ statusEl.textContent = msg;
2061
+ statusEl.className = 'status ' + (cls || '');
2062
+ if (cls === 'saved') setTimeout(() => { statusEl.textContent = 'Ready'; statusEl.className = 'status'; }, 3000);
2063
+ }
2064
+
2065
+ function updateSceneDuration(sceneName) {
2066
+ const badge = document.querySelector('.scene-card[data-scene="' + sceneName + '"] .scene-duration');
2067
+ const durationMs = DATA.sceneDurations[sceneName];
2068
+ if (!badge || !durationMs) return;
2069
+ badge.textContent = (durationMs / 1000).toFixed(1) + 's';
2070
+ }
2071
+
2072
+ // ─── Sidebar tabs ──────────────────────────────────────────────────────────
2073
+ document.querySelectorAll('.sidebar-tab').forEach(tab => {
2074
+ tab.addEventListener('click', () => {
2075
+ document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
2076
+ tab.classList.add('active');
2077
+ document.querySelectorAll('.sidebar-panel').forEach(p => p.style.display = 'none');
2078
+ document.getElementById('panel-' + tab.dataset.tab).style.display = '';
2079
+ });
2080
+ });
2081
+
2082
+ // Render metadata if available
2083
+ if (DATA.pipelineMeta) {
2084
+ const meta = DATA.pipelineMeta;
2085
+ const lines = [];
2086
+ lines.push('Created: ' + (meta.createdAt || 'unknown'));
2087
+ lines.push('');
2088
+ if (meta.tts) {
2089
+ lines.push('TTS Engine');
2090
+ const tts = meta.tts;
2091
+ for (const [k, v] of Object.entries(tts)) {
2092
+ lines.push(' ' + k + ': ' + v);
2093
+ }
2094
+ lines.push('');
2095
+ }
2096
+ if (meta.video) {
2097
+ lines.push('Video');
2098
+ const vid = meta.video;
2099
+ lines.push(' resolution: ' + vid.width + 'x' + vid.height);
2100
+ lines.push(' fps: ' + vid.fps);
2101
+ lines.push(' browser: ' + vid.browser);
2102
+ if (vid.deviceScaleFactor > 1) lines.push(' scale: ' + vid.deviceScaleFactor + 'x');
2103
+ lines.push('');
2104
+ }
2105
+ if (meta.export) {
2106
+ lines.push('Export');
2107
+ lines.push(' preset: ' + meta.export.preset);
2108
+ lines.push(' crf: ' + meta.export.crf);
2109
+ lines.push('');
2110
+ }
2111
+ if (meta.scenes) {
2112
+ lines.push('Scenes');
2113
+ for (const s of meta.scenes) {
2114
+ const dur = s.durationMs ? ' (' + (s.durationMs / 1000).toFixed(1) + 's)' : '';
2115
+ lines.push(' ' + s.scene + ': voice=' + (s.voice || 'default') + ' speed=' + (s.speed || 1) + dur);
2116
+ }
2117
+ }
2118
+ document.getElementById('metadata-content').textContent = lines.join('\\n');
2119
+ } else {
2120
+ document.getElementById('metadata-content').textContent = 'No pipeline metadata found.\\n\\nRun argo pipeline to generate metadata.';
2121
+ }
2122
+
2123
+ // ─── Init ──────────────────────────────────────────────────────────────────
2124
+ renderSceneList();
2125
+ snapshotAllScenes();
2126
+ initAudio();
2127
+ updateSceneScrubUI(0);
2128
+
2129
+ // Mark dirty on any voiceover field edit (text, voice, speed)
2130
+ sceneList.addEventListener('input', (e) => {
2131
+ const field = e.target?.dataset?.field;
2132
+ if (field === 'text' || field === 'voice' || field === 'speed') markDirty();
2133
+ });
2134
+ </script>
2135
+ </body>
2136
+ </html>`;
2137
+ //# sourceMappingURL=preview.js.map