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:
co-authored by
Claude Opus 4.8
parent
6eab8d9632
commit
aeded5cafa
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user