@argo-video/cli 0.25.0 → 0.27.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
@@ -91,6 +91,7 @@ function updatePreviewVoiceoverEntry(target, entry) {
91
91
  changed = setManifestField(target, 'speed', entry.speed) || changed;
92
92
  changed = setManifestField(target, 'lang', entry.lang) || changed;
93
93
  changed = setManifestField(target, '_hint', entry._hint) || changed;
94
+ changed = setManifestField(target, 'playbackSpeed', entry.playbackSpeed) || changed;
94
95
  return changed;
95
96
  }
96
97
  function updatePreviewOverlayEntry(target, overlay) {
@@ -123,6 +124,23 @@ function updatePreviewOverlayEntry(target, overlay) {
123
124
  changed = setManifestField(overlayTarget, 'body', undefined) || changed;
124
125
  changed = setManifestField(overlayTarget, 'kicker', undefined) || changed;
125
126
  changed = setManifestField(overlayTarget, 'src', undefined) || changed;
127
+ // Clear arrow fields
128
+ changed = setManifestField(overlayTarget, 'direction', undefined) || changed;
129
+ changed = setManifestField(overlayTarget, 'label', undefined) || changed;
130
+ changed = setManifestField(overlayTarget, 'color', undefined) || changed;
131
+ changed = setManifestField(overlayTarget, 'size', undefined) || changed;
132
+ }
133
+ else if (overlay.type === 'arrow') {
134
+ changed = setManifestField(overlayTarget, 'direction', 'direction' in overlay ? overlay.direction : undefined) || changed;
135
+ changed = setManifestField(overlayTarget, 'label', 'label' in overlay ? overlay.label : undefined) || changed;
136
+ changed = setManifestField(overlayTarget, 'color', 'color' in overlay ? overlay.color : undefined) || changed;
137
+ changed = setManifestField(overlayTarget, 'size', 'size' in overlay ? overlay.size : undefined) || changed;
138
+ // Clear non-arrow fields
139
+ changed = setManifestField(overlayTarget, 'text', undefined) || changed;
140
+ changed = setManifestField(overlayTarget, 'title', undefined) || changed;
141
+ changed = setManifestField(overlayTarget, 'body', undefined) || changed;
142
+ changed = setManifestField(overlayTarget, 'kicker', undefined) || changed;
143
+ changed = setManifestField(overlayTarget, 'src', undefined) || changed;
126
144
  }
127
145
  else {
128
146
  changed = setManifestField(overlayTarget, 'text', undefined) || changed;
@@ -130,6 +148,11 @@ function updatePreviewOverlayEntry(target, overlay) {
130
148
  changed = setManifestField(overlayTarget, 'body', 'body' in overlay ? overlay.body : undefined) || changed;
131
149
  changed = setManifestField(overlayTarget, 'kicker', 'kicker' in overlay ? overlay.kicker : undefined) || changed;
132
150
  changed = setManifestField(overlayTarget, 'src', 'src' in overlay ? overlay.src : undefined) || changed;
151
+ // Clear arrow fields
152
+ changed = setManifestField(overlayTarget, 'direction', undefined) || changed;
153
+ changed = setManifestField(overlayTarget, 'label', undefined) || changed;
154
+ changed = setManifestField(overlayTarget, 'color', undefined) || changed;
155
+ changed = setManifestField(overlayTarget, 'size', undefined) || changed;
133
156
  }
134
157
  return changed;
135
158
  }
@@ -194,6 +217,7 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
194
217
  speed: s.speed,
195
218
  lang: s.lang,
196
219
  _hint: s._hint,
220
+ playbackSpeed: s.playbackSpeed,
197
221
  }));
198
222
  const overlays = scenes
199
223
  .filter((s) => s.overlay)
@@ -270,6 +294,18 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
270
294
  }
271
295
  catch { /* ignore */ }
272
296
  }
297
+ // Load cursor telemetry for dwell-based camera suggestions, shift for head trim
298
+ const cursorTelemetryPath = join(demoDir, '.timing.cursor-telemetry.json');
299
+ const rawCursorTelemetry = readJsonFile(cursorTelemetryPath, []);
300
+ const cursorTelemetry = headTrimMs > 0
301
+ ? rawCursorTelemetry.map(s => ({ ...s, timeMs: s.timeMs - headTrimMs })).filter(s => s.timeMs >= 0)
302
+ : rawCursorTelemetry;
303
+ // Load camera moves from sidecar file, shift for head trim
304
+ const cameraMovesPath = join(demoDir, '.timing.camera-moves.json');
305
+ let cameraMoves = readJsonFile(cameraMovesPath, []);
306
+ if (headTrimMs > 0 && cameraMoves.length > 0) {
307
+ cameraMoves = shiftCameraMoves(cameraMoves, headTrimMs);
308
+ }
273
309
  return {
274
310
  demoName,
275
311
  timing,
@@ -283,6 +319,9 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
283
319
  videoDurationMs,
284
320
  pipelineMeta,
285
321
  bgm,
322
+ cameraMoves,
323
+ cursorTelemetry,
324
+ headTrimMs,
286
325
  };
287
326
  }
288
327
  /** List WAV clip files available for a demo. */
@@ -587,6 +626,18 @@ export async function startPreviewServer(options) {
587
626
  res.end(JSON.stringify({ ok: true, changed }));
588
627
  return;
589
628
  }
629
+ // Save camera moves to .timing.camera-moves.json
630
+ if (url === '/api/camera-moves' && req.method === 'POST') {
631
+ const chunks = [];
632
+ for await (const chunk of req)
633
+ chunks.push(chunk);
634
+ const moves = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
635
+ const cameraMovesPath = join(demoDir, '.timing.camera-moves.json');
636
+ writeFileSync(cameraMovesPath, JSON.stringify(moves, null, 2) + '\n', 'utf-8');
637
+ res.writeHead(200, { 'Content-Type': 'application/json' });
638
+ res.end(JSON.stringify({ ok: true }));
639
+ return;
640
+ }
590
641
  // Save timing marks to .timing.json
591
642
  if (url === '/api/save-timing' && req.method === 'POST') {
592
643
  const chunks = [];
@@ -1247,6 +1298,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1247
1298
  position: relative;
1248
1299
  background: #000;
1249
1300
  display: flex;
1301
+ overflow: hidden;
1250
1302
  align-items: center;
1251
1303
  justify-content: center;
1252
1304
  min-height: 0;
@@ -1877,6 +1929,25 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1877
1929
  margin-bottom: 6px;
1878
1930
  }
1879
1931
 
1932
+ .camera-moves-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1933
+ .camera-moves-section .section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; }
1934
+ .camera-move-entry { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; padding: 6px 8px; background: var(--card); border-radius: 6px; border: 1px solid var(--border); }
1935
+ .camera-move-entry label { font-size: 10px; color: var(--muted); display: block; }
1936
+ .camera-move-entry input { width: 60px; }
1937
+ .camera-move-entry .btn-target { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; }
1938
+ .camera-move-entry .btn-target.active { background: #ef4444; }
1939
+ .timeline-camera-move { position: absolute; bottom: 0; height: 5px; background: rgba(245,158,11,0.4); border-radius: 2px; pointer-events: auto; cursor: pointer; z-index: 2; }
1940
+ .timeline-camera-move:hover { background: rgba(245,158,11,0.7); }
1941
+ .timeline-camera-chain { position: absolute; bottom: 2px; height: 1px; background: rgba(245,158,11,0.6); pointer-events: none; z-index: 1; }
1942
+ .timeline-camera-suggestion { position: absolute; bottom: 0; height: 5px; background: rgba(168,85,247,0.25); border: 1px dashed rgba(168,85,247,0.5); border-radius: 2px; pointer-events: auto; cursor: pointer; z-index: 3; }
1943
+ .timeline-camera-suggestion:hover { background: rgba(168,85,247,0.45); }
1944
+ .suggestion-tooltip { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font-size: 11px; white-space: nowrap; z-index: 20; display: flex; gap: 6px; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
1945
+ .suggestion-tooltip .btn-accept { background: #22c55e; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 11px; }
1946
+ .suggestion-tooltip .btn-dismiss { background: #6b7280; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 11px; }
1947
+ .video-container.target-mode { cursor: crosshair; }
1948
+ .camera-region-overlay { position: absolute; border: 2px solid rgba(245,158,11,0.8); background: rgba(245,158,11,0.08); pointer-events: none; z-index: 5; border-radius: 4px; transition: all 0.15s ease-out; }
1949
+ .camera-region-overlay.target-preview { border-color: rgba(239,68,68,0.8); background: rgba(239,68,68,0.1); }
1950
+ .camera-region-label { position: absolute; top: -18px; left: 0; font-size: 10px; color: rgba(245,158,11,0.9); white-space: nowrap; }
1880
1951
  .effects-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1881
1952
  .effects-section .section-title {
1882
1953
  font-size: 11px;
@@ -1963,6 +2034,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1963
2034
  <div class="video-container">
1964
2035
  <video id="video" src="/video" preload="auto" muted playsinline></video>
1965
2036
  <div class="overlay-layer" id="overlay-layer"></div>
2037
+ <div class="camera-region-overlay" id="camera-region" style="display:none"><span class="camera-region-label"></span></div>
1966
2038
  <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>
1967
2039
  <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>
1968
2040
  <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>
@@ -1999,6 +2071,13 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1999
2071
  </label>
2000
2072
  <span class="toggle-label">Overlays</span>
2001
2073
  </div>
2074
+ <div class="toggle-group">
2075
+ <label class="toggle-switch" title="Camera Preview">
2076
+ <input type="checkbox" id="cb-camera" checked>
2077
+ <span class="slider"></span>
2078
+ </label>
2079
+ <span class="toggle-label">Camera</span>
2080
+ </div>
2002
2081
  </div>
2003
2082
  </div>
2004
2083
  </div>
@@ -2104,6 +2183,7 @@ const scenes = Object.entries(DATA.timing)
2104
2183
  name,
2105
2184
  startMs,
2106
2185
  vo: DATA.voiceover.find(v => v.scene === name),
2186
+ playbackSpeed: DATA.voiceover.find(v => v.scene === name)?.playbackSpeed,
2107
2187
  overlay: DATA.overlays.find(o => o.scene === name),
2108
2188
  effects: DATA.effects[name] ?? [],
2109
2189
  rendered: DATA.renderedOverlays[name],
@@ -2119,11 +2199,17 @@ function getPreviewDurationMs() {
2119
2199
  return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
2120
2200
  }
2121
2201
 
2202
+ function moveEndMs(m) {
2203
+ return m.startMs + m.durationMs + (m.holdMs ?? 0) + m.durationMs;
2204
+ }
2205
+
2122
2206
  function renderTimelineMarkers() {
2123
2207
  const totalMs = getPreviewDurationMs();
2124
2208
  if (!totalMs) return;
2125
2209
 
2126
2210
  timelineBar.querySelectorAll('.timeline-scene').forEach(node => node.remove());
2211
+ timelineBar.querySelectorAll('.timeline-camera-move').forEach(node => node.remove());
2212
+ timelineBar.querySelectorAll('.timeline-camera-chain').forEach(node => node.remove());
2127
2213
 
2128
2214
  scenes.forEach((s, i) => {
2129
2215
  const pct = (s.startMs / totalMs) * 100;
@@ -2135,16 +2221,57 @@ function renderTimelineMarkers() {
2135
2221
  marker.style.left = pct + '%';
2136
2222
  marker.style.width = Math.max(widthPct, 2) + '%';
2137
2223
  const hasOverlay = s.overlay?.type;
2138
- marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
2224
+ // Scene name is validated (alphanumeric + hyphens only) so esc() is safe for textContent
2225
+ marker.textContent = s.name;
2226
+ if (hasOverlay) {
2227
+ const dot = document.createElement('span');
2228
+ dot.className = 'has-overlay';
2229
+ marker.appendChild(dot);
2230
+ }
2139
2231
  marker.dataset.scene = s.name;
2140
2232
  marker.addEventListener('click', (e) => {
2141
2233
  e.stopPropagation();
2142
- // Don't seek to scene start if user just finished scrubbing
2143
2234
  if (justScrubbed) return;
2144
2235
  seekToScene(s);
2145
2236
  });
2146
2237
  timelineBar.appendChild(marker);
2147
2238
  });
2239
+
2240
+ // Camera move markers on timeline
2241
+ const moves = DATA.cameraMoves ?? [];
2242
+ const CHAIN_GAP_MS = 1500;
2243
+ moves.forEach((m, i) => {
2244
+ const scale = m.scale ?? 1.5;
2245
+ if (scale <= 1.0) return;
2246
+ const startPct = (m.startMs / totalMs) * 100;
2247
+ const endMs = moveEndMs(m);
2248
+ const widthPct = ((endMs - m.startMs) / totalMs) * 100;
2249
+ const el = document.createElement('div');
2250
+ el.className = 'timeline-camera-move';
2251
+ el.style.left = startPct + '%';
2252
+ el.style.width = Math.max(widthPct, 0.5) + '%';
2253
+ el.title = 'Camera: ' + (m.scene ?? '') + ' (' + scale.toFixed(1) + 'x)';
2254
+ el.addEventListener('click', (e) => {
2255
+ e.stopPropagation();
2256
+ video.currentTime = m.startMs / 1000;
2257
+ });
2258
+ timelineBar.appendChild(el);
2259
+
2260
+ // Chain indicator between connected moves
2261
+ if (i + 1 < moves.length) {
2262
+ const next = moves[i + 1];
2263
+ const gap = next.startMs - endMs;
2264
+ if (gap >= 0 && gap <= CHAIN_GAP_MS && (next.scale ?? 1.5) > 1.0) {
2265
+ const chainStart = (endMs / totalMs) * 100;
2266
+ const chainWidth = ((next.startMs - endMs) / totalMs) * 100;
2267
+ const chain = document.createElement('div');
2268
+ chain.className = 'timeline-camera-chain';
2269
+ chain.style.left = chainStart + '%';
2270
+ chain.style.width = chainWidth + '%';
2271
+ timelineBar.appendChild(chain);
2272
+ }
2273
+ }
2274
+ });
2148
2275
  }
2149
2276
 
2150
2277
  // ─── Timeline ──────────────────────────────────────────────────────────────
@@ -2192,6 +2319,7 @@ video.addEventListener('timeupdate', () => {
2192
2319
  updateActiveSceneUI();
2193
2320
  updateOverlayVisibility(currentMs);
2194
2321
  }
2322
+ applyCameraTransform(currentMs);
2195
2323
  });
2196
2324
 
2197
2325
  // Click and drag on timeline bar to scrub
@@ -2363,6 +2491,11 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
2363
2491
  updateOverlayVisibility(video.currentTime * 1000);
2364
2492
  });
2365
2493
 
2494
+ document.getElementById('cb-camera').addEventListener('change', () => {
2495
+ cameraPreviewEnabled = document.getElementById('cb-camera').checked;
2496
+ applyCameraTransform(video.currentTime * 1000);
2497
+ });
2498
+
2366
2499
  // ─── Drag-to-snap overlay positioning ──────────────────────────────────────
2367
2500
  const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
2368
2501
  const snapZoneEls = document.querySelectorAll('.snap-zone');
@@ -2544,9 +2677,14 @@ function renderSceneList() {
2544
2677
  <label>Speed</label>
2545
2678
  <input data-field="speed" data-scene="\${esc(s.name)}" type="number" step="0.1" min="0.5" max="2" value="\${s.vo?.speed ?? ''}\" placeholder="1.0">
2546
2679
  </div>
2680
+ <div style="flex:0 0 80px">
2681
+ <label title="Video playback speed (not TTS)">Playback</label>
2682
+ <input data-field="playbackSpeed" data-scene="\${esc(s.name)}" type="number" step="0.25" min="0.25" max="4" value="\${s.playbackSpeed ?? ''}" placeholder="1.0">
2683
+ </div>
2547
2684
  </div>
2548
2685
  \${renderOverlayFields(s)}
2549
2686
  \${renderEffectsFields(s)}
2687
+ \${renderCameraMovesFields(s)}
2550
2688
  <div class="btn-row">
2551
2689
  <button class="btn btn-undo" data-scene="\${esc(s.name)}" onclick="undoScene('\${esc(s.name)}')" style="display:none" title="Revert to last saved state">Undo</button>
2552
2690
  <span class="btn-group"><button class="btn" onclick="previewScene('\${esc(s.name)}')" title="Play this scene">&#9654;</button><button class="btn" onclick="pausePreview()" title="Pause">&#9646;&#9646;</button></span>
@@ -2701,6 +2839,32 @@ function renderDynamicOverlayFields(sceneName, type, ov) {
2701
2839
  <label>Src</label>
2702
2840
  <input data-field="overlay-src" data-scene="\${esc(sceneName)}" value="\${esc(ov?.src ?? '')}" placeholder="assets/example.png">
2703
2841
  </div>\`;
2842
+ } else if (type === 'arrow') {
2843
+ fields += \`
2844
+ <div class="field-group" style="display:flex;gap:8px">
2845
+ <div style="flex:1">
2846
+ <label>Direction</label>
2847
+ <select data-field="overlay-direction" data-scene="\${esc(sceneName)}">
2848
+ \${['up','down','left','right','up-left','up-right','down-left','down-right'].map(d =>
2849
+ \`<option value="\${d}" \${(ov?.direction ?? 'down') === d ? 'selected' : ''}>\${d}</option>\`
2850
+ ).join('')}
2851
+ </select>
2852
+ </div>
2853
+ <div style="flex:1">
2854
+ <label>Color</label>
2855
+ <input type="color" data-field="overlay-color" data-scene="\${esc(sceneName)}" value="\${ov?.color ?? '#ef4444'}">
2856
+ </div>
2857
+ </div>
2858
+ <div class="field-group" style="display:flex;gap:8px">
2859
+ <div style="flex:1">
2860
+ <label>Label</label>
2861
+ <input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.label ?? '')}" placeholder="optional">
2862
+ </div>
2863
+ <div style="flex:0 0 80px">
2864
+ <label>Size</label>
2865
+ <input type="number" data-field="overlay-size" data-scene="\${esc(sceneName)}" value="\${ov?.size ?? 48}" min="16" max="128" step="4">
2866
+ </div>
2867
+ </div>\`;
2704
2868
  }
2705
2869
  return fields;
2706
2870
  }
@@ -2720,6 +2884,7 @@ function renderOverlayFields(s) {
2720
2884
  <option value="headline-card" \${type === 'headline-card' ? 'selected' : ''}>headline-card</option>
2721
2885
  <option value="callout" \${type === 'callout' ? 'selected' : ''}>callout</option>
2722
2886
  <option value="image-card" \${type === 'image-card' ? 'selected' : ''}>image-card</option>
2887
+ <option value="arrow" \${type === 'arrow' ? 'selected' : ''}>arrow</option>
2723
2888
  </select>
2724
2889
  </div>
2725
2890
  \${type ? \`<div style="flex:1">
@@ -2749,6 +2914,9 @@ function renderOverlayFields(s) {
2749
2914
  <option value="slide-in" \${ov?.motion === 'slide-in' ? 'selected' : ''}>slide-in</option>
2750
2915
  </select>
2751
2916
  </div>\` : ''}
2917
+ \${type ? \`<div class="field-group" style="display:flex;align-items:center;gap:8px">
2918
+ <label style="margin:0"><input type="checkbox" data-field="overlay-autoBackground" data-scene="\${esc(s.name)}" \${ov?.autoBackground ? 'checked' : ''}> Auto theme</label>
2919
+ </div>\` : ''}
2752
2920
  <div class="overlay-fields-dynamic" data-scene="\${esc(s.name)}">
2753
2921
  \${renderDynamicOverlayFields(s.name, type, ov)}
2754
2922
  </div>
@@ -2822,6 +2990,8 @@ function wireOverlayListeners(sceneName) {
2822
2990
  else if (field === 'overlay-text') {
2823
2991
  if (s.overlay.type === 'lower-third' || s.overlay.type === 'callout') {
2824
2992
  s.overlay.text = input.value;
2993
+ } else if (s.overlay.type === 'arrow') {
2994
+ s.overlay.label = input.value || undefined;
2825
2995
  } else {
2826
2996
  s.overlay.title = input.value;
2827
2997
  }
@@ -2829,6 +2999,13 @@ function wireOverlayListeners(sceneName) {
2829
2999
  else if (field === 'overlay-body') s.overlay.body = input.value || undefined;
2830
3000
  else if (field === 'overlay-kicker') s.overlay.kicker = input.value || undefined;
2831
3001
  else if (field === 'overlay-src') s.overlay.src = input.value || undefined;
3002
+ else if (field === 'overlay-direction') s.overlay.direction = input.value || undefined;
3003
+ else if (field === 'overlay-color') s.overlay.color = input.value || undefined;
3004
+ else if (field === 'overlay-size') s.overlay.size = input.value ? parseInt(input.value, 10) : undefined;
3005
+ else if (field === 'overlay-autoBackground') {
3006
+ if (input.checked) s.overlay.autoBackground = true;
3007
+ else delete s.overlay.autoBackground;
3008
+ }
2832
3009
 
2833
3010
  markDirty();
2834
3011
 
@@ -3033,6 +3210,507 @@ async function saveEffects() {
3033
3210
  });
3034
3211
  }
3035
3212
 
3213
+ // ─── Camera Moves UI ─────────────────────────────────────────────────────
3214
+
3215
+ function getMovesForScene(sceneName) {
3216
+ const s = scenes.find(sc => sc.name === sceneName);
3217
+ if (!s) return [];
3218
+ const nextScene = scenes[scenes.indexOf(s) + 1];
3219
+ const sceneEnd = nextScene ? nextScene.startMs : getPreviewDurationMs();
3220
+ return (DATA.cameraMoves ?? []).filter(m => m.startMs >= s.startMs && m.startMs < sceneEnd);
3221
+ }
3222
+
3223
+ function renderCameraMovesFields(s) {
3224
+ const moves = getMovesForScene(s.name);
3225
+ const items = moves.map((m, i) => {
3226
+ const globalIdx = (DATA.cameraMoves ?? []).indexOf(m);
3227
+ return \`<div class="camera-move-entry">
3228
+ <div><label>Scale</label><input type="number" data-cm-field="scale" data-cm-idx="\${globalIdx}" step="0.1" min="1" max="5" value="\${m.scale ?? 1.5}"></div>
3229
+ <div><label>Duration</label><input type="number" data-cm-field="durationMs" data-cm-idx="\${globalIdx}" step="100" min="100" value="\${m.durationMs}"></div>
3230
+ <div><label>Hold</label><input type="number" data-cm-field="holdMs" data-cm-idx="\${globalIdx}" step="100" min="0" value="\${m.holdMs ?? 0}"></div>
3231
+ <div><label>Target</label><button class="btn-target" onclick="enterTargetMode(\${globalIdx})" title="Click on video to set zoom target">Set</button></div>
3232
+ <button class="btn btn-sm btn-danger" onclick="removeCameraMove(\${globalIdx})" title="Remove">&times;</button>
3233
+ </div>\`;
3234
+ }).join('');
3235
+
3236
+ return \`<div class="camera-moves-section">
3237
+ <div class="section-title">
3238
+ <span>Camera Moves (\${moves.length})</span>
3239
+ <button class="btn btn-sm" onclick="addCameraMove('\${esc(s.name)}')">+ Add</button>
3240
+ </div>
3241
+ <div class="camera-moves-list" data-scene="\${esc(s.name)}">\${items}</div>
3242
+ </div>\`;
3243
+ }
3244
+
3245
+ function refreshCameraMovesUI(sceneName) {
3246
+ const container = document.querySelector('.camera-moves-list[data-scene="' + sceneName + '"]');
3247
+ if (!container) return;
3248
+ const s = scenes.find(sc => sc.name === sceneName);
3249
+ if (!s) return;
3250
+ const moves = getMovesForScene(sceneName);
3251
+ container.innerHTML = moves.map((m, i) => {
3252
+ const globalIdx = (DATA.cameraMoves ?? []).indexOf(m);
3253
+ return \`<div class="camera-move-entry">
3254
+ <div><label>Scale</label><input type="number" data-cm-field="scale" data-cm-idx="\${globalIdx}" step="0.1" min="1" max="5" value="\${m.scale ?? 1.5}"></div>
3255
+ <div><label>Duration</label><input type="number" data-cm-field="durationMs" data-cm-idx="\${globalIdx}" step="100" min="100" value="\${m.durationMs}"></div>
3256
+ <div><label>Hold</label><input type="number" data-cm-field="holdMs" data-cm-idx="\${globalIdx}" step="100" min="0" value="\${m.holdMs ?? 0}"></div>
3257
+ <div><label>Target</label><button class="btn-target" onclick="enterTargetMode(\${globalIdx})" title="Click on video to set zoom target">Set</button></div>
3258
+ <button class="btn btn-sm btn-danger" onclick="removeCameraMove(\${globalIdx})" title="Remove">&times;</button>
3259
+ </div>\`;
3260
+ }).join('');
3261
+ // Update count in header
3262
+ const section = container.closest('.camera-moves-section');
3263
+ const header = section?.querySelector('.section-title span');
3264
+ if (header) header.textContent = 'Camera Moves (' + moves.length + ')';
3265
+ wireCameraMoveListeners();
3266
+ renderTimelineMarkers();
3267
+ }
3268
+
3269
+ function wireCameraMoveListeners() {
3270
+ document.querySelectorAll('[data-cm-field]').forEach(input => {
3271
+ input.addEventListener('change', () => {
3272
+ const idx = Number(input.dataset.cmIdx);
3273
+ const field = input.dataset.cmField;
3274
+ const move = DATA.cameraMoves?.[idx];
3275
+ if (!move) return;
3276
+ const val = parseFloat(input.value);
3277
+ if (!Number.isFinite(val)) return;
3278
+ if (field === 'scale') move.scale = val;
3279
+ else if (field === 'durationMs') move.durationMs = val;
3280
+ else if (field === 'holdMs') move.holdMs = val;
3281
+ markDirty();
3282
+ renderTimelineMarkers();
3283
+ applyCameraTransform(video.currentTime * 1000);
3284
+ });
3285
+ });
3286
+ }
3287
+
3288
+ function addCameraMove(sceneName) {
3289
+ const s = scenes.find(sc => sc.name === sceneName);
3290
+ if (!s) return;
3291
+ const vw = video.videoWidth || 1920;
3292
+ const vh = video.videoHeight || 1080;
3293
+ if (!DATA.cameraMoves) DATA.cameraMoves = [];
3294
+ DATA.cameraMoves.push({
3295
+ scene: sceneName,
3296
+ startMs: s.startMs + 500,
3297
+ durationMs: 400,
3298
+ x: Math.round(vw / 2),
3299
+ y: Math.round(vh / 2),
3300
+ w: Math.round(vw / 3),
3301
+ h: Math.round(vh / 3),
3302
+ scale: 1.5,
3303
+ holdMs: 1000,
3304
+ });
3305
+ DATA.cameraMoves.sort((a, b) => a.startMs - b.startMs);
3306
+ refreshCameraMovesUI(sceneName);
3307
+ markDirty();
3308
+ applyCameraTransform(video.currentTime * 1000);
3309
+ }
3310
+
3311
+ function removeCameraMove(globalIdx) {
3312
+ const move = DATA.cameraMoves?.[globalIdx];
3313
+ if (!move) return;
3314
+ const sceneName = move.scene ?? scenes.find(s => move.startMs >= s.startMs)?.name;
3315
+ DATA.cameraMoves.splice(globalIdx, 1);
3316
+ if (sceneName) refreshCameraMovesUI(sceneName);
3317
+ markDirty();
3318
+ applyCameraTransform(video.currentTime * 1000);
3319
+ }
3320
+
3321
+ let targetModeIdx = -1;
3322
+
3323
+ function enterTargetMode(globalIdx) {
3324
+ targetModeIdx = globalIdx;
3325
+ document.querySelector('.video-container')?.classList.add('target-mode');
3326
+ document.querySelectorAll('.btn-target').forEach(b => b.classList.remove('active'));
3327
+ const activeBtn = document.querySelector('[onclick="enterTargetMode(' + globalIdx + ')"]');
3328
+ if (activeBtn) activeBtn.classList.add('active');
3329
+ }
3330
+
3331
+ // Drag-to-select zoom region (macOS ⌘⇧4 style)
3332
+ let dragStart = null; // { x, y } in video pixels
3333
+ let isDraggingRegion = false;
3334
+
3335
+ const videoContainer = document.querySelector('.video-container');
3336
+
3337
+ videoContainer?.addEventListener('mousedown', (e) => {
3338
+ if (targetModeIdx < 0) return;
3339
+ e.preventDefault();
3340
+ const rect = video.getBoundingClientRect();
3341
+ const vw = video.videoWidth || 1920;
3342
+ const vh = video.videoHeight || 1080;
3343
+ dragStart = {
3344
+ x: Math.round(((e.clientX - rect.left) / rect.width) * vw),
3345
+ y: Math.round(((e.clientY - rect.top) / rect.height) * vh),
3346
+ };
3347
+ isDraggingRegion = true;
3348
+ const regionEl = document.getElementById('camera-region');
3349
+ if (regionEl) { regionEl.classList.add('target-preview'); regionEl.style.display = 'block'; }
3350
+ });
3351
+
3352
+ videoContainer?.addEventListener('mousemove', (e) => {
3353
+ if (!isDraggingRegion || targetModeIdx < 0 || !dragStart) return;
3354
+ const rect = video.getBoundingClientRect();
3355
+ const vw = video.videoWidth || 1920;
3356
+ const vh = video.videoHeight || 1080;
3357
+ const curX = Math.round(((e.clientX - rect.left) / rect.width) * vw);
3358
+ const curY = Math.round(((e.clientY - rect.top) / rect.height) * vh);
3359
+
3360
+ // Draw the selection rectangle
3361
+ const x1 = Math.max(0, Math.min(dragStart.x, curX));
3362
+ const y1 = Math.max(0, Math.min(dragStart.y, curY));
3363
+ const x2 = Math.min(vw, Math.max(dragStart.x, curX));
3364
+ const y2 = Math.min(vh, Math.max(dragStart.y, curY));
3365
+ const w = Math.max(20, x2 - x1);
3366
+ const h = Math.max(20, y2 - y1);
3367
+
3368
+ const scaleX = rect.width / vw;
3369
+ const scaleY = rect.height / vh;
3370
+ const regionEl = document.getElementById('camera-region');
3371
+ if (regionEl) {
3372
+ const videoOffset = video.offsetLeft || 0;
3373
+ const videoTop = video.offsetTop || 0;
3374
+ regionEl.style.left = (videoOffset + x1 * scaleX) + 'px';
3375
+ regionEl.style.top = (videoTop + y1 * scaleY) + 'px';
3376
+ regionEl.style.width = (w * scaleX) + 'px';
3377
+ regionEl.style.height = (h * scaleY) + 'px';
3378
+ const scale = Math.max(1.1, Math.min(5, vw / w));
3379
+ const label = regionEl.querySelector('.camera-region-label');
3380
+ if (label) label.textContent = scale.toFixed(1) + 'x zoom';
3381
+ }
3382
+ });
3383
+
3384
+ videoContainer?.addEventListener('mouseup', (e) => {
3385
+ if (!isDraggingRegion || targetModeIdx < 0 || !dragStart) return;
3386
+ isDraggingRegion = false;
3387
+ const move = DATA.cameraMoves?.[targetModeIdx];
3388
+ if (!move) { dragStart = null; return; }
3389
+
3390
+ const rect = video.getBoundingClientRect();
3391
+ const vw = video.videoWidth || 1920;
3392
+ const vh = video.videoHeight || 1080;
3393
+ const endX = Math.round(((e.clientX - rect.left) / rect.width) * vw);
3394
+ const endY = Math.round(((e.clientY - rect.top) / rect.height) * vh);
3395
+
3396
+ const x1 = Math.max(0, Math.min(dragStart.x, endX));
3397
+ const y1 = Math.max(0, Math.min(dragStart.y, endY));
3398
+ const x2 = Math.min(vw, Math.max(dragStart.x, endX));
3399
+ const y2 = Math.min(vh, Math.max(dragStart.y, endY));
3400
+ const w = Math.max(50, x2 - x1);
3401
+ const h = Math.max(50, y2 - y1);
3402
+
3403
+ // Set camera move from the drawn region
3404
+ move.x = Math.round(x1 + w / 2); // center x
3405
+ move.y = Math.round(y1 + h / 2); // center y
3406
+ move.w = w;
3407
+ move.h = h;
3408
+ move.scale = Math.max(1.1, Math.min(5, Math.round((vw / w) * 10) / 10));
3409
+
3410
+ // Update the scale input in the sidebar
3411
+ const scaleInput = document.querySelector('[data-cm-field="scale"][data-cm-idx="' + targetModeIdx + '"]');
3412
+ if (scaleInput) scaleInput.value = String(move.scale);
3413
+
3414
+ // Keep region visible
3415
+ const regionEl = document.getElementById('camera-region');
3416
+ if (regionEl) regionEl.classList.remove('target-preview');
3417
+ updateCameraRegionOverlay({ scale: move.scale, x: move.x, y: move.y });
3418
+
3419
+ document.querySelector('.video-container')?.classList.remove('target-mode');
3420
+ document.querySelectorAll('.btn-target').forEach(b => b.classList.remove('active'));
3421
+ targetModeIdx = -1;
3422
+ dragStart = null;
3423
+ markDirty();
3424
+ renderTimelineMarkers();
3425
+ });
3426
+
3427
+ // Cancel drag if mouse released outside video container
3428
+ document.addEventListener('mouseup', () => {
3429
+ if (isDraggingRegion) {
3430
+ isDraggingRegion = false;
3431
+ dragStart = null;
3432
+ if (targetModeIdx >= 0) {
3433
+ document.querySelector('.video-container')?.classList.remove('target-mode');
3434
+ document.querySelectorAll('.btn-target').forEach(b => b.classList.remove('active'));
3435
+ const regionEl = document.getElementById('camera-region');
3436
+ if (regionEl) { regionEl.classList.remove('target-preview'); regionEl.style.display = 'none'; }
3437
+ targetModeIdx = -1;
3438
+ }
3439
+ }
3440
+ });
3441
+
3442
+ async function saveCameraMoves() {
3443
+ // Un-shift camera moves back to original (pre-trim) timeline for storage
3444
+ const trim = DATA.headTrimMs ?? 0;
3445
+ const moves = (DATA.cameraMoves ?? []).map(m => ({
3446
+ ...m,
3447
+ startMs: m.startMs + trim,
3448
+ }));
3449
+ await fetch('/api/camera-moves', {
3450
+ method: 'POST',
3451
+ headers: { 'Content-Type': 'application/json' },
3452
+ body: JSON.stringify(moves),
3453
+ });
3454
+ }
3455
+
3456
+ // ─── Cursor-Dwell Camera Suggestions ────────────────────────────────────
3457
+
3458
+ const DWELL_MOVE_THRESHOLD = 0.02; // 2% of viewport
3459
+ const MIN_DWELL_MS = 450;
3460
+ const MAX_DWELL_MS = 2600;
3461
+ const dismissedSuggestions = new Set();
3462
+
3463
+ function detectDwells(telemetry) {
3464
+ if (!telemetry || telemetry.length < 2) return [];
3465
+ const sorted = [...telemetry].sort((a, b) => a.timeMs - b.timeMs);
3466
+ const dwells = [];
3467
+ let runStart = 0;
3468
+
3469
+ for (let i = 1; i <= sorted.length; i++) {
3470
+ const broke = i === sorted.length ||
3471
+ Math.hypot(sorted[i].cx - sorted[i - 1].cx, sorted[i].cy - sorted[i - 1].cy) > DWELL_MOVE_THRESHOLD;
3472
+ if (broke) {
3473
+ const runLen = i - runStart;
3474
+ if (runLen >= 2) {
3475
+ const duration = sorted[i - 1].timeMs - sorted[runStart].timeMs;
3476
+ if (duration >= MIN_DWELL_MS && duration <= MAX_DWELL_MS) {
3477
+ let sumX = 0, sumY = 0;
3478
+ for (let j = runStart; j < i; j++) {
3479
+ sumX += sorted[j].cx;
3480
+ sumY += sorted[j].cy;
3481
+ }
3482
+ dwells.push({
3483
+ cx: sumX / runLen,
3484
+ cy: sumY / runLen,
3485
+ startMs: sorted[runStart].timeMs,
3486
+ durationMs: duration,
3487
+ id: runStart + '-' + duration,
3488
+ });
3489
+ }
3490
+ }
3491
+ runStart = i;
3492
+ }
3493
+ }
3494
+ return dwells;
3495
+ }
3496
+
3497
+ function renderSuggestionMarkers() {
3498
+ timelineBar.querySelectorAll('.timeline-camera-suggestion').forEach(n => n.remove());
3499
+ const telemetry = DATA.cursorTelemetry ?? [];
3500
+ if (telemetry.length === 0) return;
3501
+
3502
+ const totalMs = getPreviewDurationMs();
3503
+ if (!totalMs) return;
3504
+
3505
+ const dwells = detectDwells(telemetry);
3506
+ const existingMoves = DATA.cameraMoves ?? [];
3507
+
3508
+ for (const dwell of dwells) {
3509
+ if (dismissedSuggestions.has(dwell.id)) continue;
3510
+ // Skip if a camera move already exists near this dwell
3511
+ const hasMove = existingMoves.some(m =>
3512
+ Math.abs(m.startMs - dwell.startMs) < 1000 &&
3513
+ (m.scale ?? 1.5) > 1.0
3514
+ );
3515
+ if (hasMove) continue;
3516
+
3517
+ const pct = (dwell.startMs / totalMs) * 100;
3518
+ const widthPct = (dwell.durationMs / totalMs) * 100;
3519
+ const el = document.createElement('div');
3520
+ el.className = 'timeline-camera-suggestion';
3521
+ el.style.left = pct + '%';
3522
+ el.style.width = Math.max(widthPct, 0.5) + '%';
3523
+ el.title = 'Suggested camera beat (' + (dwell.durationMs / 1000).toFixed(1) + 's dwell)';
3524
+ el.addEventListener('click', (e) => {
3525
+ e.stopPropagation();
3526
+ showSuggestionTooltip(el, dwell);
3527
+ });
3528
+ timelineBar.appendChild(el);
3529
+ }
3530
+ }
3531
+
3532
+ function showSuggestionTooltip(markerEl, dwell) {
3533
+ // Remove existing tooltips
3534
+ document.querySelectorAll('.suggestion-tooltip').forEach(t => t.remove());
3535
+
3536
+ const tooltip = document.createElement('div');
3537
+ tooltip.className = 'suggestion-tooltip';
3538
+
3539
+ const label = document.createElement('span');
3540
+ label.textContent = 'Add camera beat?';
3541
+ tooltip.appendChild(label);
3542
+
3543
+ const acceptBtn = document.createElement('button');
3544
+ acceptBtn.className = 'btn-accept';
3545
+ acceptBtn.textContent = 'Accept';
3546
+ acceptBtn.addEventListener('click', (e) => {
3547
+ e.stopPropagation();
3548
+ acceptSuggestion(dwell);
3549
+ tooltip.remove();
3550
+ markerEl.remove();
3551
+ });
3552
+ tooltip.appendChild(acceptBtn);
3553
+
3554
+ const dismissBtn = document.createElement('button');
3555
+ dismissBtn.className = 'btn-dismiss';
3556
+ dismissBtn.textContent = 'Dismiss';
3557
+ dismissBtn.addEventListener('click', (e) => {
3558
+ e.stopPropagation();
3559
+ dismissedSuggestions.add(dwell.id);
3560
+ tooltip.remove();
3561
+ markerEl.remove();
3562
+ });
3563
+ tooltip.appendChild(dismissBtn);
3564
+
3565
+ markerEl.appendChild(tooltip);
3566
+ }
3567
+
3568
+ function acceptSuggestion(dwell) {
3569
+ const vw = video.videoWidth || 1920;
3570
+ const vh = video.videoHeight || 1080;
3571
+ if (!DATA.cameraMoves) DATA.cameraMoves = [];
3572
+ DATA.cameraMoves.push({
3573
+ startMs: Math.round(dwell.startMs),
3574
+ durationMs: 400,
3575
+ x: Math.round(dwell.cx * vw),
3576
+ y: Math.round(dwell.cy * vh),
3577
+ w: Math.round(vw / 1.5),
3578
+ h: Math.round(vh / 1.5),
3579
+ scale: 1.5,
3580
+ holdMs: Math.round(Math.min(dwell.durationMs, 2000)),
3581
+ });
3582
+ DATA.cameraMoves.sort((a, b) => a.startMs - b.startMs);
3583
+ // Refresh the scene that contains this timestamp
3584
+ const scene = scenes.find((s, i) => {
3585
+ const next = scenes[i + 1];
3586
+ return dwell.startMs >= s.startMs && (!next || dwell.startMs < next.startMs);
3587
+ });
3588
+ if (scene) refreshCameraMovesUI(scene.name);
3589
+ renderTimelineMarkers();
3590
+ renderSuggestionMarkers();
3591
+ markDirty();
3592
+ }
3593
+
3594
+ // Render suggestions on load
3595
+ setTimeout(renderSuggestionMarkers, 500);
3596
+
3597
+ // ─── CSS Transform Camera Preview ───────────────────────────────────────
3598
+
3599
+ function computeCameraTransform(currentMs) {
3600
+ const moves = DATA.cameraMoves ?? [];
3601
+ if (moves.length === 0) return null;
3602
+ const CHAIN_GAP = 1500;
3603
+
3604
+ for (let i = 0; i < moves.length; i++) {
3605
+ const m = moves[i];
3606
+ const scale = m.scale ?? 1.5;
3607
+ if (scale <= 1.0) continue;
3608
+
3609
+ const fadeIn = m.durationMs;
3610
+ const hold = m.holdMs ?? 0;
3611
+ const fadeOut = fadeIn;
3612
+ const start = m.startMs;
3613
+ const zoomInEnd = start + fadeIn;
3614
+ const holdEnd = zoomInEnd + hold;
3615
+ const zoomOutEnd = holdEnd + fadeOut;
3616
+
3617
+ // Check for chained next move
3618
+ const next = moves[i + 1];
3619
+ const nextScale = next?.scale ?? 1.5;
3620
+ const isChained = next && nextScale > 1.0 && (next.startMs - zoomOutEnd) >= 0 && (next.startMs - zoomOutEnd) <= CHAIN_GAP;
3621
+
3622
+ // Zoom in
3623
+ if (currentMs >= start && currentMs < zoomInEnd) {
3624
+ const t = (currentMs - start) / fadeIn;
3625
+ const s = 1 + t * (scale - 1);
3626
+ return { scale: s, x: m.x, y: m.y };
3627
+ }
3628
+ // Hold
3629
+ if (currentMs >= zoomInEnd && currentMs < holdEnd) {
3630
+ return { scale, x: m.x, y: m.y };
3631
+ }
3632
+ // Chained pan or zoom out
3633
+ if (isChained) {
3634
+ const panDur = Math.max(100, Math.min(1000, next.startMs - holdEnd + next.durationMs));
3635
+ const panEnd = holdEnd + panDur;
3636
+ if (currentMs >= holdEnd && currentMs < panEnd) {
3637
+ const t = (currentMs - holdEnd) / panDur;
3638
+ const eased = 1 - Math.pow(1 - t, 3);
3639
+ const s = scale + (nextScale - scale) * eased;
3640
+ const x = m.x + (next.x - m.x) * eased;
3641
+ const y = m.y + (next.y - m.y) * eased;
3642
+ return { scale: s, x, y };
3643
+ }
3644
+ if (currentMs >= panEnd && currentMs < next.startMs + next.durationMs) {
3645
+ return { scale: nextScale, x: next.x, y: next.y };
3646
+ }
3647
+ } else if (currentMs >= holdEnd && currentMs < zoomOutEnd) {
3648
+ const t = 1 - (currentMs - holdEnd) / fadeOut;
3649
+ const s = 1 + t * (scale - 1);
3650
+ return { scale: s, x: m.x, y: m.y };
3651
+ }
3652
+ }
3653
+ return null;
3654
+ }
3655
+
3656
+ let cameraPreviewEnabled = true;
3657
+
3658
+ function updateCameraRegionOverlay(cam) {
3659
+ const regionEl = document.getElementById('camera-region');
3660
+ if (!regionEl) return;
3661
+ if (!cam || cam.scale <= 1.01) {
3662
+ regionEl.style.display = 'none';
3663
+ return;
3664
+ }
3665
+ const rect = video.getBoundingClientRect();
3666
+ const vw = video.videoWidth || 1920;
3667
+ const vh = video.videoHeight || 1080;
3668
+ const scaleX = rect.width / vw;
3669
+ const scaleY = rect.height / vh;
3670
+
3671
+ // Region size: what portion of the frame is visible at this zoom
3672
+ const regionW = vw / cam.scale;
3673
+ const regionH = vh / cam.scale;
3674
+ // Region position: centered on focus point, clamped to frame
3675
+ const regionX = Math.max(0, Math.min(cam.x - regionW / 2, vw - regionW));
3676
+ const regionY = Math.max(0, Math.min(cam.y - regionH / 2, vh - regionH));
3677
+
3678
+ // Convert to CSS pixels relative to video element
3679
+ const videoOffset = video.offsetLeft || 0;
3680
+ const videoTop = video.offsetTop || 0;
3681
+ regionEl.style.display = 'block';
3682
+ regionEl.style.left = (videoOffset + regionX * scaleX) + 'px';
3683
+ regionEl.style.top = (videoTop + regionY * scaleY) + 'px';
3684
+ regionEl.style.width = (regionW * scaleX) + 'px';
3685
+ regionEl.style.height = (regionH * scaleY) + 'px';
3686
+
3687
+ const label = regionEl.querySelector('.camera-region-label');
3688
+ if (label) label.textContent = cam.scale.toFixed(1) + 'x zoom';
3689
+ }
3690
+
3691
+ function applyCameraTransform(currentMs) {
3692
+ const regionEl = document.getElementById('camera-region');
3693
+ if (!cameraPreviewEnabled) {
3694
+ video.style.transform = '';
3695
+ if (regionEl) regionEl.style.display = 'none';
3696
+ return;
3697
+ }
3698
+ const cam = computeCameraTransform(currentMs);
3699
+ if (!cam || cam.scale <= 1.01) {
3700
+ video.style.transform = '';
3701
+ video.style.transformOrigin = '';
3702
+ if (regionEl) regionEl.style.display = 'none';
3703
+ return;
3704
+ }
3705
+ const vw = video.videoWidth || 1920;
3706
+ const vh = video.videoHeight || 1080;
3707
+ const originX = (cam.x / vw) * 100;
3708
+ const originY = (cam.y / vh) * 100;
3709
+ video.style.transformOrigin = originX + '% ' + originY + '%';
3710
+ video.style.transform = 'scale(' + cam.scale.toFixed(3) + ')';
3711
+ updateCameraRegionOverlay(cam);
3712
+ }
3713
+
3036
3714
  async function saveTiming() {
3037
3715
  const timing = {};
3038
3716
  for (const s of scenes) {
@@ -3328,6 +4006,9 @@ function syncFormValuesToScenes() {
3328
4006
  if (voiceEl?.value && s.vo) s.vo.voice = voiceEl.value;
3329
4007
  const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
3330
4008
  if (speedEl?.value && s.vo) s.vo.speed = parseFloat(speedEl.value);
4009
+ const pbSpeedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="playbackSpeed"]');
4010
+ const pbVal = pbSpeedEl?.value ? parseFloat(pbSpeedEl.value) : undefined;
4011
+ s.playbackSpeed = (Number.isFinite(pbVal) && pbVal !== 1.0) ? pbVal : undefined;
3331
4012
  }
3332
4013
  }
3333
4014
 
@@ -3345,6 +4026,11 @@ function collectVoiceover() {
3345
4026
  if (Number.isFinite(speed)) entry.speed = speed;
3346
4027
  else delete entry.speed;
3347
4028
 
4029
+ const pbSpeedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="playbackSpeed"]');
4030
+ const pbSpeed = pbSpeedEl?.value ? parseFloat(pbSpeedEl.value) : undefined;
4031
+ if (Number.isFinite(pbSpeed) && pbSpeed !== 1.0) entry.playbackSpeed = pbSpeed;
4032
+ else delete entry.playbackSpeed;
4033
+
3348
4034
  return entry;
3349
4035
  });
3350
4036
  }
@@ -3412,8 +4098,10 @@ function snapshotAllScenes() {
3412
4098
  text: s.vo?.text ?? '',
3413
4099
  voice: s.vo?.voice ?? '',
3414
4100
  speed: s.vo?.speed ?? '',
4101
+ playbackSpeed: s.playbackSpeed ?? '',
3415
4102
  overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
3416
4103
  effects: s.effects?.length ? JSON.parse(JSON.stringify(s.effects)) : [],
4104
+ cameraMoves: JSON.parse(JSON.stringify(getMovesForScene(s.name))),
3417
4105
  });
3418
4106
  }
3419
4107
  }
@@ -3430,7 +4118,8 @@ function isSceneModified(sceneName) {
3430
4118
  const text = card.querySelector('[data-field="text"]')?.value ?? '';
3431
4119
  const voice = card.querySelector('[data-field="voice"]')?.value ?? '';
3432
4120
  const speed = card.querySelector('[data-field="speed"]')?.value ?? '';
3433
- if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed)) return true;
4121
+ const pbSpeed = card.querySelector('[data-field="playbackSpeed"]')?.value ?? '';
4122
+ if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed) || String(pbSpeed) !== String(snap.playbackSpeed)) return true;
3434
4123
  // Check overlay and effects from s.overlay / s.effects (single source of truth)
3435
4124
  const s = scenes.find(sc => sc.name === sceneName);
3436
4125
  const currentOverlay = s?.overlay;
@@ -3440,6 +4129,10 @@ function isSceneModified(sceneName) {
3440
4129
  const currentEffects = JSON.stringify(s?.effects ?? []);
3441
4130
  const snapEffects = JSON.stringify(snap.effects ?? []);
3442
4131
  if (currentEffects !== snapEffects) return true;
4132
+ // Check camera moves
4133
+ const currentMoves = JSON.stringify(getMovesForScene(sceneName));
4134
+ const snapMoves = JSON.stringify(snap.cameraMoves ?? []);
4135
+ if (currentMoves !== snapMoves) return true;
3443
4136
  return false;
3444
4137
  }
3445
4138
 
@@ -3467,12 +4160,29 @@ function undoScene(sceneName) {
3467
4160
  if (voiceEl) voiceEl.value = snap.voice;
3468
4161
  const speedEl = card.querySelector('[data-field="speed"]');
3469
4162
  if (speedEl) speedEl.value = snap.speed;
4163
+ const pbSpeedEl = card.querySelector('[data-field="playbackSpeed"]');
4164
+ if (pbSpeedEl) pbSpeedEl.value = snap.playbackSpeed;
3470
4165
  // Restore effects
3471
4166
  const s = scenes.find(sc => sc.name === sceneName);
3472
4167
  if (s) {
3473
4168
  s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
3474
4169
  refreshEffectsUI(sceneName);
3475
4170
  }
4171
+ // Restore camera moves
4172
+ if (s && snap.cameraMoves) {
4173
+ // Remove current moves for this scene and replace with snapshot
4174
+ const currentMoves = getMovesForScene(sceneName);
4175
+ for (const m of currentMoves) {
4176
+ const idx = (DATA.cameraMoves ?? []).indexOf(m);
4177
+ if (idx >= 0) DATA.cameraMoves.splice(idx, 1);
4178
+ }
4179
+ const restored = JSON.parse(JSON.stringify(snap.cameraMoves));
4180
+ if (!DATA.cameraMoves) DATA.cameraMoves = [];
4181
+ DATA.cameraMoves.push(...restored);
4182
+ DATA.cameraMoves.sort((a, b) => a.startMs - b.startMs);
4183
+ refreshCameraMovesUI(sceneName);
4184
+ renderTimelineMarkers();
4185
+ }
3476
4186
  // Restore overlay from snapshot into s.overlay (single source of truth)
3477
4187
  if (s) {
3478
4188
  s.overlay = snap.overlay ? JSON.parse(JSON.stringify(snap.overlay)) : undefined;
@@ -3488,7 +4198,7 @@ function undoScene(sceneName) {
3488
4198
  const so = snap.overlay || {};
3489
4199
  const textField = card.querySelector('[data-field="overlay-text"]');
3490
4200
  if (textField) {
3491
- textField.value = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
4201
+ textField.value = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : so.type === 'arrow' ? (so.label ?? '') : (so.title ?? '');
3492
4202
  }
3493
4203
  const bodyField = card.querySelector('[data-field="overlay-body"]');
3494
4204
  if (bodyField) bodyField.value = so.body ?? '';
@@ -3496,6 +4206,14 @@ function undoScene(sceneName) {
3496
4206
  if (kickerField) kickerField.value = so.kicker ?? '';
3497
4207
  const srcField = card.querySelector('[data-field="overlay-src"]');
3498
4208
  if (srcField) srcField.value = so.src ?? '';
4209
+ const dirField = card.querySelector('[data-field="overlay-direction"]');
4210
+ if (dirField) dirField.value = so.direction ?? 'down';
4211
+ const colorField = card.querySelector('[data-field="overlay-color"]');
4212
+ if (colorField) colorField.value = so.color ?? '#ef4444';
4213
+ const sizeField = card.querySelector('[data-field="overlay-size"]');
4214
+ if (sizeField) sizeField.value = String(so.size ?? 48);
4215
+ const autoBgField = card.querySelector('[data-field="overlay-autoBackground"]');
4216
+ if (autoBgField) autoBgField.checked = !!so.autoBackground;
3499
4217
  const placementField = card.querySelector('[data-field="overlay-placement"]');
3500
4218
  if (placementField) placementField.value = so.placement ?? 'bottom-center';
3501
4219
  const motionField = card.querySelector('[data-field="overlay-motion"]');
@@ -3537,6 +4255,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
3537
4255
  await saveVoiceover();
3538
4256
  await saveOverlays();
3539
4257
  await saveEffects();
4258
+ await saveCameraMoves();
3540
4259
  await saveTiming();
3541
4260
  clearDirty();
3542
4261
  setStatus('All changes saved', 'saved');
@@ -3559,6 +4278,7 @@ document.getElementById('btn-export').addEventListener('click', async () => {
3559
4278
  await saveVoiceover();
3560
4279
  await saveOverlays();
3561
4280
  await saveEffects();
4281
+ await saveCameraMoves();
3562
4282
  await saveTiming();
3563
4283
  clearDirty();
3564
4284
  const overlay = document.getElementById('recording-overlay');
@@ -3603,6 +4323,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
3603
4323
  await saveVoiceover();
3604
4324
  await saveOverlays();
3605
4325
  await saveEffects();
4326
+ await saveCameraMoves();
3606
4327
  await saveTiming();
3607
4328
  clearDirty();
3608
4329
  }