From 53752f3018ea777ece2078ef004ae48dc4077a2d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:04:54 +0300 Subject: [PATCH] feat(ui): a clip's effects drawn live on the monitor (flip, fill, crop, zoom ramp, blur, fades) Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/preview/fx.js | 50 ++++++++++++++++++++++++++++++ app/ui/features/preview/fx.test.js | 19 ++++++++++++ app/ui/modules.js | 1 + 3 files changed, 70 insertions(+) create mode 100644 app/ui/features/preview/fx.js create mode 100644 app/ui/features/preview/fx.test.js diff --git a/app/ui/features/preview/fx.js b/app/ui/features/preview/fx.js new file mode 100644 index 00000000..d5f110bd --- /dev/null +++ b/app/ui/features/preview/fx.js @@ -0,0 +1,50 @@ +// A clip's effects drawn live on the monitor (the render does the real thing; this follows the same geometry): flips, fill, crop, +// zoom ramp, blur, fades. Computed from the playhead, so it tracks scrubbing as well as playing. +import { segments } from "../../shell/scenes.js"; + +const num = (v, d = 0) => (Number.isFinite(+v) ? +v : d); + +/** The zoom scale `within` seconds into a clip of `dur` seconds at `fps`: the ramp from effects.zoom_scale_at. */ +export function zoomScale(fx, within, dur, fps) { + if (fx.zoom !== "in" && fx.zoom !== "out") return 1; + const n = Math.max(1, Math.round(dur * fps)), ratio = Math.max(0.01, Math.min(0.5, num(fx.zoom_ratio, 0.15))); + const start = Math.max(0, Math.min(Math.round(num(fx.zoom_start_frame)), n - 1)); + const length = Math.max(1, Math.min(Math.round(num(fx.zoom_frames, Math.min(Math.max(1, Math.floor(n / 4)), 25))), Math.max(1, n - start))); + const on = Math.max(0, Math.min(Math.floor(within * fps), n - 1)), end = 1 + ratio; + if (on < start) return fx.zoom === "in" ? 1 : end; + if (on >= start + length) return fx.zoom === "in" ? end : 1; + const t = (on - start) / length; + return fx.zoom === "in" ? 1 + ratio * t : end - ratio * t; +} + +/** CSS for the monitor's video: { transform, filter, objectFit, opacity }. */ +export function fxStyle(fx = {}, within = 0, dur = 0, fps = 25) { + const inset = Math.max(0, Math.min(0.4, num(fx.crop_inset))); + const scale = zoomScale(fx, within, dur, fps) * (inset > 0 ? 1 / (1 - 2 * inset) : 1); + const t = [fx.flip_h ? "scaleX(-1)" : "", fx.flip_v ? "scaleY(-1)" : "", scale !== 1 ? `scale(${scale.toFixed(4)})` : ""].filter(Boolean).join(" "); + let opacity = 1; + const fi = num(fx.fade_in), fo = num(fx.fade_out); + if (fi > 0 && within < fi) opacity = Math.max(0, within / fi); + if (fo > 0 && dur > 0 && within > dur - fo) opacity = Math.min(opacity, Math.max(0, (dur - within) / fo)); + return { transform: t, filter: num(fx.blur) > 0 ? `blur(${(num(fx.blur) * 8).toFixed(1)}px)` : "", objectFit: fx.fit === "fill" ? "cover" : "contain", opacity: opacity < 1 ? opacity.toFixed(3) : "" }; +} + +export default { + id: "preview-fx", + mount: "preview", + needs: ["project", "playhead"], + setup({ host, app }) { + const p = app.project, head = app.playhead; + function draw() { + const v = host.querySelector("video"), open = p.project; + if (!v) return; + const seg = open && segments(open).find((s) => s.kind === "scene" && head.at >= s.start - 1e-6 && head.at < s.start + s.dur - 1e-6); + const st = fxStyle(seg ? seg.scene.effects : {}, seg ? head.at - seg.start : 0, seg ? seg.dur : 0, seg ? num(seg.scene.fps_mode === "project" ? open.frame_rate : seg.scene.fps, 25) || 25 : 25); + Object.assign(v.style, st); + if (v.parentElement) v.parentElement.style.overflow = "hidden"; // a zoom or crop punches in: the frame stays put + } + const off = [head.on(draw), app.on(draw)]; + draw(); + return () => off.forEach((f) => f()); + }, +}; diff --git a/app/ui/features/preview/fx.test.js b/app/ui/features/preview/fx.test.js new file mode 100644 index 00000000..069679da --- /dev/null +++ b/app/ui/features/preview/fx.test.js @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { fxStyle, zoomScale } from "./fx.js"; + +test("flips, fill, crop and blur become CSS; nothing set means nothing applied", () => { + assert.deepEqual(fxStyle({}), { transform: "", filter: "", objectFit: "contain", opacity: "" }); + const s = fxStyle({ flip_h: true, fit: "fill", crop_inset: 0.1, blur: 0.5 }); + assert.deepEqual([s.transform, s.objectFit, s.filter], ["scaleX(-1) scale(1.2500)", "cover", "blur(4.0px)"]); +}); + +test("zoom in ramps from 1 to 1+ratio over its frames and holds; zoom out is the reverse; fades follow the clip's edges", () => { + const fx = { zoom: "in", zoom_ratio: 0.2, zoom_frames: 10, zoom_start_frame: 5 }; + assert.equal(zoomScale(fx, 0, 4, 25), 1); + assert.equal(zoomScale(fx, 1, 4, 25), 1.2); + assert.ok(Math.abs(zoomScale(fx, 0.3, 4, 25) - 1.04) < 1e-9); + assert.equal(zoomScale({ ...fx, zoom: "out" }, 0, 4, 25), 1.2); + assert.equal(fxStyle({ fade_in: 1 }, 0.5, 4).opacity, "0.500"); + assert.equal(fxStyle({ fade_out: 1 }, 3.5, 4).opacity, "0.500"); +}); diff --git a/app/ui/modules.js b/app/ui/modules.js index 0287ffd7..e62115a0 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -29,6 +29,7 @@ export default [ "./features/overlays/index.js", "./features/overlays/monitor.js", "./features/preview/lanes.js", + "./features/preview/fx.js", "./features/montage/index.js", "./features/generate/warn.js", "./features/composer/index.js",