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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:04:54 +03:00
co-authored by Claude Sonnet 5.5
parent 4a1bf89c48
commit 53752f3018
3 changed files with 70 additions and 0 deletions
+50
View File
@@ -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());
},
};
+19
View File
@@ -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");
});
+1
View File
@@ -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",