@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/README.md +1 -1
- package/dist/camera-move.d.ts +9 -5
- package/dist/camera-move.d.ts.map +1 -1
- package/dist/camera-move.js +126 -48
- package/dist/camera-move.js.map +1 -1
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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 +725 -4
- package/dist/preview.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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">▶</button><button class="btn" onclick="pausePreview()" title="Pause">▮▮</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">×</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
|
+
|
|
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
|
-
|
|
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
|
}
|