summaryrefslogtreecommitdiff
path: root/apps/web/src/rendering
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/rendering')
-rw-r--r--apps/web/src/rendering/__tests__/transition.test.ts166
-rw-r--r--apps/web/src/rendering/transition.ts194
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,
+ },
+ };
+}