From c555f1eaebff90749bdc543592cfa80a060b29e2 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 9 Aug 2026 02:37:37 +0200 Subject: 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. --- apps/web/src/effects/__tests__/motion.test.ts | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) (limited to 'apps/web/src/effects/__tests__/motion.test.ts') 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, + }); + }); +}); -- cgit v1.3.1