Fix Ken Burns zoom preview with clipped viewport layering.
Match ffmpeg zoompan by scaling inside a fixed overflow-hidden frame instead of transforming the letterboxed video element directly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f26a234a8e
commit
4b4ac1b002
@@ -261,18 +261,35 @@
|
||||
}
|
||||
|
||||
// ── video pool management ─────────────────────────────────────────────────────
|
||||
function _clearFxStyles(v) {
|
||||
if (!v) return;
|
||||
v.style.filter = "";
|
||||
const fx = v._pmFx;
|
||||
if (fx) {
|
||||
fx.zoom.style.transform = "";
|
||||
fx.viewport.style.opacity = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureVideo(url) {
|
||||
let v = pool.get(url);
|
||||
if (v) return v;
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "pm-fx-viewport";
|
||||
const zoom = document.createElement("div");
|
||||
zoom.className = "pm-fx-zoom";
|
||||
v = document.createElement("video");
|
||||
v.className = "pm-video"; v.preload = "auto"; v.playsInline = true; v.src = url;
|
||||
v._pmFx = { viewport, zoom };
|
||||
zoom.append(v);
|
||||
viewport.append(zoom);
|
||||
v.addEventListener("loadedmetadata", () => {
|
||||
if (v !== _active) return;
|
||||
if (_seekPending != null) { v.currentTime = _clampT(v, _seekPending); _seekPending = null; }
|
||||
if (_playPending) { _playPending = false; v.play().catch(() => {}); }
|
||||
});
|
||||
v.addEventListener("ended", () => { if (v === _active) _advance(); });
|
||||
stage.append(v); pool.set(url, v);
|
||||
stage.append(viewport); pool.set(url, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -281,14 +298,19 @@
|
||||
urls.forEach((u) => _ensureVideo(u));
|
||||
for (const [u, v] of [...pool]) {
|
||||
if (urls.has(u)) continue;
|
||||
v.pause(); v.remove(); pool.delete(u);
|
||||
v.pause();
|
||||
v._pmFx?.viewport.remove();
|
||||
pool.delete(u);
|
||||
if (_active === v) { _active = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function _resetPool() {
|
||||
_stopTick();
|
||||
for (const [, v] of pool) { v.pause(); v.remove(); }
|
||||
for (const [, v] of pool) {
|
||||
v.pause();
|
||||
v._pmFx?.viewport.remove();
|
||||
}
|
||||
pool.clear(); _active = null; _currentClip = null; _seekPending = null; _playPending = false;
|
||||
_pauseInsAudio();
|
||||
for (const a of _insPool.values()) a.removeAttribute("src");
|
||||
@@ -299,19 +321,25 @@
|
||||
function _setActive(v) {
|
||||
if (_active === v) return;
|
||||
if (_active) {
|
||||
_active.pause(); _active.classList.remove("active");
|
||||
_active.style.filter = ""; _active.style.transform = ""; _active.style.opacity = ""; // drop stale fx
|
||||
_active.pause();
|
||||
_active.classList.remove("active");
|
||||
_active._pmFx?.viewport.classList.remove("active");
|
||||
_clearFxStyles(_active);
|
||||
}
|
||||
_active = v;
|
||||
if (v) {
|
||||
v.classList.add("active");
|
||||
v._pmFx?.viewport.classList.add("active");
|
||||
}
|
||||
_active = v; if (v) v.classList.add("active");
|
||||
}
|
||||
|
||||
// Live preview approximation of a clip's video effects (the render does the real thing).
|
||||
// within = seconds into the clip. Blur -> CSS blur; zoom is VIRTUAL — the frame stays the
|
||||
// canvas size and only the content scales (overflow clipped by the canvas), matching the
|
||||
// render's zoompan: in 1.0->1.2 (push in), out 1.2->1.0 (pull back). fade -> opacity.
|
||||
// Computed from the playhead so it tracks scrubbing too.
|
||||
// within = seconds into the clip. Blur -> CSS blur on the video; zoom uses a clipped
|
||||
// viewport + inner scale layer (matches ffmpeg zoompan: fixed frame, centered crop).
|
||||
// fade -> viewport opacity. Computed from the playhead so it tracks scrubbing too.
|
||||
function _applyFx(clip, within) {
|
||||
const v = _active; if (!v) return;
|
||||
const fxLayer = v._pmFx;
|
||||
const fx = (clip && clip.fx) || {};
|
||||
const dur = (clip && clip.durationSec) || 0;
|
||||
const t = dur > 0 ? Math.max(0, Math.min(1, within / dur)) : 0;
|
||||
@@ -320,13 +348,16 @@
|
||||
let scale = 1;
|
||||
if (fx.zoom === "in") scale = 1 + 0.2 * t;
|
||||
else if (fx.zoom === "out") scale = 1.2 - 0.2 * t;
|
||||
v.style.transformOrigin = "center";
|
||||
v.style.transform = scale !== 1 ? `scale(${scale.toFixed(4)})` : "";
|
||||
if (fxLayer?.zoom) {
|
||||
fxLayer.zoom.style.transform = scale !== 1 ? `scale(${scale.toFixed(4)})` : "";
|
||||
}
|
||||
let op = 1;
|
||||
const fi = +fx.fade_in || 0, fo = +fx.fade_out || 0;
|
||||
if (fi > 0 && within < fi) op = Math.max(0, within / fi);
|
||||
if (fo > 0 && dur > 0 && within > dur - fo) op = Math.min(op, Math.max(0, (dur - within) / fo));
|
||||
v.style.opacity = op < 1 ? op.toFixed(3) : "";
|
||||
if (fxLayer?.viewport) {
|
||||
fxLayer.viewport.style.opacity = op < 1 ? op.toFixed(3) : "";
|
||||
}
|
||||
// audio: per-clip original volume, muted when the project drops original audio. Guarded —
|
||||
// a non-finite volume throws on assignment, which (in the rAF tick) would freeze playback.
|
||||
const keepOrig = (S.get().project || {}).keep_original_audio !== false;
|
||||
|
||||
@@ -396,8 +396,10 @@ input[type=number] { font-family: var(--font-mono); }
|
||||
.pm-media-preview-vid { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; position: relative; z-index: 1; cursor: default; background: #000; }
|
||||
.pm-media-preview-label-vid { color: var(--amber-2); border-color: color-mix(in srgb, var(--amber-2) 35%, var(--line)); }
|
||||
.pm-stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 0; }
|
||||
.pm-video { max-width: 100%; max-height: 100%; display: none; object-fit: contain; position: relative; z-index: 0; }
|
||||
.pm-video.active { display: block; }
|
||||
.pm-fx-viewport { position: absolute; inset: 0; display: none; overflow: hidden; }
|
||||
.pm-fx-viewport.active { display: block; }
|
||||
.pm-fx-zoom { width: 100%; height: 100%; transform-origin: center center; will-change: transform; }
|
||||
.pm-video { width: 100%; height: 100%; display: block; object-fit: contain; position: relative; z-index: 0; }
|
||||
.program-empty { text-align: center; color: var(--faint); }
|
||||
.program-empty .reel { font-size: 40px; opacity: .35; margin-bottom: 10px; }
|
||||
.pm-gen-splash { display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--muted); }
|
||||
|
||||
Reference in New Issue
Block a user