diff options
Diffstat (limited to 'apps/web/src/rendering')
| -rw-r--r-- | apps/web/src/rendering/__tests__/transition.test.ts | 166 | ||||
| -rw-r--r-- | apps/web/src/rendering/transition.ts | 194 |
2 files changed, 360 insertions, 0 deletions
diff --git a/apps/web/src/rendering/__tests__/transition.test.ts b/apps/web/src/rendering/__tests__/transition.test.ts new file mode 100644 index 00000000..4421fe58 --- /dev/null +++ b/apps/web/src/rendering/__tests__/transition.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { + applyVisualTransition, + buildTransitionFromParams, +} from "@/rendering/transition"; +import type { Transform } from "@/rendering"; +import { TICKS_PER_SECOND } from "@/wasm"; + +const baseTransform: Transform = { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, +}; + +describe("visual transitions", () => { + test("fade ramps opacity at clip edges", () => { + const transition = buildTransitionFromParams({ + params: { + "transition.kind": "fade", + "transition.inDuration": 1, + "transition.outDuration": 1, + }, + }); + + expect( + applyVisualTransition({ + baseTransform, + transition, + localTime: 0, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }).opacityMultiplier, + ).toBe(0); + expect( + applyVisualTransition({ + baseTransform, + transition, + localTime: 2 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }).opacityMultiplier, + ).toBe(1); + expect( + applyVisualTransition({ + baseTransform, + transition, + localTime: 4 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }).opacityMultiplier, + ).toBe(0); + }); + + test("slide left moves in and out across canvas", () => { + const transition = buildTransitionFromParams({ + params: { + "transition.kind": "push-left", + "transition.inDuration": 1, + "transition.outDuration": 1, + }, + }); + + const enter = applyVisualTransition({ + baseTransform, + transition, + localTime: 0, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + const middle = applyVisualTransition({ + baseTransform, + transition, + localTime: 2 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + const leave = applyVisualTransition({ + baseTransform, + transition, + localTime: 4 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + + expect(enter.transform.position.x).toBe(100); + expect(middle.transform.position.x).toBe(0); + expect(leave.transform.position.x).toBe(-100); + expect(leave.opacityMultiplier).toBe(1); + }); + + test("slide right mirrors horizontal entry and exit", () => { + const transition = buildTransitionFromParams({ + params: { + "transition.kind": "push-right", + "transition.inDuration": 1, + "transition.outDuration": 1, + }, + }); + + const enter = applyVisualTransition({ + baseTransform, + transition, + localTime: 0, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + const middle = applyVisualTransition({ + baseTransform, + transition, + localTime: 2 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + const leave = applyVisualTransition({ + baseTransform, + transition, + localTime: 4 * TICKS_PER_SECOND, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + + expect(enter.transform.position.x).toBe(-100); + expect(middle.transform.position.x).toBe(0); + expect(leave.transform.position.x).toBe(100); + expect(enter.opacityMultiplier).toBe(1); + }); + + test("zoom and spin variants converge on the base transform", () => { + const applyKind = ({ + kind, + localTime, + }: { + kind: "zoom" | "zoom-out" | "spin"; + localTime: number; + }) => + applyVisualTransition({ + baseTransform, + transition: buildTransitionFromParams({ + params: { + "transition.kind": kind, + "transition.inDuration": 1, + "transition.outDuration": 0, + }, + }), + localTime, + duration: 4 * TICKS_PER_SECOND, + canvasWidth: 100, + }); + + expect(applyKind({ kind: "zoom", localTime: 0 }).transform.scaleX).toBe( + 0.96, + ); + expect(applyKind({ kind: "zoom-out", localTime: 0 }).transform.scaleX).toBe( + 1.04, + ); + expect(applyKind({ kind: "spin", localTime: 0 }).transform.rotate).toBe(-6); + + const settledSpin = applyKind({ + kind: "spin", + localTime: 2 * TICKS_PER_SECOND, + }); + expect(settledSpin.transform.scaleX).toBe(1); + expect(settledSpin.transform.rotate).toBe(0); + }); +}); diff --git a/apps/web/src/rendering/transition.ts b/apps/web/src/rendering/transition.ts new file mode 100644 index 00000000..874975a1 --- /dev/null +++ b/apps/web/src/rendering/transition.ts @@ -0,0 +1,194 @@ +import type { ParamValues } from "@/params"; +import { clamp } from "@/utils/math"; +import { TICKS_PER_SECOND } from "@/wasm"; +import type { Transform } from "./index"; + +export type TransitionKind = + | "fade" + | "push-left" + | "push-right" + | "zoom" + | "zoom-out" + | "spin"; + +export interface VisualTransition { + kind: TransitionKind; + inDuration: number; + outDuration: number; +} + +export interface TransitionState { + opacityMultiplier: number; + transform: Transform; +} + +export const TRANSITION_KIND_OPTIONS: Array<{ + value: TransitionKind; + label: string; +}> = [ + { value: "fade", label: "Soft fade" }, + { value: "push-left", label: "Slide left" }, + { value: "push-right", label: "Slide right" }, + { value: "zoom", label: "Zoom in" }, + { value: "zoom-out", label: "Zoom out" }, + { value: "spin", label: "Spin dissolve" }, +]; + +export function isTransitionKind(value: string): value is TransitionKind { + return TRANSITION_KIND_OPTIONS.some((option) => option.value === value); +} +export function withVisualTransitionParams({ + params, + transitionKind, + inDuration, + outDuration, +}: { + params: ParamValues; + transitionKind: TransitionKind; + inDuration: number; + outDuration: number; +}): ParamValues { + return { + ...params, + "transition.kind": transitionKind, + "transition.inDuration": inDuration, + "transition.outDuration": outDuration, + }; +} + +export function buildTransitionFromParams({ + params, +}: { + params: ParamValues; +}): VisualTransition { + const kindValue = params["transition.kind"]; + const inDurationValue = params["transition.inDuration"]; + const outDurationValue = params["transition.outDuration"]; + return { + kind: + typeof kindValue === "string" && isTransitionKind(kindValue) + ? kindValue + : "fade", + inDuration: readDuration({ value: inDurationValue }), + outDuration: readDuration({ value: outDurationValue }), + }; +} + +export function applyVisualTransition({ + baseTransform, + transition, + localTime, + duration, + canvasWidth, +}: { + baseTransform: Transform; + transition?: VisualTransition; + localTime: number; + duration: number; + canvasWidth: number; +}): TransitionState { + if (!transition || duration <= 0) { + return { opacityMultiplier: 1, transform: baseTransform }; + } + + const inProgress = progressWithin({ + elapsed: localTime, + duration: transition.inDuration, + }); + const outProgress = progressWithin({ + elapsed: duration - localTime, + duration: transition.outDuration, + }); + const progress = Math.min(inProgress, outProgress); + const isSlide = + transition.kind === "push-left" || transition.kind === "push-right"; + const opacityMultiplier = isSlide ? 1 : ease({ progress }); + const transform = applyTransitionTransform({ + transform: baseTransform, + transition, + inProgress, + outProgress, + canvasWidth, + }); + return { opacityMultiplier, transform }; +} + +function readDuration({ value }: { value: unknown }): number { + const seconds = + typeof value === "number" && Number.isFinite(value) + ? clamp({ value, min: 0, max: 5 }) + : 0; + return seconds * TICKS_PER_SECOND; +} + +function progressWithin({ + elapsed, + duration, +}: { + elapsed: number; + duration: number; +}): number { + if (duration <= 0) { + return 1; + } + return clamp({ value: elapsed / duration, min: 0, max: 1 }); +} + +function ease({ progress }: { progress: number }): number { + return progress * progress * (3 - 2 * progress); +} + +function applyTransitionTransform({ + transform, + transition, + inProgress, + outProgress, + canvasWidth, +}: { + transform: Transform; + transition: VisualTransition; + inProgress: number; + outProgress: number; + canvasWidth: number; +}): Transform { + if (transition.kind === "fade") { + return transform; + } + + if (transition.kind === "zoom" || transition.kind === "zoom-out") { + const progress = Math.min(inProgress, outProgress); + const eased = ease({ progress }); + const scale = + transition.kind === "zoom" ? 0.96 + eased * 0.04 : 1.04 - eased * 0.04; + return { + ...transform, + scaleX: transform.scaleX * scale, + scaleY: transform.scaleY * scale, + }; + } + + if (transition.kind === "spin") { + const progress = Math.min(inProgress, outProgress); + const eased = ease({ progress }); + const scale = 0.96 + eased * 0.04; + return { + ...transform, + scaleX: transform.scaleX * scale, + scaleY: transform.scaleY * scale, + rotate: transform.rotate - (1 - eased) * 6, + }; + } + + const direction = transition.kind === "push-right" ? -1 : 1; + const enteringOffset = + (1 - ease({ progress: inProgress })) * canvasWidth * direction; + const leavingOffset = + (ease({ progress: outProgress }) - 1) * canvasWidth * direction; + return { + ...transform, + position: { + x: transform.position.x + enteringOffset + leavingOffset, + y: transform.position.y, + }, + }; +} |
