Movie Editor: fix render — normalize canvas + virtual zoom (zoompan)

Render failed two ways: clips with different native sizes (768x768 vs 576x768) broke
xfade (needs matching size/fps/sar), and zoom physically rescaled the frame.

- Every clip is now normalized to one canvas (scale+pad to clips[0] size, fixed fps,
  setsar=1) before effects, so xfade/concat always match.
- Zoom is virtual via zoompan: output stays fixed at canvas size, only the content scales
  within it (in 1.0->1.2, out 1.2->1.0). No frame-size change -> renders cleanly.
- Preview matches (zoom-out now pulls back 1.2->1.0); labels updated (push / pull back).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-08 00:44:36 +03:00
co-authored by Claude Opus 4.8
parent 6eab8d9632
commit aeded5cafa
4 changed files with 31 additions and 18 deletions
+1 -1
View File
@@ -177,7 +177,7 @@
body.append(fadeRow);
const zoom = el("select"); zoom.dataset.k = "sc-fx-zoom";
[["none", "None"], ["in", "Zoom in (crop)"], ["out", "Zoom out (pad)"]].forEach(([v, label]) => {
[["none", "None"], ["in", "Zoom in (push)"], ["out", "Zoom out (pull back)"]].forEach(([v, label]) => {
const o = el("option", null, label); o.value = v; if ((fx.zoom || "none") === v) o.selected = true; zoom.append(o);
});
zoom.onchange = () => patchFx("zoom", zoom.value);
+5 -4
View File
@@ -105,9 +105,10 @@
}
// Live preview approximation of a clip's video effects (the render does the real thing).
// within = seconds into the clip. Blur -> CSS blur; zoom in -> scale up (overflow hidden
// by the canvas = crop); zoom out -> scale down (canvas bg shows through = pad); fade ->
// opacity. Computed from the playhead so it tracks scrubbing too.
// 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.
function _applyFx(clip, within) {
const v = _active; if (!v) return;
const fx = (clip && clip.fx) || {};
@@ -117,7 +118,7 @@
v.style.filter = blur > 0 ? `blur(${(blur * 8).toFixed(1)}px)` : "";
let scale = 1;
if (fx.zoom === "in") scale = 1 + 0.2 * t;
else if (fx.zoom === "out") 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)})` : "";
let op = 1;
+2 -2
View File
@@ -235,8 +235,8 @@
// Each writes onto the selected scene, so the inspector's per-scene controls stay the
// single source of truth and reflect whatever is added here.
const FX_DEFS = [
{ id: "zoom_in", label: "Zoom in (crop)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "in" } }) },
{ id: "zoom_out", label: "Zoom out (pad)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "out" } }) },
{ id: "zoom_in", label: "Zoom in (push)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "in" } }) },
{ id: "zoom_out", label: "Zoom out (pull back)", apply: (sc) => ({ effects: { ...(sc.effects || {}), zoom: "out" } }) },
{ id: "blur", label: "Gaussian blur", val: { label: "Strength (0–1)", def: 0.3, min: 0, max: 1, step: 0.05 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), blur: v } }) },
{ id: "fade_in", label: "Fade in", val: { label: "Seconds", def: 0.5, min: 0, max: 10, step: 0.1 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), fade_in: v } }) },
{ id: "fade_out", label: "Fade out", val: { label: "Seconds", def: 0.5, min: 0, max: 10, step: 0.1 }, apply: (sc, v) => ({ effects: { ...(sc.effects || {}), fade_out: v } }) },
+23 -11
View File
@@ -172,21 +172,33 @@ def _build_render_filter(clips: list) -> str:
n = len(clips)
parts: list[str] = []
# One canvas for the whole render (clips may have differing native sizes/fps — xfade and
# concat require identical size/fps/sar, so every clip is normalized to this).
cw = int(clips[0].get("w") or 0) or 768
ch = int(clips[0].get("h") or 0) or 768
cfps = float(clips[0].get("fps") or 0) or 25.0
for i, c in enumerate(clips):
fx = c.get("fx") or {}
dur = float(c.get("dur") or 0) or 0.0
w = int(c.get("w") or 0) or None
h = int(c.get("h") or 0) or None
vf: list[str] = []
# Normalize first: fit into the canvas (letterbox), fixed fps + square pixels.
vf: list[str] = [
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease",
f"pad={cw}:{ch}:-1:-1:color=black",
"setsar=1", f"fps={cfps:g}",
]
zoom = fx.get("zoom")
if zoom in ("in", "out") and dur > 0 and w and h:
# animate the scale across the clip via scale's per-frame eval, relative to box.
if zoom == "in":
vf.append(f"scale=w='{w}*(1+0.20*t/{dur:.3f})':h='{h}*(1+0.20*t/{dur:.3f})':eval=frame")
vf.append(f"crop={w}:{h}:(iw-{w})/2:(ih-{h})/2")
else: # out -> shrink and pad into the box
vf.append(f"scale=w='{w}*(1-0.20*t/{dur:.3f})':h='{h}*(1-0.20*t/{dur:.3f})':eval=frame")
vf.append(f"pad={w}:{h}:({w}-iw)/2:({h}-ih)/2:black")
if zoom in ("in", "out") and dur > 0:
# VIRTUAL zoom: zoompan keeps the output fixed at the canvas size and scales the
# content within it (never changes the actual frame size). in: 1.0->1.2 (push in);
# out: 1.2->1.0 (pull back). Centered.
nframes = max(1, round(dur * cfps))
z = f"1+0.20*on/{nframes}" if zoom == "in" else f"1.20-0.20*on/{nframes}"
vf.append(
f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f":s={cw}x{ch}:fps={cfps:g}"
)
vf.append("setsar=1")
blur = float(fx.get("blur") or 0)
if blur > 0:
vf.append(f"gblur=sigma={blur * 20:.2f}")