@argo-video/cli 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/preview.js CHANGED
@@ -294,6 +294,18 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
294
294
  }
295
295
  catch { /* ignore */ }
296
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
+ }
297
309
  return {
298
310
  demoName,
299
311
  timing,
@@ -307,6 +319,9 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
307
319
  videoDurationMs,
308
320
  pipelineMeta,
309
321
  bgm,
322
+ cameraMoves,
323
+ cursorTelemetry,
324
+ headTrimMs,
310
325
  };
311
326
  }
312
327
  /** List WAV clip files available for a demo. */
@@ -611,6 +626,18 @@ export async function startPreviewServer(options) {
611
626
  res.end(JSON.stringify({ ok: true, changed }));
612
627
  return;
613
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
+ }
614
641
  // Save timing marks to .timing.json
615
642
  if (url === '/api/save-timing' && req.method === 'POST') {
616
643
  const chunks = [];
@@ -1271,6 +1298,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1271
1298
  position: relative;
1272
1299
  background: #000;
1273
1300
  display: flex;
1301
+ overflow: hidden;
1274
1302
  align-items: center;
1275
1303
  justify-content: center;
1276
1304
  min-height: 0;
@@ -1901,6 +1929,25 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1901
1929
  margin-bottom: 6px;
1902
1930
  }
1903
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; }
1904
1951
  .effects-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1905
1952
  .effects-section .section-title {
1906
1953
  font-size: 11px;
@@ -1987,6 +2034,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1987
2034
  <div class="video-container">
1988
2035
  <video id="video" src="/video" preload="auto" muted playsinline></video>
1989
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>
1990
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>
1991
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>
1992
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>
@@ -2023,6 +2071,13 @@ const PREVIEW_HTML = `<!DOCTYPE html>
2023
2071
  </label>
2024
2072
  <span class="toggle-label">Overlays</span>
2025
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>
2026
2081
  </div>
2027
2082
  </div>
2028
2083
  </div>
@@ -2144,11 +2199,17 @@ function getPreviewDurationMs() {
2144
2199
  return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
2145
2200
  }
2146
2201
 
2202
+ function moveEndMs(m) {
2203
+ return m.startMs + m.durationMs + (m.holdMs ?? 0) + m.durationMs;
2204
+ }
2205
+
2147
2206
  function renderTimelineMarkers() {
2148
2207
  const totalMs = getPreviewDurationMs();
2149
2208
  if (!totalMs) return;
2150
2209
 
2151
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());
2152
2213
 
2153
2214
  scenes.forEach((s, i) => {
2154
2215
  const pct = (s.startMs / totalMs) * 100;
@@ -2160,16 +2221,57 @@ function renderTimelineMarkers() {
2160
2221
  marker.style.left = pct + '%';
2161
2222
  marker.style.width = Math.max(widthPct, 2) + '%';
2162
2223
  const hasOverlay = s.overlay?.type;
2163
- 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
+ }
2164
2231
  marker.dataset.scene = s.name;
2165
2232
  marker.addEventListener('click', (e) => {
2166
2233
  e.stopPropagation();
2167
- // Don't seek to scene start if user just finished scrubbing
2168
2234
  if (justScrubbed) return;
2169
2235
  seekToScene(s);
2170
2236
  });
2171
2237
  timelineBar.appendChild(marker);
2172
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
+ });
2173
2275
  }
2174
2276
 
2175
2277
  // ─── Timeline ──────────────────────────────────────────────────────────────
@@ -2217,6 +2319,7 @@ video.addEventListener('timeupdate', () => {
2217
2319
  updateActiveSceneUI();
2218
2320
  updateOverlayVisibility(currentMs);
2219
2321
  }
2322
+ applyCameraTransform(currentMs);
2220
2323
  });
2221
2324
 
2222
2325
  // Click and drag on timeline bar to scrub
@@ -2388,6 +2491,11 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
2388
2491
  updateOverlayVisibility(video.currentTime * 1000);
2389
2492
  });
2390
2493
 
2494
+ document.getElementById('cb-camera').addEventListener('change', () => {
2495
+ cameraPreviewEnabled = document.getElementById('cb-camera').checked;
2496
+ applyCameraTransform(video.currentTime * 1000);
2497
+ });
2498
+
2391
2499
  // ─── Drag-to-snap overlay positioning ──────────────────────────────────────
2392
2500
  const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
2393
2501
  const snapZoneEls = document.querySelectorAll('.snap-zone');
@@ -2576,6 +2684,7 @@ function renderSceneList() {
2576
2684
  </div>
2577
2685
  \${renderOverlayFields(s)}
2578
2686
  \${renderEffectsFields(s)}
2687
+ \${renderCameraMovesFields(s)}
2579
2688
  <div class="btn-row">
2580
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>
2581
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>
@@ -3101,6 +3210,507 @@ async function saveEffects() {
3101
3210
  });
3102
3211
  }
3103
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
+
3104
3714
  async function saveTiming() {
3105
3715
  const timing = {};
3106
3716
  for (const s of scenes) {
@@ -3491,6 +4101,7 @@ function snapshotAllScenes() {
3491
4101
  playbackSpeed: s.playbackSpeed ?? '',
3492
4102
  overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
3493
4103
  effects: s.effects?.length ? JSON.parse(JSON.stringify(s.effects)) : [],
4104
+ cameraMoves: JSON.parse(JSON.stringify(getMovesForScene(s.name))),
3494
4105
  });
3495
4106
  }
3496
4107
  }
@@ -3518,6 +4129,10 @@ function isSceneModified(sceneName) {
3518
4129
  const currentEffects = JSON.stringify(s?.effects ?? []);
3519
4130
  const snapEffects = JSON.stringify(snap.effects ?? []);
3520
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;
3521
4136
  return false;
3522
4137
  }
3523
4138
 
@@ -3553,6 +4168,21 @@ function undoScene(sceneName) {
3553
4168
  s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
3554
4169
  refreshEffectsUI(sceneName);
3555
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
+ }
3556
4186
  // Restore overlay from snapshot into s.overlay (single source of truth)
3557
4187
  if (s) {
3558
4188
  s.overlay = snap.overlay ? JSON.parse(JSON.stringify(snap.overlay)) : undefined;
@@ -3625,6 +4255,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
3625
4255
  await saveVoiceover();
3626
4256
  await saveOverlays();
3627
4257
  await saveEffects();
4258
+ await saveCameraMoves();
3628
4259
  await saveTiming();
3629
4260
  clearDirty();
3630
4261
  setStatus('All changes saved', 'saved');
@@ -3647,6 +4278,7 @@ document.getElementById('btn-export').addEventListener('click', async () => {
3647
4278
  await saveVoiceover();
3648
4279
  await saveOverlays();
3649
4280
  await saveEffects();
4281
+ await saveCameraMoves();
3650
4282
  await saveTiming();
3651
4283
  clearDirty();
3652
4284
  const overlay = document.getElementById('recording-overlay');
@@ -3691,6 +4323,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
3691
4323
  await saveVoiceover();
3692
4324
  await saveOverlays();
3693
4325
  await saveEffects();
4326
+ await saveCameraMoves();
3694
4327
  await saveTiming();
3695
4328
  clearDirty();
3696
4329
  }