@argo-video/cli 0.22.1 → 0.24.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;
328
427
  }
329
- let videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
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
+ }
440
+ }
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,50 @@ 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 only on visible overlays — invisible ones must not intercept clicks */
1294
+ }
1295
+ .overlay-cue.overlay-draggable.visible {
1296
+ pointer-events: auto;
1297
+ }
1298
+ .overlay-cue.overlay-draggable:active {
1299
+ cursor: grabbing;
1300
+ }
1301
+ .overlay-cue.overlay-dragging {
1302
+ cursor: grabbing;
1303
+ opacity: 0.85;
1304
+ z-index: 20;
1305
+ }
1306
+ .snap-zone {
1307
+ position: absolute;
1308
+ border: 2px dashed rgba(99, 102, 241, 0.4);
1309
+ border-radius: 8px;
1310
+ pointer-events: none;
1311
+ opacity: 0;
1312
+ transition: opacity 0.2s;
1313
+ z-index: 15;
1314
+ }
1315
+ .snap-zone.visible {
1316
+ opacity: 1;
1317
+ }
1318
+ .snap-zone.highlight {
1319
+ background: rgba(99, 102, 241, 0.15);
1320
+ border-color: rgba(99, 102, 241, 0.8);
1321
+ }
1322
+ .snap-zone-label {
1323
+ position: absolute;
1324
+ bottom: 4px;
1325
+ left: 50%;
1326
+ transform: translateX(-50%);
1327
+ font-size: 11px;
1328
+ color: rgba(99, 102, 241, 0.8);
1329
+ font-family: var(--mono);
1330
+ white-space: nowrap;
1331
+ }
1332
+
1096
1333
  /* Timeline bar */
1097
1334
  .timeline {
1098
1335
  background: var(--surface);
@@ -1433,6 +1670,24 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1433
1670
  border-radius: 4px;
1434
1671
  }
1435
1672
 
1673
+ .add-scene-btn {
1674
+ width: 100%;
1675
+ padding: 10px;
1676
+ margin-bottom: 12px;
1677
+ background: var(--bg-card, var(--surface));
1678
+ border: 2px dashed var(--border);
1679
+ border-radius: 10px;
1680
+ color: var(--text-muted);
1681
+ cursor: pointer;
1682
+ font-family: var(--mono);
1683
+ font-size: 0.85rem;
1684
+ transition: all 0.2s;
1685
+ }
1686
+ .add-scene-btn:hover {
1687
+ border-color: var(--accent);
1688
+ color: var(--accent);
1689
+ }
1690
+
1436
1691
  /* Editable fields */
1437
1692
  .field-group { margin-top: 8px; }
1438
1693
  .field-group label {
@@ -1697,6 +1952,12 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1697
1952
  <div class="video-container">
1698
1953
  <video id="video" src="/video" preload="auto" muted playsinline></video>
1699
1954
  <div class="overlay-layer" id="overlay-layer"></div>
1955
+ <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>
1956
+ <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>
1957
+ <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>
1958
+ <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>
1959
+ <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>
1960
+ <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
1961
  </div>
1701
1962
 
1702
1963
  <div class="timeline">
@@ -1737,6 +1998,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1737
1998
  <button class="sidebar-tab" data-tab="metadata">Metadata</button>
1738
1999
  </div>
1739
2000
  <div class="sidebar-panel" id="panel-scenes">
2001
+ <button id="add-scene-btn" class="add-scene-btn">+ Add scene at current time</button>
1740
2002
  <div id="scene-list"></div>
1741
2003
  <div class="music-panel" id="music-panel">
1742
2004
  <div class="music-panel-header" id="music-panel-header">
@@ -1839,12 +2101,19 @@ const scenes = Object.entries(DATA.timing)
1839
2101
 
1840
2102
  let activeScene = null;
1841
2103
 
1842
- // ─── Timeline ──────────────────────────────────────────────────────────────
1843
- video.addEventListener('loadedmetadata', () => {
1844
- const totalMs = video.duration * 1000;
1845
- document.getElementById('time-total').textContent = formatTime(totalMs);
2104
+ function getPreviewDurationMs() {
2105
+ const mediaDurationMs = Number.isFinite(video.duration) && video.duration > 0
2106
+ ? video.duration * 1000
2107
+ : 0;
2108
+ return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
2109
+ }
2110
+
2111
+ function renderTimelineMarkers() {
2112
+ const totalMs = getPreviewDurationMs();
2113
+ if (!totalMs) return;
2114
+
2115
+ timelineBar.querySelectorAll('.timeline-scene').forEach(node => node.remove());
1846
2116
 
1847
- // Render scene markers on timeline
1848
2117
  scenes.forEach((s, i) => {
1849
2118
  const pct = (s.startMs / totalMs) * 100;
1850
2119
  const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
@@ -1854,23 +2123,38 @@ video.addEventListener('loadedmetadata', () => {
1854
2123
  marker.className = 'timeline-scene';
1855
2124
  marker.style.left = pct + '%';
1856
2125
  marker.style.width = Math.max(widthPct, 2) + '%';
1857
- const hasOverlay = DATA.overlays.find(o => o.scene === s.name);
1858
- // s.name is already escaped via esc() — safe for innerHTML
2126
+ const hasOverlay = s.overlay?.type;
1859
2127
  marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
1860
2128
  marker.dataset.scene = s.name;
1861
2129
  marker.addEventListener('click', (e) => {
1862
2130
  e.stopPropagation();
2131
+ // Don't seek to scene start if user just finished scrubbing
2132
+ if (justScrubbed) return;
1863
2133
  seekToScene(s);
1864
2134
  });
1865
2135
  timelineBar.appendChild(marker);
1866
2136
  });
2137
+ }
2138
+
2139
+ // ─── Timeline ──────────────────────────────────────────────────────────────
2140
+ video.addEventListener('loadedmetadata', () => {
2141
+ const totalMs = getPreviewDurationMs();
2142
+ document.getElementById('time-total').textContent = formatTime(totalMs);
2143
+ renderTimelineMarkers();
1867
2144
 
1868
2145
  // Create overlay DOM elements
1869
2146
  renderOverlayElements();
1870
2147
  });
1871
2148
 
2149
+ if (getPreviewDurationMs() > 0) {
2150
+ document.getElementById('time-total').textContent = formatTime(getPreviewDurationMs());
2151
+ renderTimelineMarkers();
2152
+ }
2153
+
1872
2154
  video.addEventListener('timeupdate', () => {
1873
- const totalMs = video.duration * 1000;
2155
+ // Don't update UI during scrubbing — scrubToX handles it directly
2156
+ if (isScrubbing) return;
2157
+ const totalMs = getPreviewDurationMs();
1874
2158
  const currentMs = video.currentTime * 1000;
1875
2159
  if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
1876
2160
  const stopAt = scenePlaybackEndMs;
@@ -1899,13 +2183,47 @@ video.addEventListener('timeupdate', () => {
1899
2183
  }
1900
2184
  });
1901
2185
 
1902
- // Click on timeline bar to seek
1903
- timelineBar.addEventListener('click', (e) => {
2186
+ // Click and drag on timeline bar to scrub
2187
+ let isScrubbing = false;
2188
+ let justScrubbed = false;
2189
+
2190
+ function scrubToX(clientX) {
1904
2191
  const rect = timelineBar.getBoundingClientRect();
1905
- const pct = (e.clientX - rect.left) / rect.width;
1906
- const seekTime = pct * video.duration;
2192
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
2193
+ const dur = video.duration;
2194
+ if (!dur || !Number.isFinite(dur)) return;
2195
+ const targetSec = pct * dur;
1907
2196
  scenePlaybackEndMs = null;
1908
- void seekAbsoluteMs(seekTime * 1000);
2197
+ // Direct assignment — no async seek during scrubbing
2198
+ video.currentTime = targetSec;
2199
+ // Update UI immediately
2200
+ const ms = targetSec * 1000;
2201
+ document.getElementById('time-current').textContent = formatTime(ms);
2202
+ timelineProgress.style.width = (pct * 100) + '%';
2203
+ document.getElementById('timeline-playhead').style.left = (pct * 100) + '%';
2204
+ }
2205
+
2206
+ timelineBar.addEventListener('mousedown', (e) => {
2207
+ isScrubbing = true;
2208
+ video.pause();
2209
+ showPlayIcon();
2210
+ scrubToX(e.clientX);
2211
+ e.preventDefault();
2212
+ });
2213
+
2214
+ document.addEventListener('mousemove', (e) => {
2215
+ if (!isScrubbing) return;
2216
+ scrubToX(e.clientX);
2217
+ e.preventDefault();
2218
+ });
2219
+
2220
+ document.addEventListener('mouseup', () => {
2221
+ if (isScrubbing) {
2222
+ isScrubbing = false;
2223
+ // Prevent the subsequent click event on scene markers from seeking to scene start
2224
+ justScrubbed = true;
2225
+ setTimeout(() => { justScrubbed = false; }, 50);
2226
+ }
1909
2227
  });
1910
2228
 
1911
2229
  // Play/pause icon toggling
@@ -2005,6 +2323,7 @@ function renderOverlayElements() {
2005
2323
  el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
2006
2324
  Object.assign(el.style, s.rendered.styles);
2007
2325
  overlayLayer.appendChild(el);
2326
+ makeOverlayDraggable(el);
2008
2327
  }
2009
2328
  }
2010
2329
 
@@ -2018,9 +2337,13 @@ function updateOverlayVisibility(currentMs) {
2018
2337
  const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
2019
2338
  if (!el) continue;
2020
2339
 
2021
- // Show overlay only during this scene's own duration (not bleeding into next scene)
2340
+ // Show overlay during this scene's time range.
2341
+ // For scenes without TTS (endMs === startMs), extend to the next scene's start or video end.
2022
2342
  const { startMs, endMs } = getSceneBounds(s);
2023
- const isActive = currentMs >= startMs && currentMs < endMs;
2343
+ const sceneIdx = scenes.indexOf(s);
2344
+ const nextStart = sceneIdx + 1 < scenes.length ? scenes[sceneIdx + 1].startMs : getPreviewDurationMs();
2345
+ const effectiveEnd = endMs > startMs ? endMs : nextStart;
2346
+ const isActive = currentMs >= startMs && currentMs < effectiveEnd;
2024
2347
  el.classList.toggle('visible', isActive);
2025
2348
  }
2026
2349
  }
@@ -2029,6 +2352,154 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
2029
2352
  updateOverlayVisibility(video.currentTime * 1000);
2030
2353
  });
2031
2354
 
2355
+ // ─── Drag-to-snap overlay positioning ──────────────────────────────────────
2356
+ const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
2357
+ const snapZoneEls = document.querySelectorAll('.snap-zone');
2358
+
2359
+ // Zone center positions as fractions of the container
2360
+ const ZONE_CENTERS = {
2361
+ 'top-left': { x: 0.05 + 0.35 / 2, y: 0.10 + 0.35 / 2 },
2362
+ 'top-right': { x: 1 - 0.05 - 0.35 / 2, y: 0.10 + 0.35 / 2 },
2363
+ 'bottom-left': { x: 0.05 + 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
2364
+ 'bottom-right': { x: 1 - 0.05 - 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
2365
+ 'bottom-center': { x: 0.25 + 0.50 / 2, y: 1 - 0.05 - 0.20 / 2 },
2366
+ 'center': { x: 0.25 + 0.50 / 2, y: 0.30 + 0.40 / 2 },
2367
+ };
2368
+
2369
+ let dragState = null;
2370
+ let isOverlayDragging = false;
2371
+
2372
+ function showSnapZones() {
2373
+ snapZoneEls.forEach(el => el.classList.add('visible'));
2374
+ }
2375
+
2376
+ function hideSnapZones() {
2377
+ snapZoneEls.forEach(el => {
2378
+ el.classList.remove('visible', 'highlight');
2379
+ });
2380
+ }
2381
+
2382
+ function highlightNearestZone(fracX, fracY) {
2383
+ let nearest = null;
2384
+ let minDist = Infinity;
2385
+ for (const zone of SNAP_ZONES) {
2386
+ const c = ZONE_CENTERS[zone];
2387
+ const d = Math.hypot(fracX - c.x, fracY - c.y);
2388
+ if (d < minDist) {
2389
+ minDist = d;
2390
+ nearest = zone;
2391
+ }
2392
+ }
2393
+ snapZoneEls.forEach(el => {
2394
+ el.classList.toggle('highlight', el.dataset.zone === nearest);
2395
+ });
2396
+ return nearest;
2397
+ }
2398
+
2399
+ function makeOverlayDraggable(el) {
2400
+ el.classList.add('overlay-draggable');
2401
+
2402
+ el.addEventListener('mousedown', (e) => {
2403
+ // Only primary button
2404
+ if (e.button !== 0) return;
2405
+ e.preventDefault();
2406
+ e.stopPropagation();
2407
+
2408
+ const container = el.closest('.video-container');
2409
+ if (!container) return;
2410
+ const containerRect = container.getBoundingClientRect();
2411
+ const elRect = el.getBoundingClientRect();
2412
+
2413
+ // Store original zone positioning styles so we can restore if needed
2414
+ const sceneName = el.dataset.scene;
2415
+
2416
+ dragState = {
2417
+ el,
2418
+ sceneName,
2419
+ container,
2420
+ containerRect,
2421
+ // Offset from mouse to element top-left
2422
+ offsetX: e.clientX - elRect.left,
2423
+ offsetY: e.clientY - elRect.top,
2424
+ nearestZone: null,
2425
+ };
2426
+
2427
+ // Switch to fixed positioning for free drag
2428
+ isOverlayDragging = true;
2429
+ el.classList.add('overlay-dragging');
2430
+ el.style.position = 'absolute';
2431
+ el.style.left = (elRect.left - containerRect.left) + 'px';
2432
+ el.style.top = (elRect.top - containerRect.top) + 'px';
2433
+ el.style.right = 'auto';
2434
+ el.style.bottom = 'auto';
2435
+ el.style.transform = 'none';
2436
+
2437
+ showSnapZones();
2438
+ });
2439
+ }
2440
+
2441
+ document.addEventListener('mousemove', (e) => {
2442
+ if (!dragState) return;
2443
+ e.preventDefault();
2444
+
2445
+ const { el, container, containerRect, offsetX, offsetY } = dragState;
2446
+ const rect = containerRect;
2447
+
2448
+ const newLeft = e.clientX - rect.left - offsetX;
2449
+ const newTop = e.clientY - rect.top - offsetY;
2450
+
2451
+ el.style.left = newLeft + 'px';
2452
+ el.style.top = newTop + 'px';
2453
+
2454
+ // Calculate overlay center as fraction of container
2455
+ const elRect = el.getBoundingClientRect();
2456
+ const centerX = (elRect.left + elRect.width / 2 - rect.left) / rect.width;
2457
+ const centerY = (elRect.top + elRect.height / 2 - rect.top) / rect.height;
2458
+
2459
+ dragState.nearestZone = highlightNearestZone(centerX, centerY);
2460
+ });
2461
+
2462
+ document.addEventListener('mouseup', (e) => {
2463
+ if (!dragState) return;
2464
+
2465
+ const { el, sceneName, nearestZone } = dragState;
2466
+ const zone = nearestZone || 'bottom-center';
2467
+
2468
+ // Remove drag styles — let CSS zone positioning take over
2469
+ el.classList.remove('overlay-dragging');
2470
+ el.style.position = '';
2471
+ el.style.left = '';
2472
+ el.style.top = '';
2473
+ el.style.right = '';
2474
+ el.style.bottom = '';
2475
+ el.style.transform = '';
2476
+
2477
+ // Update the data-zone attribute so CSS positioning applies
2478
+ el.dataset.zone = zone;
2479
+
2480
+ // Update s.overlay (single source of truth)
2481
+ const s = scenes.find(sc => sc.name === sceneName);
2482
+ if (s && s.overlay) {
2483
+ s.overlay.placement = zone;
2484
+ }
2485
+
2486
+ // Update the placement dropdown for visual consistency
2487
+ const placeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-placement"]');
2488
+ if (placeEl) {
2489
+ placeEl.value = zone;
2490
+ }
2491
+
2492
+ // Update rendered data so renderOverlayElements stays in sync
2493
+ if (DATA.renderedOverlays[sceneName]) {
2494
+ DATA.renderedOverlays[sceneName].zone = zone;
2495
+ }
2496
+
2497
+ hideSnapZones();
2498
+ markDirty();
2499
+ isOverlayDragging = false;
2500
+ dragState = null;
2501
+ });
2502
+
2032
2503
  // ─── Scene list (sidebar) ──────────────────────────────────────────────────
2033
2504
  function renderSceneList() {
2034
2505
  sceneList.innerHTML = '';
@@ -2122,8 +2593,66 @@ function renderSceneList() {
2122
2593
  wireOverlayListeners(s.name);
2123
2594
  wireEffectListeners(s.name);
2124
2595
  }
2596
+
2597
+ // Trigger overlay preview after rendering so existing overlays appear on the video
2598
+ previewOverlays();
2125
2599
  }
2126
2600
 
2601
+ // ─── Add Scene button ────────────────────────────────────────────────────
2602
+ document.getElementById('add-scene-btn').addEventListener('click', () => {
2603
+ // Generate a unique scene name
2604
+ let idx = scenes.length + 1;
2605
+ let name = 'scene-' + idx;
2606
+ const existingNames = new Set(scenes.map(s => s.name));
2607
+ while (existingNames.has(name)) {
2608
+ idx++;
2609
+ name = 'scene-' + idx;
2610
+ }
2611
+
2612
+ // Timestamp from current video position
2613
+ const startMs = Math.round(video.currentTime * 1000);
2614
+
2615
+ // Add to timing data
2616
+ DATA.timing[name] = startMs;
2617
+ DATA.sceneDurations[name] = 0;
2618
+ DATA.voiceover.push({ scene: name, text: '' });
2619
+
2620
+ // Insert scene into the sorted array at the right position
2621
+ const newScene = {
2622
+ name,
2623
+ startMs,
2624
+ vo: { scene: name, text: '' },
2625
+ overlay: undefined,
2626
+ effects: [],
2627
+ rendered: undefined,
2628
+ report: undefined,
2629
+ };
2630
+ // Find insertion index to keep sorted by startMs
2631
+ let insertIdx = scenes.length;
2632
+ for (let i = 0; i < scenes.length; i++) {
2633
+ if (scenes[i].startMs > startMs) {
2634
+ insertIdx = i;
2635
+ break;
2636
+ }
2637
+ }
2638
+ scenes.splice(insertIdx, 0, newScene);
2639
+
2640
+ // Capture current form values before re-render wipes the DOM
2641
+ syncFormValuesToScenes();
2642
+
2643
+ // Re-render scene list
2644
+ renderSceneList();
2645
+ snapshotAllScenes();
2646
+ markDirty();
2647
+
2648
+ // Auto-scroll to the new card and expand it
2649
+ const newCard = document.querySelector('.scene-card[data-scene="' + name + '"]');
2650
+ if (newCard) {
2651
+ newCard.classList.add('expanded');
2652
+ newCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
2653
+ }
2654
+ });
2655
+
2127
2656
  function renderDynamicOverlayFields(sceneName, type, ov) {
2128
2657
  if (!type) return '';
2129
2658
  let fields = '';
@@ -2192,6 +2721,13 @@ function renderOverlayFields(s) {
2192
2721
  <option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
2193
2722
  <option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
2194
2723
  </select>
2724
+ </div>
2725
+ <div style="flex:0 0 auto; display:flex; align-items:flex-end; padding-bottom:2px;">
2726
+ <span class="overlay-theme-badge" data-scene="\${esc(s.name)}" title="Auto-detected overlay theme"
2727
+ style="font-size:11px; padding:2px 6px; border-radius:3px;
2728
+ background:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#fff' : '#333'};
2729
+ color:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#333' : '#ccc'};
2730
+ border:1px solid #555;">\${DATA.overlayThemes[s.name] ?? 'dark'}</span>
2195
2731
  </div>\` : ''}
2196
2732
  </div>
2197
2733
  \${type ? \`<div class="field-group">
@@ -2213,6 +2749,10 @@ function updateOverlayFieldsForScene(sceneName) {
2213
2749
  const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
2214
2750
  const type = typeEl?.value ?? '';
2215
2751
  const s = scenes.find(sc => sc.name === sceneName);
2752
+ if (s) {
2753
+ if (!type) s.overlay = undefined;
2754
+ else s.overlay = { ...(s.overlay ?? {}), type };
2755
+ }
2216
2756
  const ov = s?.overlay;
2217
2757
  const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
2218
2758
  if (!container) return;
@@ -2227,6 +2767,8 @@ function updateOverlayFieldsForScene(sceneName) {
2227
2767
  section.outerHTML = renderOverlayFields(fakeScene);
2228
2768
  // Re-wire event listeners for the new overlay fields
2229
2769
  wireOverlayListeners(sceneName);
2770
+ // Trigger preview to show the overlay immediately after type change
2771
+ renderSingleSceneOverlay(sceneName);
2230
2772
  }
2231
2773
  }
2232
2774
 
@@ -2234,19 +2776,80 @@ function wireOverlayListeners(sceneName) {
2234
2776
  const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
2235
2777
  if (!card) return;
2236
2778
  let debounceTimer;
2237
- card.querySelectorAll('[data-field^="overlay"]').forEach(input => {
2779
+
2780
+ // Type change — special: re-renders the dynamic fields
2781
+ const typeSelect = card.querySelector('select[data-field="overlay-type"]');
2782
+ if (typeSelect) {
2783
+ typeSelect.addEventListener('change', () => {
2784
+ const s = scenes.find(sc => sc.name === sceneName);
2785
+ if (!s) return;
2786
+ const type = typeSelect.value;
2787
+ if (!type) {
2788
+ s.overlay = undefined;
2789
+ } else {
2790
+ s.overlay = { ...(s.overlay ?? {}), type };
2791
+ }
2792
+ updateOverlayFieldsForScene(sceneName);
2793
+ });
2794
+ }
2795
+
2796
+ // All other overlay fields — update s.overlay directly
2797
+ card.querySelectorAll('[data-field^="overlay-"]').forEach(input => {
2798
+ const field = input.dataset.field;
2799
+ if (field === 'overlay-type') return; // handled above
2800
+
2238
2801
  const handler = () => {
2802
+ const s = scenes.find(sc => sc.name === sceneName);
2803
+ if (!s || !s.overlay) return;
2804
+
2805
+ // Map field name to overlay property
2806
+ if (field === 'overlay-placement') s.overlay.placement = input.value;
2807
+ else if (field === 'overlay-motion') {
2808
+ if (input.value && input.value !== 'none') s.overlay.motion = input.value;
2809
+ else delete s.overlay.motion;
2810
+ }
2811
+ else if (field === 'overlay-text') {
2812
+ if (s.overlay.type === 'lower-third' || s.overlay.type === 'callout') {
2813
+ s.overlay.text = input.value;
2814
+ } else {
2815
+ s.overlay.title = input.value;
2816
+ }
2817
+ }
2818
+ else if (field === 'overlay-body') s.overlay.body = input.value || undefined;
2819
+ else if (field === 'overlay-kicker') s.overlay.kicker = input.value || undefined;
2820
+ else if (field === 'overlay-src') s.overlay.src = input.value || undefined;
2821
+
2239
2822
  markDirty();
2823
+
2824
+ // Skip render during drag
2825
+ if (isOverlayDragging) return;
2826
+
2827
+ // Debounce per-scene render
2240
2828
  clearTimeout(debounceTimer);
2241
- debounceTimer = setTimeout(() => previewOverlays(), 300);
2829
+ debounceTimer = setTimeout(() => renderSingleSceneOverlay(sceneName), 300);
2242
2830
  };
2831
+
2243
2832
  input.addEventListener('input', handler);
2244
2833
  input.addEventListener('change', handler);
2245
2834
  });
2246
- // Re-wire the type change listener
2247
- const typeSelect = card.querySelector('select[data-field="overlay-type"]');
2248
- if (typeSelect) {
2249
- typeSelect.addEventListener('change', () => updateOverlayFieldsForScene(sceneName));
2835
+ }
2836
+
2837
+ async function renderSingleSceneOverlay(sceneName) {
2838
+ const s = scenes.find(sc => sc.name === sceneName);
2839
+ if (!s?.overlay?.type) return;
2840
+
2841
+ const ov = [{ ...s.overlay, scene: sceneName }];
2842
+ const resp = await fetch('/api/render-overlays', {
2843
+ method: 'POST',
2844
+ headers: { 'Content-Type': 'application/json' },
2845
+ body: JSON.stringify(ov),
2846
+ });
2847
+ const result = await resp.json();
2848
+ if (result.renderedOverlays?.[sceneName]) {
2849
+ DATA.renderedOverlays[sceneName] = result.renderedOverlays[sceneName];
2850
+ s.rendered = result.renderedOverlays[sceneName];
2851
+ renderOverlayElements();
2852
+ updateOverlayVisibility(video.currentTime * 1000);
2250
2853
  }
2251
2854
  }
2252
2855
 
@@ -2419,6 +3022,18 @@ async function saveEffects() {
2419
3022
  });
2420
3023
  }
2421
3024
 
3025
+ async function saveTiming() {
3026
+ const timing = {};
3027
+ for (const s of scenes) {
3028
+ timing[s.name] = s.startMs;
3029
+ }
3030
+ await fetch('/api/save-timing', {
3031
+ method: 'POST',
3032
+ headers: { 'Content-Type': 'application/json' },
3033
+ body: JSON.stringify({ timing }),
3034
+ });
3035
+ }
3036
+
2422
3037
  function previewEffect(sceneName, index) {
2423
3038
  const s = scenes.find(sc => sc.name === sceneName);
2424
3039
  if (!s?.effects?.[index]) return;
@@ -2556,7 +3171,7 @@ async function seekAbsoluteMs(absoluteMs) {
2556
3171
  return;
2557
3172
  }
2558
3173
 
2559
- const totalMs = video.duration * 1000;
3174
+ const totalMs = getPreviewDurationMs();
2560
3175
  if (totalMs > 0) {
2561
3176
  const pct = (targetMs / totalMs) * 100;
2562
3177
  timelineProgress.style.width = pct + '%';
@@ -2652,8 +3267,9 @@ async function regenClip(sceneName, btn) {
2652
3267
  setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
2653
3268
 
2654
3269
  try {
2655
- // Save current voiceover state first
3270
+ // Save current voiceover + timing state first (new scenes need timing marks)
2656
3271
  await saveVoiceover();
3272
+ await saveTiming();
2657
3273
 
2658
3274
  const resp = await fetch('/api/regen-clip', {
2659
3275
  method: 'POST',
@@ -2688,6 +3304,23 @@ async function regenClip(sceneName, btn) {
2688
3304
  }
2689
3305
  }
2690
3306
 
3307
+ // Sync DOM form values back to in-memory scenes array.
3308
+ // Called before renderSceneList() to preserve user edits.
3309
+ function syncFormValuesToScenes() {
3310
+ for (const s of scenes) {
3311
+ const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
3312
+ if (textEl && textEl.value) {
3313
+ if (!s.vo) s.vo = { scene: s.name, text: '' };
3314
+ s.vo.text = textEl.value;
3315
+ }
3316
+ const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
3317
+ if (voiceEl?.value && s.vo) s.vo.voice = voiceEl.value;
3318
+ const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
3319
+ if (speedEl?.value && s.vo) s.vo.speed = parseFloat(speedEl.value);
3320
+ }
3321
+ }
3322
+
3323
+
2691
3324
  function collectVoiceover() {
2692
3325
  return scenes.map(s => {
2693
3326
  const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
@@ -2706,43 +3339,10 @@ function collectVoiceover() {
2706
3339
  }
2707
3340
 
2708
3341
  function collectOverlays() {
3342
+ // Serialize from s.overlay (single source of truth) — no DOM reading
2709
3343
  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);
3344
+ .filter(s => s.overlay?.type)
3345
+ .map(s => ({ ...s.overlay, scene: s.name }));
2746
3346
  }
2747
3347
 
2748
3348
  async function saveVoiceover() {
@@ -2754,7 +3354,7 @@ async function saveVoiceover() {
2754
3354
  });
2755
3355
  }
2756
3356
 
2757
- // Render-only preview (no disk write) — called on every overlay field edit
3357
+ // Render-only preview (no disk write) — called on overlay field edits
2758
3358
  async function previewOverlays() {
2759
3359
  const ov = collectOverlays();
2760
3360
  const resp = await fetch('/api/render-overlays', {
@@ -2765,9 +3365,7 @@ async function previewOverlays() {
2765
3365
  const result = await resp.json();
2766
3366
  if (result.renderedOverlays) {
2767
3367
  DATA.renderedOverlays = result.renderedOverlays;
2768
- DATA.overlays = ov;
2769
3368
  for (const s of scenes) {
2770
- s.overlay = DATA.overlays.find(o => o.scene === s.name);
2771
3369
  s.rendered = DATA.renderedOverlays[s.name];
2772
3370
  }
2773
3371
  renderOverlayElements();
@@ -2786,9 +3384,7 @@ async function saveOverlays() {
2786
3384
  const result = await resp.json();
2787
3385
  if (result.renderedOverlays) {
2788
3386
  DATA.renderedOverlays = result.renderedOverlays;
2789
- DATA.overlays = ov;
2790
3387
  for (const s of scenes) {
2791
- s.overlay = DATA.overlays.find(o => o.scene === s.name);
2792
3388
  s.rendered = DATA.renderedOverlays[s.name];
2793
3389
  }
2794
3390
  renderOverlayElements();
@@ -2824,28 +3420,12 @@ function isSceneModified(sceneName) {
2824
3420
  const voice = card.querySelector('[data-field="voice"]')?.value ?? '';
2825
3421
  const speed = card.querySelector('[data-field="speed"]')?.value ?? '';
2826
3422
  if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed)) return true;
2827
- // Check overlay fields
2828
- const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
2829
- const snapType = snap.overlay?.type ?? '';
2830
- if (type !== snapType) return true;
2831
- if (type) {
2832
- const placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? '';
2833
- const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? '';
2834
- const overlayText = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
2835
- const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
2836
- const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
2837
- const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
2838
- const so = snap.overlay || {};
2839
- if (placement !== (so.placement ?? 'bottom-center')) return true;
2840
- if (motion !== (so.motion ?? 'none')) return true;
2841
- const snapText = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
2842
- if (overlayText !== snapText) return true;
2843
- if (body !== (so.body ?? '')) return true;
2844
- if (kicker !== (so.kicker ?? '')) return true;
2845
- if (src !== (so.src ?? '')) return true;
2846
- }
2847
- // Check effects
3423
+ // Check overlay and effects from s.overlay / s.effects (single source of truth)
2848
3424
  const s = scenes.find(sc => sc.name === sceneName);
3425
+ const currentOverlay = s?.overlay;
3426
+ const snapOverlay = snap.overlay;
3427
+ if (JSON.stringify(currentOverlay ?? null) !== JSON.stringify(snapOverlay ?? null)) return true;
3428
+ // Check effects
2849
3429
  const currentEffects = JSON.stringify(s?.effects ?? []);
2850
3430
  const snapEffects = JSON.stringify(snap.effects ?? []);
2851
3431
  if (currentEffects !== snapEffects) return true;
@@ -2882,13 +3462,17 @@ function undoScene(sceneName) {
2882
3462
  s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
2883
3463
  refreshEffectsUI(sceneName);
2884
3464
  }
2885
- // Restore overlay type (triggers field re-render)
3465
+ // Restore overlay from snapshot into s.overlay (single source of truth)
3466
+ if (s) {
3467
+ s.overlay = snap.overlay ? JSON.parse(JSON.stringify(snap.overlay)) : undefined;
3468
+ }
3469
+ // Restore overlay type (triggers field re-render via updateOverlayFieldsForScene)
2886
3470
  const typeEl = card.querySelector('[data-field="overlay-type"]');
2887
3471
  if (typeEl) {
2888
3472
  typeEl.value = snap.overlay?.type ?? '';
2889
3473
  updateOverlayFieldsForScene(sceneName);
2890
3474
  }
2891
- // Restore overlay field values after re-render
3475
+ // Restore overlay field values in DOM after re-render
2892
3476
  setTimeout(() => {
2893
3477
  const so = snap.overlay || {};
2894
3478
  const textField = card.querySelector('[data-field="overlay-text"]');
@@ -2905,8 +3489,8 @@ function undoScene(sceneName) {
2905
3489
  if (placementField) placementField.value = so.placement ?? 'bottom-center';
2906
3490
  const motionField = card.querySelector('[data-field="overlay-motion"]');
2907
3491
  if (motionField) motionField.value = so.motion ?? 'none';
2908
- // Re-render overlay preview
2909
- previewOverlays();
3492
+ // Re-render overlay preview for this scene only
3493
+ renderSingleSceneOverlay(sceneName);
2910
3494
  updateUndoButton(sceneName);
2911
3495
  // Check if all scenes are back to saved state
2912
3496
  const anyModified = scenes.some(s => isSceneModified(s.name));
@@ -2942,6 +3526,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2942
3526
  await saveVoiceover();
2943
3527
  await saveOverlays();
2944
3528
  await saveEffects();
3529
+ await saveTiming();
2945
3530
  clearDirty();
2946
3531
  setStatus('All changes saved', 'saved');
2947
3532
  saveBtn.textContent = '\\u2713 Saved';
@@ -2959,13 +3544,12 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2959
3544
 
2960
3545
  // Export button (re-align audio + export MP4, no re-recording)
2961
3546
  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
- }
3547
+ // Always save before export to ensure timing marks + voiceover are persisted
3548
+ await saveVoiceover();
3549
+ await saveOverlays();
3550
+ await saveEffects();
3551
+ await saveTiming();
3552
+ clearDirty();
2969
3553
  const overlay = document.getElementById('recording-overlay');
2970
3554
  const title = document.getElementById('recording-title');
2971
3555
  const subtitle = document.getElementById('recording-subtitle');
@@ -3008,6 +3592,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
3008
3592
  await saveVoiceover();
3009
3593
  await saveOverlays();
3010
3594
  await saveEffects();
3595
+ await saveTiming();
3011
3596
  clearDirty();
3012
3597
  }
3013
3598
  const overlay = document.getElementById('recording-overlay');