@argo-video/cli 0.22.0 → 0.23.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.
package/dist/preview.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * renders overlay cues on a DOM layer, and lets the user edit voiceover text
8
8
  * + overlay props inline with per-scene TTS regen.
9
9
  */
10
- import { execFile } from 'node:child_process';
10
+ import { execFile, spawnSync } from 'node:child_process';
11
11
  import { createServer } from 'node:http';
12
12
  import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync } from 'node:fs';
13
13
  import { dirname, extname, join, relative, resolve } from 'node:path';
@@ -21,6 +21,8 @@ import { exportVideo, checkFfmpeg } from './export.js';
21
21
  import { applySpeedRampToTimeline } from './speed-ramp.js';
22
22
  import { shiftCameraMoves, scaleCameraMoves } from './camera-move.js';
23
23
  import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs } from './freeze.js';
24
+ import { buildOverlayPngsForImport, isImportedVideo } from './overlays/render-to-png.js';
25
+ import { detectVideoTheme, getVideoDurationMs } from './media.js';
24
26
  const MIME_TYPES = {
25
27
  '.html': 'text/html',
26
28
  '.js': 'text/javascript',
@@ -36,6 +38,39 @@ function readJsonFile(filePath, fallback) {
36
38
  return fallback;
37
39
  return JSON.parse(readFileSync(filePath, 'utf-8'));
38
40
  }
41
+ function ensureSeekablePreviewProxy(rawVideoPath, proxyPath) {
42
+ try {
43
+ const needsRender = !existsSync(proxyPath) || statSync(proxyPath).mtimeMs < statSync(rawVideoPath).mtimeMs;
44
+ if (!needsRender)
45
+ return proxyPath;
46
+ checkFfmpeg();
47
+ const result = spawnSync('ffmpeg', [
48
+ '-i', rawVideoPath,
49
+ '-map', '0:v:0',
50
+ '-map', '0:a:0?',
51
+ '-c:v', 'libx264',
52
+ '-preset', 'veryfast',
53
+ '-crf', '23',
54
+ '-pix_fmt', 'yuv420p',
55
+ '-movflags', '+faststart',
56
+ '-c:a', 'aac',
57
+ '-b:a', '128k',
58
+ '-y', proxyPath,
59
+ ], {
60
+ stdio: 'pipe',
61
+ });
62
+ if (result.status !== 0) {
63
+ const stderr = result.stderr?.toString('utf-8').trim();
64
+ console.warn(`Warning: failed to create preview MP4 proxy for ${rawVideoPath}${stderr ? `: ${stderr}` : ''}`);
65
+ return null;
66
+ }
67
+ return proxyPath;
68
+ }
69
+ catch (err) {
70
+ console.warn(`Warning: failed to prepare seekable preview video: ${err.message}`);
71
+ return null;
72
+ }
73
+ }
39
74
  function setManifestField(target, key, value) {
40
75
  if (value === undefined || value === null || value === '') {
41
76
  if (key in target) {
@@ -98,12 +133,13 @@ function updatePreviewOverlayEntry(target, overlay) {
98
133
  }
99
134
  return changed;
100
135
  }
101
- function buildRenderedOverlays(overlays) {
136
+ function buildRenderedOverlays(overlays, themeMap) {
102
137
  const renderedOverlays = {};
103
138
  for (const entry of overlays) {
104
139
  const { scene, ...cue } = entry;
105
140
  const zone = cue.placement ?? 'bottom-center';
106
- const { contentHtml, styles } = renderTemplate(cue, 'dark');
141
+ const theme = themeMap?.[scene] ?? 'dark';
142
+ const { contentHtml, styles } = renderTemplate(cue, theme);
107
143
  renderedOverlays[scene] = { html: contentHtml, styles, zone };
108
144
  }
109
145
  return renderedOverlays;
@@ -177,7 +213,32 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
177
213
  const reportPath = join(demoDir, 'scene-report.json');
178
214
  const persistedReport = readJsonFile(reportPath, null);
179
215
  const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
180
- const renderedOverlays = buildRenderedOverlays(overlays);
216
+ // Detect per-scene overlay theme from the video content.
217
+ // Uses ffmpeg to sample frames at each overlay's scene timestamp.
218
+ // Find video file — prefer original extension (imported videos) over .webm
219
+ const videoExts = ['.mp4', '.mov', '.mkv', '.avi', '.webm'];
220
+ let videoPath = null;
221
+ for (const ext of videoExts) {
222
+ const candidate = join(demoDir, `video${ext}`);
223
+ if (existsSync(candidate)) {
224
+ videoPath = candidate;
225
+ break;
226
+ }
227
+ }
228
+ let overlayThemeMap;
229
+ if (videoPath && overlays.length > 0) {
230
+ overlayThemeMap = {};
231
+ for (const ov of overlays) {
232
+ const sceneMs = timing[ov.scene] ?? 0;
233
+ // Use next scene start as end bound, or scene + 5s as fallback
234
+ const nextSceneMs = Object.values(timing)
235
+ .filter((ms) => ms > sceneMs)
236
+ .sort((a, b) => a - b)[0];
237
+ const endMs = nextSceneMs ?? sceneMs + 5000;
238
+ overlayThemeMap[ov.scene] = detectVideoTheme(videoPath, sceneMs, endMs);
239
+ }
240
+ }
241
+ const renderedOverlays = buildRenderedOverlays(overlays, overlayThemeMap);
181
242
  // Pipeline metadata (reuse meta loaded above for headTrimMs)
182
243
  const pipelineMeta = Object.keys(meta).length > 0 ? meta : null;
183
244
  const hasGenerated = Boolean(activeMusicPath && existsSync(activeMusicPath));
@@ -188,6 +249,27 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
188
249
  include: hasGenerated || hasConfig,
189
250
  volume: exportConfig?.musicVolume ?? 0.15,
190
251
  };
252
+ // Get actual video duration as the timeline floor
253
+ let videoDurationMs = 0;
254
+ const videoExtsForDur = ['.mp4', '.mov', '.mkv', '.avi', '.webm'];
255
+ for (const ext of videoExtsForDur) {
256
+ const candidate = join(demoDir, `video${ext}`);
257
+ if (existsSync(candidate)) {
258
+ try {
259
+ videoDurationMs = getVideoDurationMs(candidate);
260
+ }
261
+ catch { /* ignore */ }
262
+ break;
263
+ }
264
+ }
265
+ // Also check the exported MP4
266
+ const exportedMp4 = join(outputDir, `${demoName}.mp4`);
267
+ if (videoDurationMs === 0 && existsSync(exportedMp4)) {
268
+ try {
269
+ videoDurationMs = getVideoDurationMs(exportedMp4);
270
+ }
271
+ catch { /* ignore */ }
272
+ }
191
273
  return {
192
274
  demoName,
193
275
  timing,
@@ -197,6 +279,8 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
197
279
  sceneDurations,
198
280
  sceneReport,
199
281
  renderedOverlays,
282
+ overlayThemes: overlayThemeMap ?? {},
283
+ videoDurationMs,
200
284
  pipelineMeta,
201
285
  bgm,
202
286
  };
@@ -267,8 +351,8 @@ function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
267
351
  };
268
352
  const clipPath = cache.getClipPath(demoName, cacheEntry);
269
353
  if (!existsSync(clipPath)) {
270
- throw new Error(`Expected regenerated clip for scene "${entry.scene}" at ${clipPath}, but it was not found. ` +
271
- `Try running: argo tts generate ${scenesPath}`);
354
+ // Clip not generated yet (e.g., imported video without TTS run) skip silently
355
+ continue;
272
356
  }
273
357
  const clipInfo = readClipInfo(clipPath, entry.scene);
274
358
  clips.push(clipInfo);
@@ -319,14 +403,48 @@ export async function startPreviewServer(options) {
319
403
  const port = options.port ?? 0; // 0 = auto-assign
320
404
  const demoName = options.demoName;
321
405
  const demoDir = join(argoDir, demoName);
322
- // Prefer exported MP4 (has keyframes for seeking) over raw WebM (no cue points)
323
- const webmPath = join(demoDir, 'video.webm');
324
- const mp4Path = join(outputDir, `${demoName}.mp4`);
325
- let videoPath = existsSync(mp4Path) ? mp4Path : webmPath;
326
- if (!existsSync(videoPath)) {
327
- throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' first.`);
406
+ const importedVideo = isImportedVideo(argoDir, demoName);
407
+ // Raw video path in .argo/<demo>/ — used for duration probing/theme detection and
408
+ // as the source for an optional seekable preview proxy.
409
+ let rawVideoPath = null;
410
+ for (const rawExt of ['.mp4', '.mov', '.mkv', '.avi', '.webm']) {
411
+ const candidate = join(demoDir, `video${rawExt}`);
412
+ if (existsSync(candidate)) {
413
+ rawVideoPath = candidate;
414
+ break;
415
+ }
416
+ }
417
+ // Prefer exported MP4 (has keyframes for seeking), then original-extension import, then raw WebM
418
+ const exportedMp4 = join(outputDir, `${demoName}.mp4`);
419
+ const previewProxyMp4 = join(demoDir, 'preview.mp4');
420
+ const mimeMap = {
421
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
422
+ '.avi': 'video/x-msvideo', '.webm': 'video/webm',
423
+ };
424
+ let videoPath = null;
425
+ if (existsSync(exportedMp4)) {
426
+ videoPath = exportedMp4;
427
+ }
428
+ else if (importedVideo && rawVideoPath) {
429
+ videoPath = ensureSeekablePreviewProxy(rawVideoPath, previewProxyMp4) ?? rawVideoPath;
430
+ }
431
+ else {
432
+ // Find the original-extension video first (correct MIME), fall back to video.webm
433
+ for (const ext of ['.mp4', '.mov', '.mkv', '.avi', '.webm']) {
434
+ const candidate = join(demoDir, `video${ext}`);
435
+ if (existsSync(candidate)) {
436
+ videoPath = candidate;
437
+ break;
438
+ }
439
+ }
328
440
  }
329
- let videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
441
+ if (!videoPath || !existsSync(videoPath)) {
442
+ throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' or 'argo import' first.`);
443
+ }
444
+ const ext = videoPath.slice(videoPath.lastIndexOf('.'));
445
+ let videoMime = mimeMap[ext] ?? 'video/mp4';
446
+ if (!rawVideoPath)
447
+ rawVideoPath = videoPath; // fallback to served video
330
448
  // Track BGM saved from the music generator panel
331
449
  let activeMusicPath;
332
450
  // Check if a previously saved BGM exists
@@ -363,6 +481,22 @@ export async function startPreviewServer(options) {
363
481
  if (existing) {
364
482
  changed = updatePreviewVoiceoverEntry(existing, vo) || changed;
365
483
  }
484
+ else {
485
+ // New scene — always create a manifest entry (even without text)
486
+ const newEntry = { scene: vo.scene };
487
+ if (vo.text?.trim())
488
+ newEntry.text = vo.text;
489
+ if (vo.voice)
490
+ newEntry.voice = vo.voice;
491
+ if (vo.speed)
492
+ newEntry.speed = vo.speed;
493
+ if (vo.lang)
494
+ newEntry.lang = vo.lang;
495
+ if (vo._hint)
496
+ newEntry._hint = vo._hint;
497
+ scenes.push(newEntry);
498
+ changed = true;
499
+ }
366
500
  }
367
501
  if (changed) {
368
502
  writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
@@ -377,7 +511,22 @@ export async function startPreviewServer(options) {
377
511
  for await (const chunk of req)
378
512
  chunks.push(chunk);
379
513
  const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
380
- const renderedOverlays = buildRenderedOverlays(body);
514
+ // Detect per-scene theme from the video for adaptive overlays
515
+ let liveThemeMap;
516
+ if (rawVideoPath && existsSync(rawVideoPath) && body.length > 0) {
517
+ const timingFile = join(demoDir, '.timing.json');
518
+ const liveTiming = existsSync(timingFile)
519
+ ? readJsonFile(timingFile, {}) : {};
520
+ liveThemeMap = {};
521
+ for (const ov of body) {
522
+ const sceneMs = liveTiming[ov.scene] ?? 0;
523
+ const nextMs = Object.values(liveTiming)
524
+ .filter((ms) => ms > sceneMs)
525
+ .sort((a, b) => a - b)[0];
526
+ liveThemeMap[ov.scene] = detectVideoTheme(rawVideoPath, sceneMs, nextMs ?? sceneMs + 5000);
527
+ }
528
+ }
529
+ const renderedOverlays = buildRenderedOverlays(body, liveThemeMap);
381
530
  res.writeHead(200, { 'Content-Type': 'application/json' });
382
531
  res.end(JSON.stringify({ ok: true, renderedOverlays }));
383
532
  return;
@@ -438,6 +587,29 @@ export async function startPreviewServer(options) {
438
587
  res.end(JSON.stringify({ ok: true, changed }));
439
588
  return;
440
589
  }
590
+ // Save timing marks to .timing.json
591
+ if (url === '/api/save-timing' && req.method === 'POST') {
592
+ const chunks = [];
593
+ for await (const chunk of req)
594
+ chunks.push(chunk);
595
+ const { timing } = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
596
+ const timingPath = join(demoDir, '.timing.json');
597
+ // Read existing timing to merge (preserves any extra keys)
598
+ const existing = readJsonFile(timingPath, {});
599
+ // If the pipeline applied head-trimming, shift new timing marks back to raw timeline
600
+ const metaPath = join(outputDir, `${demoName}.meta.json`);
601
+ const meta = existsSync(metaPath) ? readJsonFile(metaPath, {}) : {};
602
+ const headTrimMs = meta?.export?.headTrimMs ?? 0;
603
+ const rawTiming = {};
604
+ for (const [scene, ms] of Object.entries(timing)) {
605
+ rawTiming[scene] = ms + headTrimMs;
606
+ }
607
+ const merged = { ...existing, ...rawTiming };
608
+ writeFileSync(timingPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
609
+ res.writeHead(200, { 'Content-Type': 'application/json' });
610
+ res.end(JSON.stringify({ ok: true }));
611
+ return;
612
+ }
441
613
  // Regenerate a single TTS clip
442
614
  if (url === '/api/regen-clip' && req.method === 'POST') {
443
615
  const chunks = [];
@@ -489,13 +661,21 @@ export async function startPreviewServer(options) {
489
661
  chunks.push(chunk);
490
662
  const bodyText = Buffer.concat(chunks).toString('utf-8').trim();
491
663
  const body = bodyText ? JSON.parse(bodyText) : {};
664
+ // Generate TTS clips for any scenes with text (auto-regen before export)
665
+ const manifestPath = join(demosDir, `${demoName}.scenes.json`);
666
+ try {
667
+ await runPreviewTtsGenerate(manifestPath);
668
+ }
669
+ catch (ttsErr) {
670
+ console.warn(`Warning: TTS generation failed, exporting without voiceover: ${ttsErr.message}`);
671
+ }
492
672
  // Refresh aligned audio from current clips + timing
493
673
  const refreshed = refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, options.ttsDefaults);
494
674
  // Read timing for head-trim + placement computation
495
675
  const timing = readJsonFile(join(demoDir, '.timing.json'), {});
496
676
  const markTimes = Object.values(timing);
497
677
  let headTrimMs = 0;
498
- if (markTimes.length > 0) {
678
+ if (!importedVideo && markTimes.length > 0) {
499
679
  const firstMarkMs = Math.min(...markTimes);
500
680
  headTrimMs = Math.max(0, firstMarkMs - 200);
501
681
  if (headTrimMs <= 500)
@@ -508,7 +688,7 @@ export async function startPreviewServer(options) {
508
688
  // Get video duration
509
689
  const { execFileSync } = await import('node:child_process');
510
690
  const rawDur = execFileSync('ffprobe', [
511
- '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', webmPath,
691
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', rawVideoPath,
512
692
  ], { encoding: 'utf-8' }).trim();
513
693
  const totalDurationMs = Math.round(parseFloat(rawDur) * 1000);
514
694
  const shiftedDurationMs = totalDurationMs - headTrimMs;
@@ -533,7 +713,8 @@ export async function startPreviewServer(options) {
533
713
  ? Math.max(0, Math.min(1, body.musicVolume))
534
714
  : (ec?.musicVolume ?? 0.15);
535
715
  const exportMusicPath = includeBgm ? (activeMusicPath ?? ec?.musicPath) : undefined;
536
- const rampResult = applySpeedRampToTimeline(placements, shiftedDurationMs, ec?.speedRamp);
716
+ const effectiveSpeedRamp = importedVideo ? undefined : ec?.speedRamp;
717
+ const rampResult = applySpeedRampToTimeline(placements, shiftedDurationMs, effectiveSpeedRamp);
537
718
  const finalPlacements = rampResult.placements;
538
719
  const finalDurationMs = rampResult.totalDurationMs;
539
720
  const speedRampSegments = rampResult.segments.length > 0 ? rampResult.segments : undefined;
@@ -583,6 +764,16 @@ export async function startPreviewServer(options) {
583
764
  }
584
765
  }
585
766
  catch { /* optional */ }
767
+ // Render overlay PNGs for imported videos (no Playwright recording step).
768
+ const overlayPngs = await buildOverlayPngsForImport({
769
+ argoDir,
770
+ demoName,
771
+ manifestPath: scenesPath,
772
+ placements: freezeAdjustedPlacements,
773
+ videoWidth: ec?.outputWidth ?? 1920,
774
+ videoHeight: ec?.outputHeight ?? 1080,
775
+ deviceScaleFactor: ec?.deviceScaleFactor,
776
+ });
586
777
  // Export — use full config so output matches argo pipeline
587
778
  await exportVideo({
588
779
  demoName,
@@ -608,10 +799,12 @@ export async function startPreviewServer(options) {
608
799
  cameraMoves,
609
800
  watermark: ec?.watermark,
610
801
  freezeSpecs: previewResolvedFreezes.length > 0 ? previewResolvedFreezes : undefined,
802
+ overlayPngs,
611
803
  });
612
804
  // Switch to serving the new MP4
613
- if (existsSync(mp4Path)) {
614
- videoPath = mp4Path;
805
+ const newMp4 = join(outputDir, `${demoName}.mp4`);
806
+ if (existsSync(newMp4)) {
807
+ videoPath = newMp4;
615
808
  videoMime = 'video/mp4';
616
809
  }
617
810
  res.end(JSON.stringify({ ok: true }));
@@ -1093,6 +1286,47 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1093
1286
  .overlay-cue[data-zone="bottom-right"] { bottom: 60px; right: 40px; }
1094
1287
  .overlay-cue[data-zone="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
1095
1288
 
1289
+ /* Drag-to-snap overlay positioning */
1290
+ .overlay-cue.overlay-draggable {
1291
+ cursor: grab;
1292
+ user-select: none;
1293
+ pointer-events: auto;
1294
+ }
1295
+ .overlay-cue.overlay-draggable:active {
1296
+ cursor: grabbing;
1297
+ }
1298
+ .overlay-cue.overlay-dragging {
1299
+ cursor: grabbing;
1300
+ opacity: 0.85;
1301
+ z-index: 20;
1302
+ }
1303
+ .snap-zone {
1304
+ position: absolute;
1305
+ border: 2px dashed rgba(99, 102, 241, 0.4);
1306
+ border-radius: 8px;
1307
+ pointer-events: none;
1308
+ opacity: 0;
1309
+ transition: opacity 0.2s;
1310
+ z-index: 15;
1311
+ }
1312
+ .snap-zone.visible {
1313
+ opacity: 1;
1314
+ }
1315
+ .snap-zone.highlight {
1316
+ background: rgba(99, 102, 241, 0.15);
1317
+ border-color: rgba(99, 102, 241, 0.8);
1318
+ }
1319
+ .snap-zone-label {
1320
+ position: absolute;
1321
+ bottom: 4px;
1322
+ left: 50%;
1323
+ transform: translateX(-50%);
1324
+ font-size: 11px;
1325
+ color: rgba(99, 102, 241, 0.8);
1326
+ font-family: var(--mono);
1327
+ white-space: nowrap;
1328
+ }
1329
+
1096
1330
  /* Timeline bar */
1097
1331
  .timeline {
1098
1332
  background: var(--surface);
@@ -1433,6 +1667,24 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1433
1667
  border-radius: 4px;
1434
1668
  }
1435
1669
 
1670
+ .add-scene-btn {
1671
+ width: 100%;
1672
+ padding: 10px;
1673
+ margin-bottom: 12px;
1674
+ background: var(--bg-card, var(--surface));
1675
+ border: 2px dashed var(--border);
1676
+ border-radius: 10px;
1677
+ color: var(--text-muted);
1678
+ cursor: pointer;
1679
+ font-family: var(--mono);
1680
+ font-size: 0.85rem;
1681
+ transition: all 0.2s;
1682
+ }
1683
+ .add-scene-btn:hover {
1684
+ border-color: var(--accent);
1685
+ color: var(--accent);
1686
+ }
1687
+
1436
1688
  /* Editable fields */
1437
1689
  .field-group { margin-top: 8px; }
1438
1690
  .field-group label {
@@ -1697,6 +1949,12 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1697
1949
  <div class="video-container">
1698
1950
  <video id="video" src="/video" preload="auto" muted playsinline></video>
1699
1951
  <div class="overlay-layer" id="overlay-layer"></div>
1952
+ <div class="snap-zone" data-zone="top-left" style="top:10%;left:5%;width:35%;height:35%"><span class="snap-zone-label">top-left</span></div>
1953
+ <div class="snap-zone" data-zone="top-right" style="top:10%;right:5%;width:35%;height:35%"><span class="snap-zone-label">top-right</span></div>
1954
+ <div class="snap-zone" data-zone="bottom-left" style="bottom:10%;left:5%;width:35%;height:35%"><span class="snap-zone-label">bottom-left</span></div>
1955
+ <div class="snap-zone" data-zone="bottom-right" style="bottom:10%;right:5%;width:35%;height:35%"><span class="snap-zone-label">bottom-right</span></div>
1956
+ <div class="snap-zone" data-zone="bottom-center" style="bottom:5%;left:25%;width:50%;height:20%"><span class="snap-zone-label">bottom-center</span></div>
1957
+ <div class="snap-zone" data-zone="center" style="top:30%;left:25%;width:50%;height:40%"><span class="snap-zone-label">center</span></div>
1700
1958
  </div>
1701
1959
 
1702
1960
  <div class="timeline">
@@ -1737,6 +1995,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1737
1995
  <button class="sidebar-tab" data-tab="metadata">Metadata</button>
1738
1996
  </div>
1739
1997
  <div class="sidebar-panel" id="panel-scenes">
1998
+ <button id="add-scene-btn" class="add-scene-btn">+ Add scene at current time</button>
1740
1999
  <div id="scene-list"></div>
1741
2000
  <div class="music-panel" id="music-panel">
1742
2001
  <div class="music-panel-header" id="music-panel-header">
@@ -1839,12 +2098,19 @@ const scenes = Object.entries(DATA.timing)
1839
2098
 
1840
2099
  let activeScene = null;
1841
2100
 
1842
- // ─── Timeline ──────────────────────────────────────────────────────────────
1843
- video.addEventListener('loadedmetadata', () => {
1844
- const totalMs = video.duration * 1000;
1845
- document.getElementById('time-total').textContent = formatTime(totalMs);
2101
+ function getPreviewDurationMs() {
2102
+ const mediaDurationMs = Number.isFinite(video.duration) && video.duration > 0
2103
+ ? video.duration * 1000
2104
+ : 0;
2105
+ return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
2106
+ }
2107
+
2108
+ function renderTimelineMarkers() {
2109
+ const totalMs = getPreviewDurationMs();
2110
+ if (!totalMs) return;
2111
+
2112
+ timelineBar.querySelectorAll('.timeline-scene').forEach(node => node.remove());
1846
2113
 
1847
- // Render scene markers on timeline
1848
2114
  scenes.forEach((s, i) => {
1849
2115
  const pct = (s.startMs / totalMs) * 100;
1850
2116
  const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
@@ -1855,22 +2121,37 @@ video.addEventListener('loadedmetadata', () => {
1855
2121
  marker.style.left = pct + '%';
1856
2122
  marker.style.width = Math.max(widthPct, 2) + '%';
1857
2123
  const hasOverlay = DATA.overlays.find(o => o.scene === s.name);
1858
- // s.name is already escaped via esc() — safe for innerHTML
1859
2124
  marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
1860
2125
  marker.dataset.scene = s.name;
1861
2126
  marker.addEventListener('click', (e) => {
1862
2127
  e.stopPropagation();
2128
+ // Don't seek to scene start if user just finished scrubbing
2129
+ if (justScrubbed) return;
1863
2130
  seekToScene(s);
1864
2131
  });
1865
2132
  timelineBar.appendChild(marker);
1866
2133
  });
2134
+ }
2135
+
2136
+ // ─── Timeline ──────────────────────────────────────────────────────────────
2137
+ video.addEventListener('loadedmetadata', () => {
2138
+ const totalMs = getPreviewDurationMs();
2139
+ document.getElementById('time-total').textContent = formatTime(totalMs);
2140
+ renderTimelineMarkers();
1867
2141
 
1868
2142
  // Create overlay DOM elements
1869
2143
  renderOverlayElements();
1870
2144
  });
1871
2145
 
2146
+ if (getPreviewDurationMs() > 0) {
2147
+ document.getElementById('time-total').textContent = formatTime(getPreviewDurationMs());
2148
+ renderTimelineMarkers();
2149
+ }
2150
+
1872
2151
  video.addEventListener('timeupdate', () => {
1873
- const totalMs = video.duration * 1000;
2152
+ // Don't update UI during scrubbing — scrubToX handles it directly
2153
+ if (isScrubbing) return;
2154
+ const totalMs = getPreviewDurationMs();
1874
2155
  const currentMs = video.currentTime * 1000;
1875
2156
  if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
1876
2157
  const stopAt = scenePlaybackEndMs;
@@ -1899,13 +2180,47 @@ video.addEventListener('timeupdate', () => {
1899
2180
  }
1900
2181
  });
1901
2182
 
1902
- // Click on timeline bar to seek
1903
- timelineBar.addEventListener('click', (e) => {
2183
+ // Click and drag on timeline bar to scrub
2184
+ let isScrubbing = false;
2185
+ let justScrubbed = false;
2186
+
2187
+ function scrubToX(clientX) {
1904
2188
  const rect = timelineBar.getBoundingClientRect();
1905
- const pct = (e.clientX - rect.left) / rect.width;
1906
- const seekTime = pct * video.duration;
2189
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
2190
+ const dur = video.duration;
2191
+ if (!dur || !Number.isFinite(dur)) return;
2192
+ const targetSec = pct * dur;
1907
2193
  scenePlaybackEndMs = null;
1908
- void seekAbsoluteMs(seekTime * 1000);
2194
+ // Direct assignment — no async seek during scrubbing
2195
+ video.currentTime = targetSec;
2196
+ // Update UI immediately
2197
+ const ms = targetSec * 1000;
2198
+ document.getElementById('time-current').textContent = formatTime(ms);
2199
+ timelineProgress.style.width = (pct * 100) + '%';
2200
+ document.getElementById('timeline-playhead').style.left = (pct * 100) + '%';
2201
+ }
2202
+
2203
+ timelineBar.addEventListener('mousedown', (e) => {
2204
+ isScrubbing = true;
2205
+ video.pause();
2206
+ showPlayIcon();
2207
+ scrubToX(e.clientX);
2208
+ e.preventDefault();
2209
+ });
2210
+
2211
+ document.addEventListener('mousemove', (e) => {
2212
+ if (!isScrubbing) return;
2213
+ scrubToX(e.clientX);
2214
+ e.preventDefault();
2215
+ });
2216
+
2217
+ document.addEventListener('mouseup', () => {
2218
+ if (isScrubbing) {
2219
+ isScrubbing = false;
2220
+ // Prevent the subsequent click event on scene markers from seeking to scene start
2221
+ justScrubbed = true;
2222
+ setTimeout(() => { justScrubbed = false; }, 50);
2223
+ }
1909
2224
  });
1910
2225
 
1911
2226
  // Play/pause icon toggling
@@ -2005,6 +2320,7 @@ function renderOverlayElements() {
2005
2320
  el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
2006
2321
  Object.assign(el.style, s.rendered.styles);
2007
2322
  overlayLayer.appendChild(el);
2323
+ makeOverlayDraggable(el);
2008
2324
  }
2009
2325
  }
2010
2326
 
@@ -2018,9 +2334,13 @@ function updateOverlayVisibility(currentMs) {
2018
2334
  const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
2019
2335
  if (!el) continue;
2020
2336
 
2021
- // Show overlay only during this scene's own duration (not bleeding into next scene)
2337
+ // Show overlay during this scene's time range.
2338
+ // For scenes without TTS (endMs === startMs), extend to the next scene's start or video end.
2022
2339
  const { startMs, endMs } = getSceneBounds(s);
2023
- const isActive = currentMs >= startMs && currentMs < endMs;
2340
+ const sceneIdx = scenes.indexOf(s);
2341
+ const nextStart = sceneIdx + 1 < scenes.length ? scenes[sceneIdx + 1].startMs : getPreviewDurationMs();
2342
+ const effectiveEnd = endMs > startMs ? endMs : nextStart;
2343
+ const isActive = currentMs >= startMs && currentMs < effectiveEnd;
2024
2344
  el.classList.toggle('visible', isActive);
2025
2345
  }
2026
2346
  }
@@ -2029,6 +2349,165 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
2029
2349
  updateOverlayVisibility(video.currentTime * 1000);
2030
2350
  });
2031
2351
 
2352
+ // ─── Drag-to-snap overlay positioning ──────────────────────────────────────
2353
+ const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
2354
+ const snapZoneEls = document.querySelectorAll('.snap-zone');
2355
+
2356
+ // Zone center positions as fractions of the container
2357
+ const ZONE_CENTERS = {
2358
+ 'top-left': { x: 0.05 + 0.35 / 2, y: 0.10 + 0.35 / 2 },
2359
+ 'top-right': { x: 1 - 0.05 - 0.35 / 2, y: 0.10 + 0.35 / 2 },
2360
+ 'bottom-left': { x: 0.05 + 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
2361
+ 'bottom-right': { x: 1 - 0.05 - 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
2362
+ 'bottom-center': { x: 0.25 + 0.50 / 2, y: 1 - 0.05 - 0.20 / 2 },
2363
+ 'center': { x: 0.25 + 0.50 / 2, y: 0.30 + 0.40 / 2 },
2364
+ };
2365
+
2366
+ let dragState = null;
2367
+ let isOverlayDragging = false;
2368
+
2369
+ function showSnapZones() {
2370
+ snapZoneEls.forEach(el => el.classList.add('visible'));
2371
+ }
2372
+
2373
+ function hideSnapZones() {
2374
+ snapZoneEls.forEach(el => {
2375
+ el.classList.remove('visible', 'highlight');
2376
+ });
2377
+ }
2378
+
2379
+ function highlightNearestZone(fracX, fracY) {
2380
+ let nearest = null;
2381
+ let minDist = Infinity;
2382
+ for (const zone of SNAP_ZONES) {
2383
+ const c = ZONE_CENTERS[zone];
2384
+ const d = Math.hypot(fracX - c.x, fracY - c.y);
2385
+ if (d < minDist) {
2386
+ minDist = d;
2387
+ nearest = zone;
2388
+ }
2389
+ }
2390
+ snapZoneEls.forEach(el => {
2391
+ el.classList.toggle('highlight', el.dataset.zone === nearest);
2392
+ });
2393
+ return nearest;
2394
+ }
2395
+
2396
+ function makeOverlayDraggable(el) {
2397
+ el.classList.add('overlay-draggable');
2398
+
2399
+ el.addEventListener('mousedown', (e) => {
2400
+ // Only primary button
2401
+ if (e.button !== 0) return;
2402
+ e.preventDefault();
2403
+ e.stopPropagation();
2404
+
2405
+ const container = el.closest('.video-container');
2406
+ if (!container) return;
2407
+ const containerRect = container.getBoundingClientRect();
2408
+ const elRect = el.getBoundingClientRect();
2409
+
2410
+ // Store original zone positioning styles so we can restore if needed
2411
+ const sceneName = el.dataset.scene;
2412
+
2413
+ dragState = {
2414
+ el,
2415
+ sceneName,
2416
+ container,
2417
+ containerRect,
2418
+ // Offset from mouse to element top-left
2419
+ offsetX: e.clientX - elRect.left,
2420
+ offsetY: e.clientY - elRect.top,
2421
+ nearestZone: null,
2422
+ };
2423
+
2424
+ // Switch to fixed positioning for free drag
2425
+ isOverlayDragging = true;
2426
+ el.classList.add('overlay-dragging');
2427
+ el.style.position = 'absolute';
2428
+ el.style.left = (elRect.left - containerRect.left) + 'px';
2429
+ el.style.top = (elRect.top - containerRect.top) + 'px';
2430
+ el.style.right = 'auto';
2431
+ el.style.bottom = 'auto';
2432
+ el.style.transform = 'none';
2433
+
2434
+ showSnapZones();
2435
+ });
2436
+ }
2437
+
2438
+ document.addEventListener('mousemove', (e) => {
2439
+ if (!dragState) return;
2440
+ e.preventDefault();
2441
+
2442
+ const { el, container, containerRect, offsetX, offsetY } = dragState;
2443
+ const rect = containerRect;
2444
+
2445
+ const newLeft = e.clientX - rect.left - offsetX;
2446
+ const newTop = e.clientY - rect.top - offsetY;
2447
+
2448
+ el.style.left = newLeft + 'px';
2449
+ el.style.top = newTop + 'px';
2450
+
2451
+ // Calculate overlay center as fraction of container
2452
+ const elRect = el.getBoundingClientRect();
2453
+ const centerX = (elRect.left + elRect.width / 2 - rect.left) / rect.width;
2454
+ const centerY = (elRect.top + elRect.height / 2 - rect.top) / rect.height;
2455
+
2456
+ dragState.nearestZone = highlightNearestZone(centerX, centerY);
2457
+ });
2458
+
2459
+ document.addEventListener('mouseup', (e) => {
2460
+ if (!dragState) return;
2461
+
2462
+ const { el, sceneName, nearestZone } = dragState;
2463
+ const zone = nearestZone || 'bottom-center';
2464
+
2465
+ // Remove drag styles — let CSS zone positioning take over
2466
+ el.classList.remove('overlay-dragging');
2467
+ el.style.position = '';
2468
+ el.style.left = '';
2469
+ el.style.top = '';
2470
+ el.style.right = '';
2471
+ el.style.bottom = '';
2472
+ el.style.transform = '';
2473
+
2474
+ // Update the data-zone attribute so CSS positioning applies
2475
+ el.dataset.zone = zone;
2476
+
2477
+ // Update the placement dropdown in the scene card (no change event — avoids re-rendering all overlays)
2478
+ const placeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-placement"]');
2479
+ if (placeEl) {
2480
+ placeEl.value = zone;
2481
+ }
2482
+
2483
+ // Update the scene's overlay data directly
2484
+ const s = scenes.find(sc => sc.name === sceneName);
2485
+ if (s && s.overlay) {
2486
+ s.overlay.placement = zone;
2487
+ }
2488
+ const dataOverlay = DATA.overlays.find(o => o.scene === sceneName);
2489
+ if (dataOverlay) {
2490
+ dataOverlay.placement = zone;
2491
+ }
2492
+ if (DATA.renderedOverlays[sceneName]) {
2493
+ DATA.renderedOverlays[sceneName].zone = zone;
2494
+ }
2495
+
2496
+ hideSnapZones();
2497
+ markDirty();
2498
+ // Keep isOverlayDragging true until after the wireOverlayListeners debounce (300ms)
2499
+ // would have fired, to prevent any overlay field handler from triggering a re-render
2500
+ setTimeout(() => { isOverlayDragging = false; }, 500);
2501
+
2502
+ // Placement-only drag should not trigger a full overlay preview refresh:
2503
+ // previewOverlays() re-scrapes every scene card from the DOM, which can
2504
+ // pull stale values from other scenes while the user is dragging. The
2505
+ // dragged element already has the new zone applied locally.
2506
+ updateOverlayVisibility(video.currentTime * 1000);
2507
+
2508
+ dragState = null;
2509
+ });
2510
+
2032
2511
  // ─── Scene list (sidebar) ──────────────────────────────────────────────────
2033
2512
  function renderSceneList() {
2034
2513
  sceneList.innerHTML = '';
@@ -2122,8 +2601,66 @@ function renderSceneList() {
2122
2601
  wireOverlayListeners(s.name);
2123
2602
  wireEffectListeners(s.name);
2124
2603
  }
2604
+
2605
+ // Trigger overlay preview after rendering so existing overlays appear on the video
2606
+ previewOverlays();
2125
2607
  }
2126
2608
 
2609
+ // ─── Add Scene button ────────────────────────────────────────────────────
2610
+ document.getElementById('add-scene-btn').addEventListener('click', () => {
2611
+ // Generate a unique scene name
2612
+ let idx = scenes.length + 1;
2613
+ let name = 'scene-' + idx;
2614
+ const existingNames = new Set(scenes.map(s => s.name));
2615
+ while (existingNames.has(name)) {
2616
+ idx++;
2617
+ name = 'scene-' + idx;
2618
+ }
2619
+
2620
+ // Timestamp from current video position
2621
+ const startMs = Math.round(video.currentTime * 1000);
2622
+
2623
+ // Add to timing data
2624
+ DATA.timing[name] = startMs;
2625
+ DATA.sceneDurations[name] = 0;
2626
+ DATA.voiceover.push({ scene: name, text: '' });
2627
+
2628
+ // Insert scene into the sorted array at the right position
2629
+ const newScene = {
2630
+ name,
2631
+ startMs,
2632
+ vo: { scene: name, text: '' },
2633
+ overlay: undefined,
2634
+ effects: [],
2635
+ rendered: undefined,
2636
+ report: undefined,
2637
+ };
2638
+ // Find insertion index to keep sorted by startMs
2639
+ let insertIdx = scenes.length;
2640
+ for (let i = 0; i < scenes.length; i++) {
2641
+ if (scenes[i].startMs > startMs) {
2642
+ insertIdx = i;
2643
+ break;
2644
+ }
2645
+ }
2646
+ scenes.splice(insertIdx, 0, newScene);
2647
+
2648
+ // Capture current form values before re-render wipes the DOM
2649
+ syncFormValuesToScenes();
2650
+
2651
+ // Re-render scene list
2652
+ renderSceneList();
2653
+ snapshotAllScenes();
2654
+ markDirty();
2655
+
2656
+ // Auto-scroll to the new card and expand it
2657
+ const newCard = document.querySelector('.scene-card[data-scene="' + name + '"]');
2658
+ if (newCard) {
2659
+ newCard.classList.add('expanded');
2660
+ newCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
2661
+ }
2662
+ });
2663
+
2127
2664
  function renderDynamicOverlayFields(sceneName, type, ov) {
2128
2665
  if (!type) return '';
2129
2666
  let fields = '';
@@ -2192,6 +2729,13 @@ function renderOverlayFields(s) {
2192
2729
  <option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
2193
2730
  <option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
2194
2731
  </select>
2732
+ </div>
2733
+ <div style="flex:0 0 auto; display:flex; align-items:flex-end; padding-bottom:2px;">
2734
+ <span class="overlay-theme-badge" data-scene="\${esc(s.name)}" title="Auto-detected overlay theme"
2735
+ style="font-size:11px; padding:2px 6px; border-radius:3px;
2736
+ background:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#fff' : '#333'};
2737
+ color:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#333' : '#ccc'};
2738
+ border:1px solid #555;">\${DATA.overlayThemes[s.name] ?? 'dark'}</span>
2195
2739
  </div>\` : ''}
2196
2740
  </div>
2197
2741
  \${type ? \`<div class="field-group">
@@ -2213,6 +2757,10 @@ function updateOverlayFieldsForScene(sceneName) {
2213
2757
  const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
2214
2758
  const type = typeEl?.value ?? '';
2215
2759
  const s = scenes.find(sc => sc.name === sceneName);
2760
+ if (s) {
2761
+ if (!type) s.overlay = undefined;
2762
+ else s.overlay = { ...(s.overlay ?? {}), type };
2763
+ }
2216
2764
  const ov = s?.overlay;
2217
2765
  const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
2218
2766
  if (!container) return;
@@ -2227,6 +2775,8 @@ function updateOverlayFieldsForScene(sceneName) {
2227
2775
  section.outerHTML = renderOverlayFields(fakeScene);
2228
2776
  // Re-wire event listeners for the new overlay fields
2229
2777
  wireOverlayListeners(sceneName);
2778
+ // Trigger preview to show the overlay immediately after type change
2779
+ previewOverlays();
2230
2780
  }
2231
2781
  }
2232
2782
 
@@ -2237,8 +2787,30 @@ function wireOverlayListeners(sceneName) {
2237
2787
  card.querySelectorAll('[data-field^="overlay"]').forEach(input => {
2238
2788
  const handler = () => {
2239
2789
  markDirty();
2790
+ // Skip full re-render during overlay drag — drag only changes placement
2791
+ if (isOverlayDragging) return;
2792
+ // Sync only THIS scene's overlay from DOM on field change
2793
+ syncOverlayFormValuesForScene(sceneName);
2240
2794
  clearTimeout(debounceTimer);
2241
- debounceTimer = setTimeout(() => previewOverlays(), 300);
2795
+ debounceTimer = setTimeout(() => {
2796
+ // Only preview this scene's overlay, not all overlays
2797
+ const singleOv = scenes.find(sc => sc.name === sceneName);
2798
+ if (singleOv?.overlay) {
2799
+ const ov = [{ ...singleOv.overlay, scene: sceneName }];
2800
+ fetch('/api/render-overlays', {
2801
+ method: 'POST',
2802
+ headers: { 'Content-Type': 'application/json' },
2803
+ body: JSON.stringify(ov),
2804
+ }).then(r => r.json()).then(result => {
2805
+ if (result.renderedOverlays?.[sceneName]) {
2806
+ DATA.renderedOverlays[sceneName] = result.renderedOverlays[sceneName];
2807
+ singleOv.rendered = result.renderedOverlays[sceneName];
2808
+ renderOverlayElements();
2809
+ updateOverlayVisibility(video.currentTime * 1000);
2810
+ }
2811
+ });
2812
+ }
2813
+ }, 300);
2242
2814
  };
2243
2815
  input.addEventListener('input', handler);
2244
2816
  input.addEventListener('change', handler);
@@ -2419,6 +2991,18 @@ async function saveEffects() {
2419
2991
  });
2420
2992
  }
2421
2993
 
2994
+ async function saveTiming() {
2995
+ const timing = {};
2996
+ for (const s of scenes) {
2997
+ timing[s.name] = s.startMs;
2998
+ }
2999
+ await fetch('/api/save-timing', {
3000
+ method: 'POST',
3001
+ headers: { 'Content-Type': 'application/json' },
3002
+ body: JSON.stringify({ timing }),
3003
+ });
3004
+ }
3005
+
2422
3006
  function previewEffect(sceneName, index) {
2423
3007
  const s = scenes.find(sc => sc.name === sceneName);
2424
3008
  if (!s?.effects?.[index]) return;
@@ -2556,7 +3140,7 @@ async function seekAbsoluteMs(absoluteMs) {
2556
3140
  return;
2557
3141
  }
2558
3142
 
2559
- const totalMs = video.duration * 1000;
3143
+ const totalMs = getPreviewDurationMs();
2560
3144
  if (totalMs > 0) {
2561
3145
  const pct = (targetMs / totalMs) * 100;
2562
3146
  timelineProgress.style.width = pct + '%';
@@ -2652,8 +3236,9 @@ async function regenClip(sceneName, btn) {
2652
3236
  setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
2653
3237
 
2654
3238
  try {
2655
- // Save current voiceover state first
3239
+ // Save current voiceover + timing state first (new scenes need timing marks)
2656
3240
  await saveVoiceover();
3241
+ await saveTiming();
2657
3242
 
2658
3243
  const resp = await fetch('/api/regen-clip', {
2659
3244
  method: 'POST',
@@ -2688,6 +3273,69 @@ async function regenClip(sceneName, btn) {
2688
3273
  }
2689
3274
  }
2690
3275
 
3276
+ // Sync DOM form values back to in-memory scenes array.
3277
+ // Called before renderSceneList() to preserve user edits.
3278
+ function syncFormValuesToScenes() {
3279
+ for (const s of scenes) {
3280
+ const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
3281
+ if (textEl && textEl.value) {
3282
+ if (!s.vo) s.vo = { scene: s.name, text: '' };
3283
+ s.vo.text = textEl.value;
3284
+ }
3285
+ const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
3286
+ if (voiceEl?.value && s.vo) s.vo.voice = voiceEl.value;
3287
+ const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
3288
+ if (speedEl?.value && s.vo) s.vo.speed = parseFloat(speedEl.value);
3289
+ }
3290
+ syncOverlayFormValuesToScenes();
3291
+ }
3292
+
3293
+ function syncOverlayFormValuesForScene(sceneName) {
3294
+ const s = scenes.find(sc => sc.name === sceneName);
3295
+ if (!s) return;
3296
+ const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
3297
+ if (!card) return;
3298
+
3299
+ const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
3300
+ if (!type) {
3301
+ s.overlay = undefined;
3302
+ return;
3303
+ }
3304
+
3305
+ const next = { ...(s.overlay ?? {}), type };
3306
+ next.placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? 'bottom-center';
3307
+
3308
+ const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? 'none';
3309
+ if (motion && motion !== 'none') next.motion = motion;
3310
+ else delete next.motion;
3311
+
3312
+ delete next.text;
3313
+ delete next.title;
3314
+ delete next.body;
3315
+ delete next.kicker;
3316
+ delete next.src;
3317
+
3318
+ const text = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
3319
+ const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
3320
+ const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
3321
+ const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
3322
+
3323
+ if (type === 'lower-third' || type === 'callout') {
3324
+ next.text = text;
3325
+ } else {
3326
+ next.title = text;
3327
+ if (body) next.body = body;
3328
+ if (type === 'headline-card' && kicker) next.kicker = kicker;
3329
+ if (type === 'image-card' && src) next.src = src;
3330
+ }
3331
+
3332
+ s.overlay = next;
3333
+ }
3334
+
3335
+ function syncOverlayFormValuesToScenes() {
3336
+ for (const s of scenes) syncOverlayFormValuesForScene(s.name);
3337
+ }
3338
+
2691
3339
  function collectVoiceover() {
2692
3340
  return scenes.map(s => {
2693
3341
  const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
@@ -2706,43 +3354,14 @@ function collectVoiceover() {
2706
3354
  }
2707
3355
 
2708
3356
  function collectOverlays() {
3357
+ // Serialize from in-memory scene state only.
3358
+ // Per-scene sync happens in wireOverlayListeners on each field change.
3359
+ // Do NOT call syncOverlayFormValuesToScenes() here — it re-reads ALL
3360
+ // scene cards from the DOM which can overwrite good in-memory data
3361
+ // with stale DOM values from collapsed/unedited cards.
2709
3362
  return scenes
2710
- .map(s => {
2711
- const typeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-type"]');
2712
- const placeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-placement"]');
2713
- const motionEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-motion"]');
2714
- const textEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-text"]');
2715
- const bodyEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-body"]');
2716
- const kickerEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-kicker"]');
2717
- const srcEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-src"]');
2718
- const type = typeEl?.value;
2719
- if (!type) return null;
2720
- const entry = {
2721
- ...(s.overlay ?? {}),
2722
- scene: s.name,
2723
- type,
2724
- placement: placeEl?.value ?? 'bottom-center',
2725
- };
2726
- if (motionEl?.value && motionEl.value !== 'none') entry.motion = motionEl.value;
2727
- else delete entry.motion;
2728
-
2729
- delete entry.text;
2730
- delete entry.title;
2731
- delete entry.body;
2732
- delete entry.kicker;
2733
- delete entry.src;
2734
-
2735
- if (type === 'lower-third' || type === 'callout') {
2736
- entry.text = textEl?.value ?? '';
2737
- } else {
2738
- entry.title = textEl?.value ?? '';
2739
- if (bodyEl?.value) entry.body = bodyEl.value;
2740
- if (type === 'headline-card' && kickerEl?.value) entry.kicker = kickerEl.value;
2741
- if (type === 'image-card' && srcEl?.value) entry.src = srcEl.value;
2742
- }
2743
- return entry;
2744
- })
2745
- .filter(Boolean);
3363
+ .filter(s => s.overlay?.type)
3364
+ .map(s => ({ ...s.overlay, scene: s.name }));
2746
3365
  }
2747
3366
 
2748
3367
  async function saveVoiceover() {
@@ -2942,6 +3561,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2942
3561
  await saveVoiceover();
2943
3562
  await saveOverlays();
2944
3563
  await saveEffects();
3564
+ await saveTiming();
2945
3565
  clearDirty();
2946
3566
  setStatus('All changes saved', 'saved');
2947
3567
  saveBtn.textContent = '\\u2713 Saved';
@@ -2959,13 +3579,12 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2959
3579
 
2960
3580
  // Export button (re-align audio + export MP4, no re-recording)
2961
3581
  document.getElementById('btn-export').addEventListener('click', async () => {
2962
- if (isDirty && !confirm('You have unsaved changes. Save before exporting?')) return;
2963
- if (isDirty) {
2964
- await saveVoiceover();
2965
- await saveOverlays();
2966
- await saveEffects();
2967
- clearDirty();
2968
- }
3582
+ // Always save before export to ensure timing marks + voiceover are persisted
3583
+ await saveVoiceover();
3584
+ await saveOverlays();
3585
+ await saveEffects();
3586
+ await saveTiming();
3587
+ clearDirty();
2969
3588
  const overlay = document.getElementById('recording-overlay');
2970
3589
  const title = document.getElementById('recording-title');
2971
3590
  const subtitle = document.getElementById('recording-subtitle');
@@ -3008,6 +3627,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
3008
3627
  await saveVoiceover();
3009
3628
  await saveOverlays();
3010
3629
  await saveEffects();
3630
+ await saveTiming();
3011
3631
  clearDirty();
3012
3632
  }
3013
3633
  const overlay = document.getElementById('recording-overlay');