@argo-video/cli 0.26.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/camera.d.ts +10 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +34 -0
- package/dist/camera.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/narration.d.ts +15 -0
- package/dist/narration.d.ts.map +1 -1
- package/dist/narration.js +22 -0
- package/dist/narration.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +635 -2
- package/dist/preview.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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">▶</button><button class="btn" onclick="pausePreview()" title="Pause">▮▮</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">×</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">×</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
|
}
|