diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
| commit | f1043734438c8518a302f698d9ae6583a75f0c14 (patch) | |
| tree | d3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/rendering/transition.ts | |
| parent | 37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff) | |
| download | opencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz | |
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/rendering/transition.ts')
| -rw-r--r-- | apps/web/src/rendering/transition.ts | 194 |
1 files changed, 194 insertions, 0 deletions
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, + }, + }; +} |
