summaryrefslogtreecommitdiff
path: root/apps/web/src/effects/__tests__/motion.test.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
committercashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
commitc555f1eaebff90749bdc543592cfa80a060b29e2 (patch)
treee6a04adb212871063653b66c17eb1b8c17c7d22f /apps/web/src/effects/__tests__/motion.test.ts
parentdf82306a86d14bca0149e6d756ba82b75659af43 (diff)
downloadopencut-c555f1eaebff90749bdc543592cfa80a060b29e2.tar.gz
feat: lens effects tab, standalone motion effects, clip/asset rename, preview popout
- New Lens category in the assets panel: Blur, Motion Blur, Zoom In/Out, Pan x4, Shake, Pulse, Spin, Custom Zoom. EffectDefinition gains a category field; tabs with an empty category are hidden, and the properties-panel 'Open effects' button opens the first non-empty one. - Motion effects work standalone: dropping them on the timeline creates an effect-track element that animates (scale/translate/rotate) every track below it, computed purely in the TS frame descriptor - no wasm changes, identical in preview and export. supportsStandalone removed. - Motion Blur: directional gaussian smear (amount + angle) reusing the existing WGSL shader with arbitrary u_direction. - Custom Zoom: start/end scale params (keyframable clip-level). - MotionOffset gains optional rotate, accumulated clip-level and applied to standalone layer quads via rotationDegrees. - Rename clips (timeline context menu) and media assets (media panel context menu) via a shared RenameDialog; asset rename persists through storageService with rollback on error. - Preview pops out into a Document Picture-in-Picture window and docks back; button hidden where the API is unsupported. - Export keeps running when the export popover is closed; only the explicit Cancel button aborts. - Captions and Adjustment tabs removed from the assets panel.
Diffstat (limited to 'apps/web/src/effects/__tests__/motion.test.ts')
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts164
1 files changed, 164 insertions, 0 deletions
diff --git a/apps/web/src/effects/__tests__/motion.test.ts b/apps/web/src/effects/__tests__/motion.test.ts
index 907dd6a9..aeeaf6ab 100644
--- a/apps/web/src/effects/__tests__/motion.test.ts
+++ b/apps/web/src/effects/__tests__/motion.test.ts
@@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test";
import { registerDefaultEffects } from "@/effects";
import { buildDefaultEffectInstance } from "@/effects";
+import { effectsRegistry } from "@/effects";
import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { MotionOffset } from "@/effects/types";
+import type { ParamValues } from "@/params";
import type { Transform } from "@/rendering";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
import { GraphicNode } from "@/services/renderer/nodes/graphic-node";
+import { ColorNode } from "@/services/renderer/nodes/color-node";
+import { EffectLayerNode } from "@/services/renderer/nodes/effect-layer-node";
+import { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildFrameDescriptor } from "@/services/renderer/compositor/frame-descriptor";
import { resolveRenderTree } from "@/services/renderer/resolve";
import type { CanvasRenderer } from "@/services/renderer/canvas-renderer";
@@ -180,3 +188,159 @@ describe("renderer wiring", () => {
expect(node.resolved?.transform.scaleX).toBeCloseTo(1.175, 5);
});
});
+
+describe("standalone motion effect layers", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function evaluateExpectedOffset({
+ effectType,
+ effectParams,
+ linearProgress,
+ }: {
+ effectType: string;
+ effectParams: ParamValues;
+ linearProgress: number;
+ }): MotionOffset {
+ const definition = effectsRegistry.get(effectType);
+ if (!definition.motion) {
+ throw new Error(`${effectType} has no motion config`);
+ }
+ const progress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === effectParams.easing)
+ ?.value ?? [0.25, 0.1, 0.25, 1],
+ x: linearProgress,
+ });
+ return definition.motion.evaluate({ effectParams, progress });
+ }
+
+ test("standalone zoom-in resolves motion state from definition math", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const node = new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ });
+
+ await resolveRenderTree({ node, renderer, time: 5 });
+
+ const expected = evaluateExpectedOffset({
+ effectType: "zoom-in",
+ effectParams,
+ linearProgress: 0.5,
+ });
+ expect(node.resolved?.passes).toEqual([]);
+ expect(node.resolved?.motion?.scale).toBeCloseTo(expected.scale, 5);
+ expect(node.resolved?.motion?.scale).toBeGreaterThan(1);
+ expect(node.resolved?.motion?.translateX).toBeCloseTo(0, 5);
+ expect(node.resolved?.motion?.translateY).toBeCloseTo(0, 5);
+ });
+
+ test("frame descriptor transforms lower layer quads without a sceneEffect item", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ const expected = evaluateExpectedOffset({
+ effectType: "zoom-in",
+ effectParams,
+ linearProgress: 0.5,
+ });
+ expect(layer.transform.centerX).toBeCloseTo(renderer.width / 2, 5);
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(
+ renderer.width * expected.scale,
+ 5,
+ );
+ expect(layer.transform.height).toBeCloseTo(
+ renderer.height * expected.scale,
+ 5,
+ );
+ });
+
+ test("frame descriptor translates lower layers for pan motion", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "pan-right",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 7.5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ const expected = evaluateExpectedOffset({
+ effectType: "pan-right",
+ effectParams,
+ linearProgress: 0.75,
+ });
+ expect(expected.translateX).toBeGreaterThan(0);
+ expect(layer.transform.centerX).toBeCloseTo(
+ renderer.width / 2 + expected.translateX * renderer.width,
+ 5,
+ );
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(
+ renderer.width * expected.scale,
+ 5,
+ );
+ });
+
+ test("identity motion leaves lower layer quads untouched", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams: { intensity: 0, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform).toEqual({
+ centerX: renderer.width / 2,
+ centerY: renderer.height / 2,
+ width: renderer.width,
+ height: renderer.height,
+ rotationDegrees: 0,
+ flipX: false,
+ flipY: false,
+ });
+ });
+});