diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml
index 58198586..386b42bc 100644
--- a/.github/workflows/bun-ci.yml
+++ b/.github/workflows/bun-ci.yml
@@ -31,11 +31,6 @@ jobs:
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "placeholder"
- CLOUDFLARE_ACCOUNT_ID: "placeholder"
- R2_ACCESS_KEY_ID: "placeholder"
- R2_SECRET_ACCESS_KEY: "placeholder"
- R2_BUCKET_NAME: "placeholder"
- MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
steps:
- name: Checkout repository
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 48d48b1d..b15779a5 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_here
FREESOUND_API_KEY=your_api_key_here
-
-CLOUDFLARE_ACCOUNT_ID=your_account_id_here
-R2_ACCESS_KEY_ID=your_access_key_here
-R2_SECRET_ACCESS_KEY=your_secret_key_here
-R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
-
-MODAL_TRANSCRIPTION_URL=your_modal_url_here
\ No newline at end of file
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 34b63e3c..a08d9b68 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
-ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
-ENV R2_ACCESS_KEY_ID="build-placeholder"
-ENV R2_SECRET_ACCESS_KEY="build-placeholder"
-ENV R2_BUCKET_NAME="build-placeholder"
-ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
index 285b37d3..5b076466 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
@@ -70,41 +71,25 @@ export function StickersView() {
/>
-
-
-
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
- const isActive = key === selectedCategory;
- return (
-
- );
- })}
-
-
+
{
+ setSelectedCategory({ category: value as StickerCategory });
+ }}
+ variant="underline"
+ className="mt-2 flex min-h-0 flex-1 flex-col"
+ >
+
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
-
+
);
}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
index 95f338e1..a900cb13 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -9,8 +9,7 @@ export function useElementPlayhead({
startTime: number;
duration: number;
}) {
- const editor = useEditor();
- const playheadTime = editor.playback.getCurrentTime();
+ const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index 92b5d634..c044ff5f 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -1,148 +1,150 @@
-"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import {
- buildGraphicParamPath,
- getKeyframeAtTime,
- getParamDefaultInterpolation,
- getParamValueKind,
- hasKeyframesForPath,
- upsertPathKeyframe,
-} from "@/lib/animation";
-import type {
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { ParamDefinition } from "@/lib/params";
-import type { TimelineElement } from "@/lib/timeline";
-
-export interface KeyframedParamPropertyResult {
- hasAnimatedKeyframes: boolean;
- isKeyframedAtTime: boolean;
- keyframeIdAtTime: string | null;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
- toggleKeyframe: () => void;
-}
-
-export function useKeyframedParamProperty({
- param,
- trackId,
- elementId,
- animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue,
- buildBaseUpdates,
-}: {
- param: ParamDefinition;
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number | string | boolean;
- buildBaseUpdates: ({
- value,
- }: {
- value: number | string | boolean;
- }) => Partial;
-}): KeyframedParamPropertyResult {
- const editor = useEditor();
- const propertyPath = buildGraphicParamPath({ paramKey: param.key });
- const hasAnimatedKeyframes = hasKeyframesForPath({
- animations,
- propertyPath,
- });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({
- animations,
- propertyPath,
- time: localTime,
- })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertPathKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value,
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({
- param,
- }),
- numericRange:
- param.type === "number"
- ? { min: param.min, max: param.max, step: param.step }
- : undefined,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value }),
- },
- ],
- });
- };
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: resolvedValue,
- },
- ],
- });
- };
-
- return {
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- onPreview: previewValue,
- onCommit: () => editor.timeline.commitPreview(),
- toggleKeyframe,
- };
-}
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ coerceAnimationValueForParam,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time: localTime,
+ })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ coerceValue: (nextValue) =>
+ coerceAnimationValueForParam({
+ param,
+ value: nextValue,
+ }),
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
new file mode 100644
index 00000000..32f39174
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useEffect, useRef, useState, type PointerEvent } from "react";
+import { getBezierPoint } from "@/lib/animation/bezier";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+import { cn } from "@/utils/ui";
+
+const GRAPH_WIDTH = 140;
+const GRAPH_HEIGHT = 94;
+const GRAPH_PADDING = 12;
+const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
+const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
+const HANDLE_RADIUS = 3.5;
+const ENDPOINT_RADIUS = 2;
+const SNAP_THRESHOLD = 0.06;
+const SNAP_TARGETS = [0, 1];
+const CURVE_SEGMENTS = 64;
+const Y_CLAMP_MIN = -0.5;
+const Y_CLAMP_MAX = 1.5;
+
+type BezierHandle = "c1" | "c2";
+
+export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
+
+function snap({
+ value,
+ targets,
+ isEnabled,
+}: {
+ value: number;
+ targets: number[];
+ isEnabled: boolean;
+}) {
+ if (!isEnabled) return value;
+ for (const target of targets) {
+ if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
+ }
+ return value;
+}
+
+function toSvgX({ value }: { value: number }) {
+ return GRAPH_PADDING + value * GRAPH_WIDTH;
+}
+
+function toSvgY({ value }: { value: number }) {
+ return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
+}
+
+function fromSvgX({ svgX }: { svgX: number }) {
+ return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
+}
+
+function fromSvgY({ svgY }: { svgY: number }) {
+ return Math.max(
+ Y_CLAMP_MIN,
+ Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
+ );
+}
+
+function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= CURVE_SEGMENTS; i++) {
+ const progress = i / CURVE_SEGMENTS;
+ points.push(
+ `${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`,
+ );
+ }
+ return `M${points.join("L")}`;
+}
+
+function clampHandleY({ svgY }: { svgY: number }) {
+ return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
+}
+
+export function BezierGraph({
+ value,
+ onChange,
+ onChangeEnd,
+ onCancel,
+}: {
+ value: NormalizedCubicBezier;
+ onChange?: (value: NormalizedCubicBezier) => void;
+ onChangeEnd?: (value: NormalizedCubicBezier) => void;
+ onCancel?: () => void;
+}) {
+ const svgRef = useRef(null);
+ const [activeHandle, setActiveHandle] = useState(null);
+ const isShiftPressedRef = useRef(false);
+ const latestValueRef = useRef(value);
+
+ latestValueRef.current = value;
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Shift") isShiftPressedRef.current = true;
+ };
+ const onKeyUp = (event: KeyboardEvent) => {
+ if (event.key === "Shift") isShiftPressedRef.current = false;
+ };
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("keyup", onKeyUp);
+ };
+ }, []);
+
+ function getPointerPosition({
+ event,
+ }: {
+ event: PointerEvent;
+ }): { x: number; y: number } {
+ const svg = svgRef.current;
+ if (!svg) return { x: 0, y: 0 };
+ const rect = svg.getBoundingClientRect();
+ const scale = SVG_WIDTH / rect.width;
+ return {
+ x: (event.clientX - rect.left) * scale,
+ y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
+ };
+ }
+
+ function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
+ return (event: PointerEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setActiveHandle(handle);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ }
+
+ function onPointerMove({ event }: { event: PointerEvent }) {
+ if (!activeHandle) return;
+ const pointerPos = getPointerPosition({ event });
+ const x = fromSvgX({ svgX: pointerPos.x });
+ const y = snap({
+ value: fromSvgY({ svgY: pointerPos.y }),
+ targets: SNAP_TARGETS,
+ isEnabled: !isShiftPressedRef.current,
+ });
+ const next: NormalizedCubicBezier = [...value];
+ if (activeHandle === "c1") {
+ next[0] = x;
+ next[1] = y;
+ } else {
+ next[2] = x;
+ next[3] = y;
+ }
+ latestValueRef.current = next;
+ onChange?.(next);
+ }
+
+ function onPointerUp() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onChangeEnd?.(latestValueRef.current);
+ }
+
+ function onPointerCancel() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onCancel?.();
+ }
+
+ const path = curvePath({ curve: value });
+ const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
+ const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
+ const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
+ const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
+ const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
+ const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
new file mode 100644
index 00000000..86665392
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useState } from "react";
+import { Popover, PopoverContent } from "@/components/ui/popover";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowDown01Icon,
+ Delete02Icon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { getBezierPoint } from "@/lib/animation/bezier";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+import type { GraphEditorComponentOption } from "./session";
+import {
+ BUILTIN_PRESETS,
+ PRESET_MATCH_TOLERANCE,
+ removePreset,
+ savePreset,
+ type EasingPreset,
+ useCustomPresets,
+} from "./presets";
+import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
+
+const COLLAPSED_MAX = 6;
+const THUMB_SEGMENTS = 24;
+const THUMB_WIDTH = 40;
+const THUMB_HEIGHT = 22;
+const THUMB_PADDING_X = 4;
+const THUMB_PADDING_Y = 3;
+const COLLAPSED_GRID_MAX_HEIGHT = 120;
+const EXPANDED_GRID_MAX_HEIGHT = 240;
+
+export function GraphEditorPopover({
+ children,
+ side,
+ open,
+ onOpenChange,
+ value,
+ message,
+ componentOptions,
+ activeComponentKey,
+ onActiveComponentKeyChange,
+ onPreviewValue,
+ onCommitValue,
+ onCancelPreview,
+}: {
+ children: React.ReactNode;
+ side?: "top" | "bottom" | "left" | "right";
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ value: NormalizedCubicBezier | null;
+ message: string;
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ onActiveComponentKeyChange?: (componentKey: string) => void;
+ onPreviewValue?: (value: NormalizedCubicBezier) => void;
+ onCommitValue?: (value: NormalizedCubicBezier) => void;
+ onCancelPreview?: () => void;
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const custom = useCustomPresets();
+ const allPresets = [...BUILTIN_PRESETS, ...custom];
+ const canEdit = value !== null;
+ const activePresetId =
+ value == null
+ ? null
+ : (allPresets.find((preset) =>
+ preset.value.every(
+ (presetValue, index) =>
+ Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
+ ),
+ )?.id ?? null);
+
+ return (
+ {
+ if (!nextOpen) {
+ onCancelPreview?.();
+ }
+ onOpenChange?.(nextOpen);
+ }}
+ >
+ {children}
+
+ {componentOptions.length > 1 && (
+
+
+ {componentOptions.map((component) => (
+
+ ))}
+
+
+ )}
+
+
+ {value ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Presets
+
+
+ Saved
+
+
+
+ COLLAPSED_MAX}
+ onExpand={() => setIsExpanded(true)}
+ >
+ {BUILTIN_PRESETS.map((preset) => (
+ onCommitValue?.(preset.value)}
+ />
+ ))}
+
+
+
+
+ {custom.map((preset) => (
+
onCommitValue?.(preset.value)}
+ onDelete={() => removePreset(preset.id)}
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function GraphEditorEmptyState({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+function ExpandableGrid({
+ children,
+ isExpanded,
+ shouldExpand,
+ onExpand,
+}: {
+ children: React.ReactNode;
+ isExpanded: boolean;
+ shouldExpand: boolean;
+ onExpand: () => void;
+}) {
+ const gridStyle = shouldExpand
+ ? isExpanded
+ ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
+ : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
+ : undefined;
+
+ return (
+
+
+ {children}
+
+ {!isExpanded && shouldExpand && (
+
+
+
+ )}
+
+ );
+}
+
+function PresetItem({
+ preset,
+ isActive,
+ onSelect,
+ onDelete,
+ disabled,
+}: {
+ preset: EasingPreset;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= THUMB_SEGMENTS; i++) {
+ const progress = i / THUMB_SEGMENTS;
+ points.push(
+ `${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`,
+ );
+ }
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
new file mode 100644
index 00000000..cbcdf566
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
@@ -0,0 +1,90 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+
+const STORAGE_KEY = "opencut:graph-editor-presets";
+export const PRESET_MATCH_TOLERANCE = 0.02;
+
+export interface EasingPreset {
+ id: string;
+ label: string;
+ value: NormalizedCubicBezier;
+ isCustom?: boolean;
+}
+
+export const BUILTIN_PRESETS: EasingPreset[] = [
+ { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
+ { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
+ { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
+ { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
+ { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
+ { id: "linear", label: "Linear", value: [0, 0, 1, 1] },
+];
+
+let cachedPresets: EasingPreset[] | null = null;
+const listeners = new Set<() => void>();
+
+function readFromStorage(): EasingPreset[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ // JSON.parse can throw if the stored value is corrupted
+ return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeToStorage(presets: EasingPreset[]): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+}
+
+function getSnapshot(): EasingPreset[] {
+ cachedPresets ??= readFromStorage();
+ return cachedPresets;
+}
+
+function getServerSnapshot(): EasingPreset[] {
+ return [];
+}
+
+function notify(): void {
+ cachedPresets = null;
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (event.key === STORAGE_KEY) notify();
+ });
+}
+
+export function useCustomPresets(): EasingPreset[] {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function savePreset(value: NormalizedCubicBezier): void {
+ const current = getSnapshot();
+ writeToStorage([
+ ...current,
+ {
+ id: `custom-${Date.now()}`,
+ label: `Custom ${current.length + 1}`,
+ value,
+ isCustom: true,
+ },
+ ]);
+ notify();
+}
+
+export function removePreset(id: string): void {
+ writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
+ notify();
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
new file mode 100644
index 00000000..148fa7b4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
@@ -0,0 +1,448 @@
+"use client";
+
+import {
+ getCurveHandlesForNormalizedCubicBezier,
+ getEditableScalarChannels,
+ getNormalizedCubicBezierForScalarSegment,
+ getScalarKeyframeContext,
+ updateScalarKeyframeCurve,
+} from "@/lib/animation";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ NormalizedCubicBezier,
+ ScalarCurveKeyframePatch,
+ ScalarGraphKeyframeContext,
+ SelectedKeyframeRef,
+} from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+
+const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
+const FLAT_VALUE_EPSILON = 1e-6;
+const LINEAR_CURVE_EPSILON = 1e-6;
+
+export type GraphEditorUnavailableReason =
+ | "no-keyframe-selected"
+ | "multiple-keyframes-selected"
+ | "selected-element-missing"
+ | "selected-element-has-no-animations"
+ | "selected-keyframe-has-no-scalar-channel"
+ | "selected-keyframe-missing-on-channel"
+ | "selected-keyframe-has-no-next-segment"
+ | "selected-segment-is-hold"
+ | "selected-segment-is-flat";
+
+export interface GraphEditorComponentOption {
+ key: string;
+ label: string;
+}
+
+interface GraphEditorBaseSelectionState {
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ message: string;
+}
+
+export interface GraphEditorUnavailableState
+ extends GraphEditorBaseSelectionState {
+ status: "unavailable";
+ reason: GraphEditorUnavailableReason;
+}
+
+export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
+ status: "ready";
+ trackId: string;
+ elementId: string;
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ element: TimelineElement;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+}
+
+export type GraphEditorSelectionState =
+ | GraphEditorUnavailableState
+ | GraphEditorReadyState;
+
+export interface GraphEditorCurvePatch {
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}
+
+function createUnavailableState({
+ reason,
+ message,
+ componentOptions = [],
+ activeComponentKey = null,
+}: {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ componentOptions?: GraphEditorComponentOption[];
+ activeComponentKey?: string | null;
+}): GraphEditorUnavailableState {
+ return {
+ status: "unavailable",
+ reason,
+ message,
+ componentOptions,
+ activeComponentKey,
+ };
+}
+
+function findElementByKeyframe({
+ tracks,
+ keyframe,
+}: {
+ tracks: TimelineTrack[];
+ keyframe: SelectedKeyframeRef;
+}): { element: TimelineElement; trackId: string; elementId: string } | null {
+ for (const track of tracks) {
+ if (track.id !== keyframe.trackId) {
+ continue;
+ }
+
+ const element = track.elements.find(
+ (trackElement) => trackElement.id === keyframe.elementId,
+ );
+ if (!element) {
+ return null;
+ }
+
+ return {
+ element,
+ trackId: track.id,
+ elementId: element.id,
+ };
+ }
+
+ return null;
+}
+
+function findKeyframeTime({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): number | null {
+ const binding = animations.bindings[propertyPath];
+ if (!binding) return null;
+
+ for (const component of binding.components) {
+ const channel = animations.channels[component.channelId];
+ if (channel?.kind !== "scalar") continue;
+ const key = channel.keys.find((k) => k.id === keyframeId);
+ if (key !== undefined) return key.time;
+ }
+
+ return null;
+}
+
+function getComponentLabel({ componentKey }: { componentKey: string }): string {
+ switch (componentKey) {
+ case "value":
+ return "Value";
+ default:
+ return componentKey.toUpperCase();
+ }
+}
+
+function isFlatSegment({
+ context,
+}: {
+ context: ScalarGraphKeyframeContext;
+}): boolean {
+ if (!context.nextKey) {
+ return true;
+ }
+
+ return (
+ Math.abs(context.nextKey.value - context.keyframe.value) <=
+ FLAT_VALUE_EPSILON
+ );
+}
+
+function isLinearCurve({
+ cubicBezier,
+}: {
+ cubicBezier: NormalizedCubicBezier;
+}): boolean {
+ return (
+ Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
+ );
+}
+
+export function resolveGraphEditorSelectionState({
+ tracks,
+ selectedKeyframes,
+ preferredComponentKey,
+}: {
+ tracks: TimelineTrack[];
+ selectedKeyframes: SelectedKeyframeRef[];
+ preferredComponentKey?: string | null;
+}): GraphEditorSelectionState {
+ if (selectedKeyframes.length === 0) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ if (selectedKeyframes.length > 2) {
+ return createUnavailableState({
+ reason: "multiple-keyframes-selected",
+ message: "Select one or two adjacent keyframes to edit a curve.",
+ });
+ }
+
+ if (selectedKeyframes.length === 2) {
+ const [kf1, kf2] = selectedKeyframes;
+ if (
+ kf1.trackId !== kf2.trackId ||
+ kf1.elementId !== kf2.elementId ||
+ kf1.propertyPath !== kf2.propertyPath
+ ) {
+ return createUnavailableState({
+ reason: "multiple-keyframes-selected",
+ message: "Selected keyframes must be on the same element and property.",
+ });
+ }
+ }
+
+ const primaryKeyframe = selectedKeyframes[0];
+ const secondaryKeyframeId =
+ selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
+
+ const selectedElement = findElementByKeyframe({
+ tracks,
+ keyframe: primaryKeyframe,
+ });
+ if (!selectedElement) {
+ return createUnavailableState({
+ reason: "selected-element-missing",
+ message: "The selected keyframe could not be resolved.",
+ });
+ }
+
+ if (!selectedElement.element.animations) {
+ return createUnavailableState({
+ reason: "selected-element-has-no-animations",
+ message: "The selected keyframe has no editable graph.",
+ });
+ }
+
+ const scalarChannels = getEditableScalarChannels({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ });
+ if (scalarChannels.length === 0) {
+ return createUnavailableState({
+ reason: "selected-keyframe-has-no-scalar-channel",
+ message: "The selected keyframe has no editable graph channel.",
+ });
+ }
+
+ // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
+ // anchor so the graph editor edits the curve between the two selected keyframes.
+ let resolvedKeyframeId = primaryKeyframe.keyframeId;
+ if (secondaryKeyframeId !== null) {
+ const time1 = findKeyframeTime({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: primaryKeyframe.keyframeId,
+ });
+ const time2 = findKeyframeTime({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: secondaryKeyframeId,
+ });
+ if (time2 !== null && (time1 === null || time2 < time1)) {
+ resolvedKeyframeId = secondaryKeyframeId;
+ }
+ }
+
+ const contexts = scalarChannels.flatMap((channel) => {
+ const context = getScalarKeyframeContext({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ componentKey: channel.componentKey,
+ keyframeId: resolvedKeyframeId,
+ });
+ if (!context) {
+ return [];
+ }
+
+ return [
+ {
+ context,
+ option: {
+ key: channel.componentKey,
+ label: getComponentLabel({ componentKey: channel.componentKey }),
+ },
+ },
+ ];
+ });
+
+ if (contexts.length === 0) {
+ return createUnavailableState({
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ });
+ }
+
+ const nextSegmentContexts = contexts.filter(
+ ({ context }) => context.nextKey !== null,
+ );
+ const preferredContext =
+ contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
+ const activeContext =
+ preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
+ const componentOptions = contexts.map(({ option }) => option);
+
+ if (!activeContext.context.nextKey) {
+ return createUnavailableState({
+ reason: "selected-keyframe-has-no-next-segment",
+ message: "Select a keyframe that has an outgoing segment.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ if (isFlatSegment({ context: activeContext.context })) {
+ return createUnavailableState({
+ reason: "selected-segment-is-flat",
+ message: "Flat segments are not graph-editable in this popover yet.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ if (activeContext.context.keyframe.segmentToNext === "step") {
+ return createUnavailableState({
+ reason: "selected-segment-is-hold",
+ message: "Hold segments are not graph-editable in this popover yet.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ const cubicBezier =
+ activeContext.context.keyframe.segmentToNext === "linear"
+ ? GRAPH_LINEAR_CURVE
+ : getNormalizedCubicBezierForScalarSegment({
+ leftKey: activeContext.context.keyframe,
+ rightKey: activeContext.context.nextKey,
+ });
+ if (!cubicBezier) {
+ return createUnavailableState({
+ reason: "selected-segment-is-flat",
+ message: "The selected segment cannot be represented in this graph view.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ return {
+ status: "ready",
+ message: "Edit graph",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ trackId: selectedElement.trackId,
+ elementId: selectedElement.elementId,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ element: selectedElement.element,
+ context: activeContext.context,
+ cubicBezier,
+ };
+}
+
+export function buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+}: {
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+}): GraphEditorCurvePatch[] | null {
+ if (!context.nextKey) {
+ return null;
+ }
+
+ if (isLinearCurve({ cubicBezier })) {
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "linear",
+ rightHandle: null,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: null,
+ },
+ },
+ ];
+ }
+
+ const handles = getCurveHandlesForNormalizedCubicBezier({
+ leftKey: context.keyframe,
+ rightKey: context.nextKey,
+ cubicBezier,
+ });
+ if (!handles) {
+ return null;
+ }
+
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "bezier",
+ rightHandle: handles.rightHandle,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: handles.leftHandle,
+ },
+ },
+ ];
+}
+
+export function applyGraphEditorCurvePreview({
+ animations,
+ context,
+ cubicBezier,
+}: {
+ animations: ElementAnimations | undefined;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+}): ElementAnimations | undefined {
+ const patches = buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ });
+ if (!patches) {
+ return animations;
+ }
+
+ return patches.reduce(
+ (nextAnimations, { keyframeId, patch }) =>
+ updateScalarKeyframeCurve({
+ animations: nextAnimations,
+ propertyPath: context.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ }),
+ animations,
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
new file mode 100644
index 00000000..3b7520e6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
@@ -0,0 +1,153 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { registerCanceller } from "@/lib/cancel-interaction";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import {
+ applyGraphEditorCurvePreview,
+ buildGraphEditorCurvePatches,
+ resolveGraphEditorSelectionState,
+ type GraphEditorSelectionState,
+} from "./session";
+
+export function useGraphEditorController() {
+ const editor = useEditor();
+ const renderTracks = useEditor((currentEditor) =>
+ currentEditor.timeline.getRenderTracks(),
+ );
+ const { selectedKeyframes } = useKeyframeSelection();
+ const [open, setOpen] = useState(false);
+ const [activeComponentKey, setActiveComponentKey] = useState(
+ null,
+ );
+ const hasPreviewRef = useRef(false);
+
+ const state = useMemo(
+ () =>
+ resolveGraphEditorSelectionState({
+ tracks: renderTracks,
+ selectedKeyframes,
+ preferredComponentKey: activeComponentKey,
+ }),
+ [activeComponentKey, renderTracks, selectedKeyframes],
+ );
+
+ const stateKey =
+ state.status === "ready"
+ ? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
+ : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
+ const previousStateKeyRef = useRef(stateKey);
+
+ const discardPreview = useCallback(() => {
+ if (!hasPreviewRef.current) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ hasPreviewRef.current = false;
+ }, [editor]);
+
+ useEffect(() => {
+ if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
+ discardPreview();
+ }
+
+ previousStateKeyRef.current = stateKey;
+ }, [discardPreview, stateKey]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ return registerCanceller({
+ fn: () => {
+ discardPreview();
+ setOpen(false);
+ },
+ });
+ }, [discardPreview, open]);
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (!nextOpen) {
+ discardPreview();
+ }
+
+ setOpen(nextOpen);
+ },
+ [discardPreview],
+ );
+
+ const handleActiveComponentKeyChange = useCallback(
+ (nextComponentKey: string) => {
+ discardPreview();
+ setActiveComponentKey(nextComponentKey);
+ },
+ [discardPreview],
+ );
+
+ function handlePreviewValue(nextValue: NormalizedCubicBezier) {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const nextAnimations = applyGraphEditorCurvePreview({
+ animations: state.element.animations,
+ context: state.context,
+ cubicBezier: nextValue,
+ });
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: state.trackId,
+ elementId: state.elementId,
+ updates: {
+ animations: nextAnimations,
+ },
+ },
+ ],
+ });
+ hasPreviewRef.current = true;
+ }
+
+ function handleCommitValue(nextValue: NormalizedCubicBezier) {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const patches = buildGraphEditorCurvePatches({
+ context: state.context,
+ cubicBezier: nextValue,
+ });
+ if (!patches) {
+ return;
+ }
+
+ editor.timeline.updateKeyframeCurves({
+ keyframes: patches.map(({ keyframeId, patch }) => ({
+ trackId: state.trackId,
+ elementId: state.elementId,
+ propertyPath: state.propertyPath,
+ componentKey: state.context.componentKey,
+ keyframeId,
+ patch,
+ })),
+ });
+ hasPreviewRef.current = false;
+ }
+
+ return {
+ open,
+ onOpenChange: handleOpenChange,
+ canOpen: state.status === "ready",
+ tooltip: state.status === "ready" ? "Open graph editor" : state.message,
+ state,
+ onActiveComponentKeyChange: handleActiveComponentKeyChange,
+ onPreviewValue: handlePreviewValue,
+ onCommitValue: handleCommitValue,
+ onCancelPreview: discardPreview,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 25218d78..1d3606e6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -292,10 +292,7 @@ export function TimelineElement({
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
- canToggleSourceAudio({
- element,
- mediaAsset,
- });
+ canToggleSourceAudio(element, mediaAsset);
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index ecaf37e2..80ea10d0 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -14,9 +14,7 @@ import {
SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
-import {
- TIMELINE_ZOOM_BUTTON_FACTOR,
-} from "./interaction";
+import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
@@ -36,7 +34,6 @@ import {
SnowIcon,
ScissorIcon,
MagnetIcon,
- Link04Icon,
SearchAddIcon,
SearchMinusIcon,
Copy01Icon,
@@ -49,6 +46,9 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
+import { GraphEditorPopover } from "./graph-editor/popover";
+import { PopoverTrigger } from "@/components/ui/popover";
+import { useGraphEditorController } from "./graph-editor/use-controller";
export function TimelineToolbar({
zoomLevel,
@@ -62,10 +62,7 @@ export function TimelineToolbar({
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
const newZoomLevel =
direction === "in"
- ? Math.min(
- TIMELINE_ZOOM_MAX,
- zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
- )
+ ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel });
};
@@ -90,8 +87,11 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
- const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
+ const mediaAssets = useEditor((currentEditor) =>
+ currentEditor.media.getAssets(),
+ );
const { selectedElements } = useElementSelection();
+ const graphEditor = useGraphEditorController();
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
@@ -115,10 +115,7 @@ function ToolbarLeftSection() {
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
- canToggleSourceAudio({
- element: selectedElement.element,
- mediaAsset: selectedMediaAsset,
- });
+ canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
@@ -166,11 +163,11 @@ function ToolbarLeftSection() {
/>
- }
+ icon={
+
+ }
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
@@ -214,13 +211,35 @@ function ToolbarLeftSection() {
/>
-
+
}
- tooltip="Open graph editor"
- onClick={() => {}}
+ tooltip={graphEditor.tooltip}
+ disabled={!graphEditor.canOpen}
+ buttonWrapper={(button) =>
+ graphEditor.canOpen ? (
+ {button}
+ ) : (
+ button
+ )
+ }
/>
-
+
);
@@ -317,29 +336,40 @@ function ToolbarButton({
onClick,
disabled,
isActive,
+ buttonWrapper,
}: {
icon: React.ReactNode;
tooltip: string;
- onClick: ({ event }: { event: React.MouseEvent }) => void;
+ onClick?: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
isActive?: boolean;
+ buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
}) {
+ const button = (
+ onClick({ event }) : undefined}
+ className={cn(
+ "rounded-sm",
+ disabled ? "cursor-not-allowed opacity-50" : "",
+ )}
+ >
+ {icon}
+
+ );
+ const trigger = disabled ? (
+ {button}
+ ) : buttonWrapper ? (
+ buttonWrapper(button)
+ ) : (
+ button
+ );
+
return (
-
- onClick({ event })}
- className={cn(
- "rounded-sm",
- disabled ? "cursor-not-allowed opacity-50" : "",
- )}
- >
- {icon}
-
-
+ {trigger}
{tooltip}
);
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index e436e876..5e70f88d 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
;
+ }): void {
+ if (keyframes.length === 0) {
+ return;
+ }
+
+ const commands = keyframes.map(
+ ({
+ trackId,
+ elementId,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+ }) =>
+ new UpdateScalarKeyframeCurveCommand({
+ trackId,
+ elementId,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+ }),
+ );
+ const command =
+ commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ this.editor.command.execute({ command });
+ }
+
upsertEffectParamKeyframe({
trackId,
elementId,
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index be2995dd..76f705e9 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -292,12 +292,7 @@ export function useEditorActions() {
null
);
})();
- if (
- !canToggleSourceAudio({
- element: selectedElement.element,
- mediaAsset,
- })
- ) {
+ if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
return;
}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index bbe843f2..b66bbea8 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -6,6 +6,7 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor } from "@/hooks/use-editor";
+import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
@@ -84,10 +85,11 @@ export function useKeyframeDrag({
deltaTime: number;
}) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
- const channel = element.animations?.channels[keyframeRef.propertyPath];
- const keyframe = channel?.keyframes.find(
- (keyframe) => keyframe.id === keyframeRef.keyframeId,
- );
+ const keyframe = getKeyframeById({
+ animations: element.animations,
+ propertyPath: keyframeRef.propertyPath,
+ keyframeId: keyframeRef.keyframeId,
+ });
if (!keyframe) return [];
const nextTime = Math.max(
0,
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index af4bb69f..659aa598 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,619 +1,629 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import {
- getVisibleElementsWithBounds,
- type ElementWithBounds,
-} from "@/lib/preview/element-bounds";
-import {
- MIN_SCALE,
- SNAP_THRESHOLD_SCREEN_PIXELS,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
- setChannel,
-} from "@/lib/animation";
-import type { Transform } from "@/lib/rendering";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type Edge = "right" | "left" | "bottom";
-type HandleType = Corner | Edge | "rotation";
-
-function getPreferredEdge({
- edge,
-}: {
- edge: Edge;
-}): ScaleEdgePreference {
- return edge === "right"
- ? { right: true }
- : edge === "left"
- ? { left: true }
- : { bottom: true };
-}
-
-interface ScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialDistance: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-interface RotationState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialAngle: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
-}
-
-interface EdgeScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- edge: Edge;
- rotationRad: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-function clampScaleNonZero(scale: number): number {
- if (Math.abs(scale) < MIN_SCALE) {
- return scale < 0 ? -MIN_SCALE : MIN_SCALE;
- }
- return scale;
-}
-
-function getCornerDistance({
- bounds,
- corner,
-}: {
- bounds: {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
- };
- corner: Corner;
-}): number {
- const halfWidth = bounds.width / 2;
- const halfHeight = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
-
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
-
- const rotatedX = localX * cos - localY * sin;
- const rotatedY = localX * sin + localY * cos;
- return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
-}
-
-export function useTransformHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [activeHandle, setActiveHandle] = useState(null);
- const scaleStateRef = useRef(null);
- const rotationStateRef = useRef(null);
- const edgeScaleStateRef = useRef(null);
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
- null,
- );
-
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const tracks = useEditor((e) => e.timeline.getRenderTracks());
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const currentTimeRef = useRef(currentTime);
- currentTimeRef.current = currentTime;
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.project.getActive().settings.canvasSize,
- );
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const selectedWithBounds: ElementWithBounds | null =
- selectedElements.length === 1
- ? (elementsWithBounds.find(
- (entry) =>
- entry.trackId === selectedElements[0].trackId &&
- entry.elementId === selectedElements[0].elementId,
- ) ?? null)
- : null;
-
- const hasVisualSelection =
- selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
-
- const clearActiveHandleState = useCallback(() => {
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- edgeScaleStateRef.current = null;
- setActiveHandle(null);
- onSnapLinesChange?.([]);
- }, [onSnapLinesChange]);
-
- const releaseCapturedPointer = useCallback(() => {
- const capture = captureRef.current;
- if (!capture) return;
-
- if (capture.element.hasPointerCapture(capture.pointerId)) {
- capture.element.releasePointerCapture(capture.pointerId);
- }
-
- captureRef.current = null;
- }, []);
-
- useEffect(() => {
- if (!activeHandle) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearActiveHandleState();
- releaseCapturedPointer();
- },
- });
- }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
-
- const handleCornerPointerDown = useCallback(
- ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const shouldClearScaleAnimation =
- !!element.animations?.channels["transform.scaleX"] ||
- !!element.animations?.channels["transform.scaleY"];
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: setChannel({
- animations: element.animations,
- propertyPath: "transform.scaleX",
- channel: undefined,
- }),
- propertyPath: "transform.scaleY",
- channel: undefined,
- })
- : element.animations;
-
- scaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialDistance,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(corner);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handleRotationPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
-
- rotationStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialAngle,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- };
- setActiveHandle("rotation");
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds, viewport],
- );
-
- const handleEdgePointerDown = useCallback(
- ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const rotationRad = (bounds.rotation * Math.PI) / 180;
-
- const propertyPath =
- edge === "right" || edge === "left"
- ? "transform.scaleX"
- : "transform.scaleY";
- const shouldClearScaleAnimation =
- !!element.animations?.channels[propertyPath];
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: element.animations,
- propertyPath,
- channel: undefined,
- })
- : element.animations;
-
- edgeScaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(edge);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handlePointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (
- !scaleStateRef.current &&
- !rotationStateRef.current &&
- !edgeScaleStateRef.current
- )
- return;
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
-
- if (
- scaleStateRef.current &&
- activeHandle &&
- activeHandle !== "rotation"
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialDistance,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = scaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance =
- Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
- const scaleFactor = currentDistance / initialDistance;
-
- // Use actual element dimensions (base * current scale) so snap
- // computes the correct edges when scaleX ≠scaleY
- const effectiveWidth = baseWidth * initialTransform.scaleX;
- const effectiveHeight = baseHeight * initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { snappedScale: snappedFactor, activeLines } =
- isShiftHeldRef.current
- ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
- : snapScale({
- proposedScale: scaleFactor,
- position: initialTransform.position,
- baseWidth: effectiveWidth,
- baseHeight: effectiveHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- });
-
- onSnapLinesChange?.(activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX: clampScaleNonZero(
- initialTransform.scaleX * snappedFactor,
- ),
- scaleY: clampScaleNonZero(
- initialTransform.scaleY * snappedFactor,
- ),
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (
- edgeScaleStateRef.current &&
- (activeHandle === "right" ||
- activeHandle === "left" ||
- activeHandle === "bottom")
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = edgeScaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const xProjection =
- deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
- const yProjection =
- -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
- const projection =
- edge === "right"
- ? xProjection
- : edge === "left"
- ? -xProjection
- : yProjection;
-
- const baseAxisHalf =
- edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
- const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
-
- const proposedScaleX =
- edge === "right" || edge === "left"
- ? proposedScale
- : initialTransform.scaleX;
- const proposedScaleY =
- edge === "bottom" ? proposedScale : initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { x: xSnap, y: ySnap } = isShiftHeldRef.current
- ? {
- x: {
- snappedScale: proposedScaleX,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- y: {
- snappedScale: proposedScaleY,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- }
- : snapScaleAxes({
- proposedScaleX,
- proposedScaleY,
- position: initialTransform.position,
- baseWidth,
- baseHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- preferredEdges: getPreferredEdge({ edge }),
- });
-
- const relevantSnap =
- edge === "right" || edge === "left" ? xSnap : ySnap;
- onSnapLinesChange?.(relevantSnap.activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX:
- edge === "right" || edge === "left"
- ? xSnap.snappedScale
- : initialTransform.scaleX,
- scaleY:
- edge === "bottom"
- ? ySnap.snappedScale
- : initialTransform.scaleY,
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (rotationStateRef.current && activeHandle === "rotation") {
- const {
- trackId,
- elementId,
- initialTransform,
- initialAngle,
- initialBoundsCx,
- initialBoundsCy,
- } = rotationStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
- let deltaAngle = currentAngle - initialAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
- const newRotate = initialTransform.rotate + deltaAngle;
- const { snappedRotation } = isShiftHeldRef.current
- ? { snappedRotation: newRotate }
- : snapRotation({ proposedRotation: newRotate });
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: { ...initialTransform, rotate: snappedRotation },
- },
- },
- ],
- });
- }
- },
- [
- activeHandle,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
-
- const handlePointerUp = useCallback(() => {
- if (
- scaleStateRef.current ||
- rotationStateRef.current ||
- edgeScaleStateRef.current
- ) {
- editor.timeline.commitPreview();
- clearActiveHandleState();
- }
- releaseCapturedPointer();
- },
- [clearActiveHandleState, editor, releaseCapturedPointer],
- );
-
- return {
- selectedWithBounds,
- hasVisualSelection,
- activeHandle,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
- };
-}
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import {
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import {
+ MIN_SCALE,
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ getElementLocalTime,
+ hasKeyframesForPath,
+ resolveTransformAtTime,
+ setChannel,
+} from "@/lib/animation";
+import type { Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+type Edge = "right" | "left" | "bottom";
+type HandleType = Corner | Edge | "rotation";
+
+function getPreferredEdge({
+ edge,
+}: {
+ edge: Edge;
+}): ScaleEdgePreference {
+ return edge === "right"
+ ? { right: true }
+ : edge === "left"
+ ? { left: true }
+ : { bottom: true };
+}
+
+interface ScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialDistance: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+}
+
+interface RotationState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialAngle: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+}
+
+interface EdgeScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+ edge: Edge;
+ rotationRad: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+}
+
+function clampScaleNonZero(scale: number): number {
+ if (Math.abs(scale) < MIN_SCALE) {
+ return scale < 0 ? -MIN_SCALE : MIN_SCALE;
+ }
+ return scale;
+}
+
+function getCornerDistance({
+ bounds,
+ corner,
+}: {
+ bounds: {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+ };
+ corner: Corner;
+}): number {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
+
+ const rotatedX = localX * cos - localY * sin;
+ const rotatedY = localX * sin + localY * cos;
+ return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
+}
+
+export function useTransformHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandle, setActiveHandle] = useState(null);
+ const scaleStateRef = useRef(null);
+ const rotationStateRef = useRef(null);
+ const edgeScaleStateRef = useRef(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithBounds: ElementWithBounds | null =
+ selectedElements.length === 1
+ ? (elementsWithBounds.find(
+ (entry) =>
+ entry.trackId === selectedElements[0].trackId &&
+ entry.elementId === selectedElements[0].elementId,
+ ) ?? null)
+ : null;
+
+ const hasVisualSelection =
+ selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+
+ const clearActiveHandleState = useCallback(() => {
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ edgeScaleStateRef.current = null;
+ setActiveHandle(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandle) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearActiveHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
+
+ const handleCornerPointerDown = useCallback(
+ ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const initialDistance = getCornerDistance({ bounds, corner });
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const shouldClearScaleAnimation =
+ hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ }) ||
+ hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ });
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
+ channel: undefined,
+ })
+ : element.animations;
+
+ scaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialDistance,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(corner);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
+ );
+
+ const handleRotationPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+
+ rotationStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialAngle,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ };
+ setActiveHandle("rotation");
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds, viewport],
+ );
+
+ const handleEdgePointerDown = useCallback(
+ ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const rotationRad = (bounds.rotation * Math.PI) / 180;
+
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+ const shouldClearScaleAnimation =
+ hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath,
+ });
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : element.animations;
+
+ edgeScaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(edge);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
+
+ const position = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+
+ if (
+ scaleStateRef.current &&
+ activeHandle &&
+ activeHandle !== "rotation"
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialDistance,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = scaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance =
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const scaleFactor = currentDistance / initialDistance;
+
+ // Use actual element dimensions (base * current scale) so snap
+ // computes the correct edges when scaleX ≠scaleY
+ const effectiveWidth = baseWidth * initialTransform.scaleX;
+ const effectiveHeight = baseHeight * initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { snappedScale: snappedFactor, activeLines } =
+ isShiftHeldRef.current
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX: clampScaleNonZero(
+ initialTransform.scaleX * snappedFactor,
+ ),
+ scaleY: clampScaleNonZero(
+ initialTransform.scaleY * snappedFactor,
+ ),
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (
+ edgeScaleStateRef.current &&
+ (activeHandle === "right" ||
+ activeHandle === "left" ||
+ activeHandle === "bottom")
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = edgeScaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
+ const projection =
+ edge === "right"
+ ? xProjection
+ : edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ edge === "right" || edge === "left"
+ ? proposedScale
+ : initialTransform.scaleX;
+ const proposedScaleY =
+ edge === "bottom" ? proposedScale : initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = isShiftHeldRef.current
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position: initialTransform.position,
+ baseWidth,
+ baseHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ preferredEdges: getPreferredEdge({ edge }),
+ });
+
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (rotationStateRef.current && activeHandle === "rotation") {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialAngle,
+ initialBoundsCx,
+ initialBoundsCy,
+ } = rotationStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ let deltaAngle = currentAngle - initialAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+ const newRotate = initialTransform.rotate + deltaAngle;
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, rotate: snappedRotation },
+ },
+ },
+ ],
+ });
+ }
+ },
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
+ editor.timeline.commitPreview();
+ clearActiveHandleState();
+ }
+ releaseCapturedPointer();
+ },
+ [clearActiveHandleState, editor, releaseCapturedPointer],
+ );
+
+ return {
+ selectedWithBounds,
+ hasVisualSelection,
+ activeHandle,
+ handleCornerPointerDown,
+ handleEdgePointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts
new file mode 100644
index 00000000..c5a354e4
--- /dev/null
+++ b/apps/web/src/lib/animation/__tests__/binding-values.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import {
+ composeAnimationValue,
+ createAnimationBinding,
+} from "@/lib/animation/binding-values";
+
+describe("binding values", () => {
+ test("formats composed animated colors as hex", () => {
+ const binding = createAnimationBinding({
+ path: "color",
+ kind: "color",
+ });
+
+ expect(
+ composeAnimationValue({
+ binding,
+ componentValues: {
+ r: 1,
+ g: 0,
+ b: 0,
+ a: 1,
+ },
+ }),
+ ).toBe("#ff0000");
+ });
+});
diff --git a/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts
new file mode 100644
index 00000000..319493c8
--- /dev/null
+++ b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test";
+import {
+ getElementKeyframes,
+ getKeyframeById,
+ getKeyframeAtTime,
+} from "@/lib/animation/keyframe-query";
+import type {
+ ElementAnimations,
+ ScalarAnimationKey,
+} from "@/lib/animation/types";
+
+function createScalarKey({
+ id,
+ time,
+ value,
+}: {
+ id: string;
+ time: number;
+ value: number;
+}): ScalarAnimationKey {
+ return {
+ id,
+ time,
+ value,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ };
+}
+
+function buildPositionAnimations({
+ xKeys,
+ yKeys,
+}: {
+ xKeys: ScalarAnimationKey[];
+ yKeys: ScalarAnimationKey[];
+}): ElementAnimations {
+ return {
+ bindings: {
+ "transform.position": {
+ path: "transform.position",
+ kind: "vector2",
+ components: [
+ { key: "x", channelId: "transform.position:x" },
+ { key: "y", channelId: "transform.position:y" },
+ ],
+ },
+ },
+ channels: {
+ "transform.position:x": {
+ kind: "scalar",
+ keys: xKeys,
+ },
+ "transform.position:y": {
+ kind: "scalar",
+ keys: yKeys,
+ },
+ },
+ };
+}
+
+describe("keyframe query", () => {
+ test("returns keyframes from any component channel", () => {
+ const animations = buildPositionAnimations({
+ xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
+ yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
+ });
+
+ expect(
+ getElementKeyframes({ animations }).map(({ id, time }) => ({
+ id,
+ time,
+ })),
+ ).toEqual([
+ { id: "x-1", time: 1 },
+ { id: "y-2", time: 2 },
+ ]);
+ });
+
+ test("finds a keyframe at time on a non-primary component", () => {
+ const animations = buildPositionAnimations({
+ xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
+ yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
+ });
+
+ expect(
+ getKeyframeAtTime({
+ animations,
+ propertyPath: "transform.position",
+ time: 2,
+ }),
+ ).toMatchObject({
+ id: "y-2",
+ time: 2,
+ });
+ });
+
+ test("finds a keyframe by id on a non-primary component", () => {
+ const animations = buildPositionAnimations({
+ xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
+ yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
+ });
+
+ expect(
+ getKeyframeById({
+ animations,
+ propertyPath: "transform.position",
+ keyframeId: "y-2",
+ }),
+ ).toMatchObject({
+ id: "y-2",
+ time: 2,
+ value: { x: 10, y: 20 },
+ });
+ });
+
+ test("prefers the primary component when multiple components share a time", () => {
+ const animations = buildPositionAnimations({
+ xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
+ yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })],
+ });
+
+ expect(
+ getElementKeyframes({ animations }).map(({ id, time }) => ({
+ id,
+ time,
+ })),
+ ).toEqual([{ id: "x-1", time: 1 }]);
+ expect(
+ getKeyframeAtTime({
+ animations,
+ propertyPath: "transform.position",
+ time: 1,
+ }),
+ ).toMatchObject({
+ id: "x-1",
+ time: 1,
+ });
+ });
+});
diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts
new file mode 100644
index 00000000..4dce027e
--- /dev/null
+++ b/apps/web/src/lib/animation/bezier.ts
@@ -0,0 +1,88 @@
+import type { ScalarAnimationKey } from "@/lib/animation/types";
+
+export function getBezierPoint({
+ progress,
+ p0,
+ p1,
+ p2,
+ p3,
+}: {
+ progress: number;
+ p0: number;
+ p1: number;
+ p2: number;
+ p3: number;
+}) {
+ const mt = 1 - progress;
+ return (
+ mt * mt * mt * p0 +
+ 3 * mt * mt * progress * p1 +
+ 3 * mt * progress * progress * p2 +
+ progress * progress * progress * p3
+ );
+}
+
+export function getDefaultRightHandle({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ const span = rightKey.time - leftKey.time;
+ const valueDelta = rightKey.value - leftKey.value;
+ return {
+ dt: span / 3,
+ dv: valueDelta / 3,
+ };
+}
+
+export function getDefaultLeftHandle({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ const span = rightKey.time - leftKey.time;
+ const valueDelta = rightKey.value - leftKey.value;
+ return {
+ dt: -span / 3,
+ dv: -valueDelta / 3,
+ };
+}
+
+export function solveBezierProgressForTime({
+ time,
+ leftKey,
+ rightKey,
+}: {
+ time: number;
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ let lower = 0;
+ let upper = 1;
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+
+ for (let iteration = 0; iteration < 20; iteration++) {
+ const mid = (lower + upper) / 2;
+ const estimate = getBezierPoint({
+ progress: mid,
+ p0: leftKey.time,
+ p1: leftKey.time + rightHandle.dt,
+ p2: rightKey.time + leftHandle.dt,
+ p3: rightKey.time,
+ });
+ if (estimate < time) {
+ lower = mid;
+ } else {
+ upper = mid;
+ }
+ }
+
+ return (lower + upper) / 2;
+}
diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts
new file mode 100644
index 00000000..6f7ce995
--- /dev/null
+++ b/apps/web/src/lib/animation/binding-values.ts
@@ -0,0 +1,335 @@
+import { converter, formatHex, formatHex8, parse } from "culori";
+import type {
+ AnimationBindingComponent,
+ AnimationBindingOfKind,
+ AnimationBindingInstance,
+ AnimationBindingKind,
+ ColorAnimationBinding,
+ DiscreteAnimationBinding,
+ NumberAnimationBinding,
+ AnimationPath,
+ AnimationValue,
+ DiscreteValue,
+ Vector2AnimationBinding,
+ VectorValue,
+} from "@/lib/animation/types";
+
+interface LinearRgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export type AnimationComponentValue = number | DiscreteValue;
+
+const toRgb = converter("rgb");
+
+function clamp01({ value }: { value: number }): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+function srgbToLinear({ value }: { value: number }): number {
+ return value <= 0.04045
+ ? value / 12.92
+ : Math.pow((value + 0.055) / 1.055, 2.4);
+}
+
+function linearToSrgb({ value }: { value: number }): number {
+ const clamped = clamp01({ value });
+ return clamped <= 0.0031308
+ ? clamped * 12.92
+ : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
+}
+
+export function getBindingComponentKeys({
+ kind,
+}: {
+ kind: AnimationBindingKind;
+}): string[] {
+ if (kind === "vector2") {
+ return ["x", "y"];
+ }
+
+ if (kind === "color") {
+ return ["r", "g", "b", "a"];
+ }
+
+ return ["value"];
+}
+
+export function buildBindingChannelId({
+ path,
+ componentKey,
+}: {
+ path: AnimationPath;
+ componentKey: string;
+}): string {
+ return `${path}:${componentKey}`;
+}
+
+function createBindingComponent({
+ path,
+ key,
+}: {
+ path: AnimationPath;
+ key: TKey;
+}): AnimationBindingComponent {
+ return {
+ key,
+ channelId: buildBindingChannelId({ path, componentKey: key }),
+ };
+}
+
+function cloneBindingComponents({
+ components,
+}: {
+ components: AnimationBindingComponent[];
+}): AnimationBindingComponent[] {
+ return components.map((component) => ({ ...component }));
+}
+
+const animationBindingFactories = {
+ color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
+ path,
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ createBindingComponent({ path, key: "r" }),
+ createBindingComponent({ path, key: "g" }),
+ createBindingComponent({ path, key: "b" }),
+ createBindingComponent({ path, key: "a" }),
+ ],
+ }),
+ vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
+ path,
+ kind: "vector2",
+ components: [
+ createBindingComponent({ path, key: "x" }),
+ createBindingComponent({ path, key: "y" }),
+ ],
+ }),
+ number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
+ path,
+ kind: "number",
+ components: [createBindingComponent({ path, key: "value" })],
+ }),
+ discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
+ path,
+ kind: "discrete",
+ components: [createBindingComponent({ path, key: "value" })],
+ }),
+} satisfies {
+ [K in AnimationBindingKind]: ({
+ path,
+ }: {
+ path: AnimationPath;
+ }) => AnimationBindingOfKind;
+};
+
+export function createAnimationBinding({
+ path,
+ kind,
+}: {
+ path: AnimationPath;
+ kind: TKind;
+}): AnimationBindingOfKind;
+export function createAnimationBinding({
+ path,
+ kind,
+}: {
+ path: AnimationPath;
+ kind: AnimationBindingKind;
+}): AnimationBindingInstance {
+ return animationBindingFactories[kind]({ path });
+}
+
+const animationBindingCloners = {
+ color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ vector2: ({
+ binding,
+ }: {
+ binding: Vector2AnimationBinding;
+ }): Vector2AnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ number: ({
+ binding,
+ }: {
+ binding: NumberAnimationBinding;
+ }): NumberAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ discrete: ({
+ binding,
+ }: {
+ binding: DiscreteAnimationBinding;
+ }): DiscreteAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+} satisfies {
+ [K in AnimationBindingKind]: ({
+ binding,
+ }: {
+ binding: AnimationBindingOfKind;
+ }) => AnimationBindingOfKind;
+};
+
+export function cloneAnimationBinding({
+ binding,
+}: {
+ binding: AnimationBindingOfKind;
+}): AnimationBindingOfKind;
+export function cloneAnimationBinding({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}): AnimationBindingInstance {
+ switch (binding.kind) {
+ case "color":
+ return animationBindingCloners.color({ binding });
+ case "vector2":
+ return animationBindingCloners.vector2({ binding });
+ case "number":
+ return animationBindingCloners.number({ binding });
+ case "discrete":
+ return animationBindingCloners.discrete({ binding });
+ }
+}
+
+export function parseColorToLinearRgba({
+ color,
+}: {
+ color: string;
+}): LinearRgba | null {
+ const parsed = parse(color);
+ const rgb = parsed ? toRgb(parsed) : null;
+ if (!rgb) {
+ return null;
+ }
+
+ return {
+ r: srgbToLinear({ value: rgb.r ?? 0 }),
+ g: srgbToLinear({ value: rgb.g ?? 0 }),
+ b: srgbToLinear({ value: rgb.b ?? 0 }),
+ a: clamp01({ value: rgb.alpha ?? 1 }),
+ };
+}
+
+export function formatLinearRgba({
+ color,
+}: {
+ color: LinearRgba;
+}): string {
+ const rgb = {
+ mode: "rgb",
+ r: linearToSrgb({ value: color.r }),
+ g: linearToSrgb({ value: color.g }),
+ b: linearToSrgb({ value: color.b }),
+ alpha: clamp01({ value: color.a }),
+ } as const;
+ return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
+}
+
+export function decomposeAnimationValue({
+ kind,
+ value,
+}: {
+ kind: AnimationBindingKind;
+ value: AnimationValue;
+}): Record | null {
+ if (kind === "number") {
+ return typeof value === "number" ? { value } : null;
+ }
+
+ if (kind === "vector2") {
+ return isVectorValue(value) ? { x: value.x, y: value.y } : null;
+ }
+
+ if (kind === "color") {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const parsed = parseColorToLinearRgba({ color: value });
+ if (!parsed) {
+ return null;
+ }
+ return {
+ r: parsed.r,
+ g: parsed.g,
+ b: parsed.b,
+ a: parsed.a,
+ };
+ }
+
+ return typeof value === "string" || typeof value === "boolean"
+ ? { value }
+ : null;
+}
+
+export function composeAnimationValue({
+ binding,
+ componentValues,
+}: {
+ binding: AnimationBindingInstance;
+ componentValues: Record;
+}): AnimationValue | null {
+ if (binding.kind === "number") {
+ const value = componentValues.value;
+ return typeof value === "number" ? value : null;
+ }
+
+ if (binding.kind === "vector2") {
+ const x = componentValues.x;
+ const y = componentValues.y;
+ return typeof x === "number" && typeof y === "number" ? { x, y } : null;
+ }
+
+ if (binding.kind === "color") {
+ const r = componentValues.r;
+ const g = componentValues.g;
+ const b = componentValues.b;
+ const a = componentValues.a;
+ if (
+ typeof r !== "number" ||
+ typeof g !== "number" ||
+ typeof b !== "number" ||
+ typeof a !== "number"
+ ) {
+ return null;
+ }
+ return formatLinearRgba({
+ color: {
+ r,
+ g,
+ b,
+ a,
+ },
+ });
+ }
+
+ const value = componentValues.value;
+ return typeof value === "string" || typeof value === "boolean" ? value : null;
+}
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
deleted file mode 100644
index c1c05f9a..00000000
--- a/apps/web/src/lib/animation/color-channel.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type {
- AnimationPropertyPath,
- ColorAnimationChannel,
- ElementAnimations,
-} from "@/lib/animation/types";
-
-export function getColorChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): ColorAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "color") {
- return undefined;
- }
- return channel;
-}
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
new file mode 100644
index 00000000..55f96933
--- /dev/null
+++ b/apps/web/src/lib/animation/curve-bridge.ts
@@ -0,0 +1,82 @@
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import {
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+} from "@/lib/animation/bezier";
+import type {
+ CurveHandle,
+ NormalizedCubicBezier,
+ ScalarAnimationKey,
+} from "@/lib/animation/types";
+
+const VALUE_EPSILON = 1e-6;
+
+function clamp01({ value }: { value: number }): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+export function getNormalizedCubicBezierForScalarSegment({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}): NormalizedCubicBezier | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ if (
+ Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ Math.abs(spanValue) <= VALUE_EPSILON
+ ) {
+ return null;
+ }
+
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+
+ return [
+ clamp01({ value: rightHandle.dt / spanTime }),
+ rightHandle.dv / spanValue,
+ clamp01({ value: 1 + leftHandle.dt / spanTime }),
+ 1 + leftHandle.dv / spanValue,
+ ];
+}
+
+export function getCurveHandlesForNormalizedCubicBezier({
+ leftKey,
+ rightKey,
+ cubicBezier,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+ cubicBezier: NormalizedCubicBezier;
+}): {
+ rightHandle: CurveHandle;
+ leftHandle: CurveHandle;
+} | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ if (
+ Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ Math.abs(spanValue) <= VALUE_EPSILON
+ ) {
+ return null;
+ }
+
+ const [rawX1, y1, rawX2, y2] = cubicBezier;
+ const x1 = clamp01({ value: rawX1 });
+ const x2 = clamp01({ value: rawX2 });
+
+ return {
+ rightHandle: {
+ dt: spanTime * x1,
+ dv: spanValue * y1,
+ },
+ leftHandle: {
+ dt: spanTime * (x2 - 1),
+ dv: spanValue * (y2 - 1),
+ },
+ };
+}
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
index a614c14f..3080e6ff 100644
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
EffectParamPath,
- NumberAnimationChannel,
} from "@/lib/animation/types";
-import {
- getChannel,
- removeKeyframe,
- setChannel,
- upsertKeyframe,
-} from "./keyframes";
-import { getChannelValueAtTime } from "./interpolation";
+import { removeElementKeyframe } from "./keyframes";
+import { resolveAnimationPathValueAtTime } from "./resolve";
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
@@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
- const channel = getChannel({ animations, propertyPath: path });
- if (channel && channel.keyframes.length > 0) {
- resolved[paramKey] = getChannelValueAtTime({
- channel,
- time: localTime,
- fallbackValue: staticValue,
- }) as number | string | boolean;
- } else {
- resolved[paramKey] = staticValue;
- }
+ resolved[paramKey] = animations?.bindings[path]
+ ? resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: path,
+ localTime,
+ fallbackValue: staticValue,
+ })
+ : staticValue;
}
return resolved;
}
-const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
- valueKind: "number",
- keyframes: [],
-};
-
-export function upsertEffectParamKeyframe({
- animations,
- effectId,
- paramKey,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- effectId: string;
- paramKey: string;
- time: number;
- value: number;
- interpolation?: "linear" | "hold";
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const path = buildEffectParamPath({ effectId, paramKey });
- const channel = getChannel({ animations, propertyPath: path });
- const targetChannel =
- channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
- const updatedChannel = upsertKeyframe({
- channel: targetChannel,
- time,
- value,
- interpolation: interpolation ?? "linear",
- keyframeId,
- });
-
- return (
- setChannel({
- animations,
- propertyPath: path,
- channel: updatedChannel,
- }) ?? { channels: {} }
- );
-}
-
export function removeEffectParamKeyframe({
animations,
effectId,
@@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
- const path = buildEffectParamPath({ effectId, paramKey });
- const channel = getChannel({ animations, propertyPath: path });
- const updatedChannel = removeKeyframe({ channel, keyframeId });
- return setChannel({
+ return removeElementKeyframe({
animations,
- propertyPath: path,
- channel: updatedChannel,
+ propertyPath: buildEffectParamPath({ effectId, paramKey }),
+ keyframeId,
});
}
diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts
new file mode 100644
index 00000000..240b00ed
--- /dev/null
+++ b/apps/web/src/lib/animation/graph-channels.ts
@@ -0,0 +1,95 @@
+import type {
+ AnimationPath,
+ ElementAnimations,
+ ScalarAnimationChannel,
+ ScalarGraphChannel,
+ ScalarGraphKeyframeContext,
+} from "@/lib/animation/types";
+
+function isScalarAnimationChannel(
+ channel: ElementAnimations["channels"][string],
+): channel is ScalarAnimationChannel {
+ return channel?.kind === "scalar";
+}
+
+export function getEditableScalarChannels({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): ScalarGraphChannel[] {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return [];
+ }
+
+ return binding.components.flatMap((component) => {
+ const channel = animations?.channels[component.channelId];
+ if (!isScalarAnimationChannel(channel)) {
+ return [];
+ }
+
+ return [
+ {
+ propertyPath,
+ componentKey: component.key,
+ channelId: component.channelId,
+ channel,
+ },
+ ];
+ });
+}
+
+export function getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+}): ScalarGraphChannel | null {
+ return (
+ getEditableScalarChannels({
+ animations,
+ propertyPath,
+ }).find((channel) => channel.componentKey === componentKey) ?? null
+ );
+}
+
+export function getScalarKeyframeContext({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+}): ScalarGraphKeyframeContext | null {
+ const scalarChannel = getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ });
+ if (!scalarChannel) {
+ return null;
+ }
+
+ const keyframeIndex = scalarChannel.channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeIndex < 0) {
+ return null;
+ }
+
+ return {
+ ...scalarChannel,
+ keyframe: scalarChannel.channel.keys[keyframeIndex],
+ keyframeIndex,
+ previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
+ nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
+ };
+}
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
index 22f78ba5..de595060 100644
--- a/apps/web/src/lib/animation/graphic-param-channel.ts
+++ b/apps/web/src/lib/animation/graphic-param-channel.ts
@@ -1,77 +1,73 @@
-import type {
- ElementAnimations,
- GraphicParamPath,
-} from "@/lib/animation/types";
-import type { ParamValues } from "@/lib/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/lib/graphics";
-import { getChannel } from "./keyframes";
-import { getChannelValueAtTime } from "./interpolation";
-
-export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
-
-export function buildGraphicParamPath({
- paramKey,
-}: {
- paramKey: string;
-}): GraphicParamPath {
- return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
-}
-
-export function isGraphicParamPath(
- propertyPath: string,
-): propertyPath is GraphicParamPath {
- return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
-}
-
-export function parseGraphicParamPath({
- propertyPath,
-}: {
- propertyPath: string;
-}): { paramKey: string } | null {
- if (!isGraphicParamPath(propertyPath)) {
- return null;
- }
-
- const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
- return paramKey.length > 0 ? { paramKey } : null;
-}
-
-export function resolveGraphicParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const baseParams = resolveGraphicParams(definition, element.params);
- const resolved: ParamValues = { ...baseParams };
-
- for (const param of definition.params) {
- const path = buildGraphicParamPath({ paramKey: param.key });
- const channel = getChannel({
- animations: element.animations,
- propertyPath: path,
- });
- if (!channel || channel.keyframes.length === 0) {
- continue;
- }
-
- resolved[param.key] = getChannelValueAtTime({
- channel,
- time: Math.max(0, localTime),
- fallbackValue: baseParams[param.key] ?? param.default,
- }) as number | string | boolean;
- }
-
- return resolved;
-}
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/lib/animation/types";
+import type { ParamValues } from "@/lib/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/lib/graphics";
+import { resolveAnimationPathValueAtTime } from "./resolve";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath(
+ propertyPath: string,
+): propertyPath is GraphicParamPath {
+ return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
+}
+
+export function parseGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { paramKey: string } | null {
+ if (!isGraphicParamPath(propertyPath)) {
+ return null;
+ }
+
+ const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
+ return paramKey.length > 0 ? { paramKey } : null;
+}
+
+export function resolveGraphicParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const baseParams = resolveGraphicParams(definition, element.params);
+ const resolved: ParamValues = { ...baseParams };
+
+ for (const param of definition.params) {
+ const path = buildGraphicParamPath({ paramKey: param.key });
+ if (!element.animations?.bindings[path]) {
+ continue;
+ }
+
+ resolved[param.key] = resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath: path,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ });
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index 819a59e5..7d2646d9 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -1,71 +1,94 @@
-export {
- getChannelValueAtTime,
- getNumberChannelValueAtTime,
- getVectorChannelValueAtTime,
- normalizeChannel,
-} from "./interpolation";
-
-export {
- clampAnimationsToDuration,
- cloneAnimations,
- getChannel,
- removeElementKeyframe,
- retimeElementKeyframe,
- setChannel,
- splitAnimationsAtTime,
- upsertElementKeyframe,
- upsertPathKeyframe,
-} from "./keyframes";
-
-export {
- getElementLocalTime,
- resolveColorAtTime,
- resolveNumberAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
-} from "./resolve";
-
-export {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getDefaultInterpolationForProperty,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- supportsAnimationProperty,
- type AnimationPropertyDefinition,
- type NumericSpec,
- type NumericRange,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export {
- getElementKeyframes,
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "./keyframe-query";
-
-export {
- buildGraphicParamPath,
- isGraphicParamPath,
- parseGraphicParamPath,
- resolveGraphicParamsAtTime,
-} from "./graphic-param-channel";
-
-export {
- isAnimationPath,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- type GroupKeyframeRef,
-} from "./property-groups";
-
-export {
- getVectorChannelForPath,
- isVectorValue,
-} from "./vector-channel";
+export {
+ getChannelValueAtTime,
+ getDiscreteChannelValueAtTime,
+ getScalarChannelValueAtTime,
+ getScalarSegmentInterpolation,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setBindingComponentChannel,
+ setChannel,
+ splitAnimationsAtTime,
+ updateScalarKeyframeCurve,
+ upsertElementKeyframe,
+ upsertPathKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ resolveAnimationPathValueAtTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "./resolve";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export {
+ getElementKeyframes,
+ getKeyframeById,
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "./keyframe-query";
+
+export {
+ getEditableScalarChannel,
+ getEditableScalarChannels,
+ getScalarKeyframeContext,
+} from "./graph-channels";
+
+export {
+ getCurveHandlesForNormalizedCubicBezier,
+ getNormalizedCubicBezierForScalarSegment,
+} from "./curve-bridge";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ buildEffectParamPath,
+ isEffectParamPath,
+ parseEffectParamPath,
+ removeEffectParamKeyframe,
+ resolveEffectParamsAtTime,
+} from "./effect-param-channel";
+
+export {
+ isAnimationPath,
+ coerceAnimationValueForParam,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ isVectorValue,
+} from "./binding-values";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index c5b9ea80..9609441c 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -1,15 +1,20 @@
import type {
AnimationChannel,
+ AnimationInterpolation,
AnimationValue,
- ColorAnimationChannel,
- DiscreteValue,
DiscreteAnimationChannel,
- NumberAnimationChannel,
- VectorAnimationChannel,
- VectorValue,
+ DiscreteValue,
+ ScalarAnimationChannel,
+ ScalarAnimationKey,
+ ScalarSegmentType,
} from "@/lib/animation/types";
-import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import {
+ getBezierPoint,
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+ solveBezierProgressForTime,
+} from "./bezier";
function byTimeAscending({
leftTime,
@@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value));
}
-function parseHexChannel({ hex }: { hex: string }): number | null {
- const value = Number.parseInt(hex, 16);
- return Number.isNaN(value) ? null : value;
-}
-
-function parseHexColor({
- color,
-}: {
- color: string;
-}): { red: number; green: number; blue: number; alpha: number } | null {
- const trimmed = color.trim();
- if (!trimmed.startsWith("#")) {
- return null;
- }
-
- const rawHex = trimmed.slice(1);
- if (rawHex.length === 3 || rawHex.length === 4) {
- const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
- const red = parseHexChannel({ hex: `${redHex}${redHex}` });
- const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
- const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
- const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
- if (red === null || green === null || blue === null || alpha === null) {
- return null;
- }
-
- return { red, green, blue, alpha: alpha / 255 };
- }
-
- if (rawHex.length === 6 || rawHex.length === 8) {
- const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
- const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
- const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
- const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
- const alpha = parseHexChannel({ hex: alphaHex });
- if (red === null || green === null || blue === null || alpha === null) {
- return null;
- }
-
- return { red, green, blue, alpha: alpha / 255 };
- }
-
- return null;
-}
-
-function formatRgbaColor({
- red,
- green,
- blue,
- alpha,
-}: {
- red: number;
- green: number;
- blue: number;
- alpha: number;
-}): string {
- const roundedRed = Math.round(red);
- const roundedGreen = Math.round(green);
- const roundedBlue = Math.round(blue);
- const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
- return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
-}
-
function lerpNumber({
leftValue,
rightValue,
@@ -115,43 +57,99 @@ function lerpNumber({
return leftValue + (rightValue - leftValue) * progress;
}
-function interpolateColor({
- leftColor,
- rightColor,
- progress,
+function normalizeRightHandle({
+ handle,
+ leftKey,
+ rightKey,
}: {
- leftColor: string;
- rightColor: string;
- progress: number;
-}): string {
- const leftParsed = parseHexColor({ color: leftColor });
- const rightParsed = parseHexColor({ color: rightColor });
- if (!leftParsed || !rightParsed) {
- return progress >= 1 ? rightColor : leftColor;
+ handle: ScalarAnimationKey["rightHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
}
- return formatRgbaColor({
- red: lerpNumber({
- leftValue: leftParsed.red,
- rightValue: rightParsed.red,
- progress,
- }),
- green: lerpNumber({
- leftValue: leftParsed.green,
- rightValue: rightParsed.green,
- progress,
- }),
- blue: lerpNumber({
- leftValue: leftParsed.blue,
- rightValue: rightParsed.blue,
- progress,
- }),
- alpha: lerpNumber({
- leftValue: leftParsed.alpha,
- rightValue: rightParsed.alpha,
- progress,
- }),
+ const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
+ return {
+ dt: Math.min(span, Math.max(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeLeftHandle({
+ handle,
+ leftKey,
+ rightKey,
+}: {
+ handle: ScalarAnimationKey["leftHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
+ }
+
+ const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
+ return {
+ dt: Math.max(-span, Math.min(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeScalarKey({
+ key,
+}: {
+ key: ScalarAnimationKey;
+}): ScalarAnimationKey {
+ return {
+ ...key,
+ tangentMode: key.tangentMode ?? "flat",
+ segmentToNext: key.segmentToNext ?? "linear",
+ };
+}
+
+function normalizeScalarChannel({
+ channel,
+}: {
+ channel: ScalarAnimationChannel;
+}): ScalarAnimationChannel {
+ const sortedKeys = [...channel.keys]
+ .map((key) => normalizeScalarKey({ key }))
+ .sort((leftKey, rightKey) =>
+ byTimeAscending({
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ }),
+ );
+ const nextKeys = sortedKeys.map((key, index) => {
+ const previousKey = sortedKeys[index - 1];
+ const nextKey = sortedKeys[index + 1];
+ return {
+ ...key,
+ leftHandle:
+ previousKey != null
+ ? normalizeLeftHandle({
+ handle: key.leftHandle,
+ leftKey: previousKey,
+ rightKey: key,
+ })
+ : undefined,
+ rightHandle:
+ nextKey != null
+ ? normalizeRightHandle({
+ handle: key.rightHandle,
+ leftKey: key,
+ rightKey: nextKey,
+ })
+ : undefined,
+ };
});
+
+ return {
+ ...channel,
+ keys: nextKeys,
+ };
}
export function normalizeChannel({
@@ -159,9 +157,15 @@ export function normalizeChannel({
}: {
channel: TChannel;
}): TChannel {
+ if (channel.kind === "scalar") {
+ return normalizeScalarChannel({
+ channel,
+ }) as TChannel;
+ }
+
return {
...channel,
- keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
+ keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({
leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time,
@@ -170,171 +174,152 @@ export function normalizeChannel({
} as TChannel;
}
-function evaluateChannelValueAtTime<
- TKeyframe extends { time: number; value: TValue },
- TValue,
->({
- keyframes,
+function extrapolateScalarEdge({
+ mode,
+ edgeKey,
+ neighborKey,
time,
- fallbackValue,
- getInterpolatedValue,
}: {
- keyframes: TKeyframe[] | undefined;
+ mode: "hold" | "linear";
+ edgeKey: ScalarAnimationKey;
+ neighborKey: ScalarAnimationKey | undefined;
time: number;
- fallbackValue: TValue;
- getInterpolatedValue: ({
- leftKeyframe,
- rightKeyframe,
- progress,
- }: {
- leftKeyframe: TKeyframe;
- rightKeyframe: TKeyframe;
- progress: number;
- }) => TValue;
-}): TValue {
- if (!keyframes || keyframes.length === 0) {
- return fallbackValue;
+}) {
+ if (mode === "hold" || !neighborKey) {
+ return edgeKey.value;
}
- const firstKeyframe = keyframes[0];
- const lastKeyframe = keyframes[keyframes.length - 1];
- if (!firstKeyframe || !lastKeyframe) {
- return fallbackValue;
+ const span = neighborKey.time - edgeKey.time;
+ if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ return edgeKey.value;
}
- if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
- return firstKeyframe.value;
- }
-
- if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
- return lastKeyframe.value;
- }
-
- for (
- let keyframeIndex = 0;
- keyframeIndex < keyframes.length - 1;
- keyframeIndex++
- ) {
- const leftKeyframe = keyframes[keyframeIndex];
- const rightKeyframe = keyframes[keyframeIndex + 1];
-
- if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
- return rightKeyframe.value;
- }
-
- const isBetweenPair = isWithinTimePair({
- time,
- leftTime: leftKeyframe.time,
- rightTime: rightKeyframe.time,
- });
- if (!isBetweenPair) {
- continue;
- }
-
- const span = rightKeyframe.time - leftKeyframe.time;
- if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
- return rightKeyframe.value;
- }
-
- const progress = clamp01({
- value: (time - leftKeyframe.time) / span,
- });
-
- return getInterpolatedValue({
- leftKeyframe,
- rightKeyframe,
- progress,
- });
- }
-
- return lastKeyframe.value;
+ return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
}
-export function getNumberChannelValueAtTime({
+export function getScalarSegmentInterpolation({
+ segment,
+}: {
+ segment: ScalarSegmentType;
+}): AnimationInterpolation {
+ if (segment === "step") {
+ return "hold";
+ }
+
+ return segment === "bezier" ? "bezier" : "linear";
+}
+
+export function getScalarChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
- channel: NumberAnimationChannel | undefined;
+ channel: ScalarAnimationChannel | undefined;
time: number;
fallbackValue: number;
}): number {
- return evaluateChannelValueAtTime({
- keyframes: channel?.keyframes,
- time,
- fallbackValue,
- getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
- if (leftKeyframe.interpolation === "hold") {
- return leftKeyframe.value;
- }
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+ const normalizedChannel = normalizeChannel({
+ channel,
+ });
+ const firstKey = normalizedChannel.keys[0];
+ const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
+ if (!firstKey || !lastKey) {
+ return fallbackValue;
+ }
+
+ if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
+ if (time < firstKey.time - TIME_EPSILON_SECONDS) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.before ?? "hold",
+ edgeKey: firstKey,
+ neighborKey: normalizedChannel.keys[1],
+ time,
+ });
+ }
+
+ return firstKey.value;
+ }
+
+ if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
+ if (time > lastKey.time + TIME_EPSILON_SECONDS) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.after ?? "hold",
+ edgeKey: lastKey,
+ neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
+ time,
+ });
+ }
+
+ return lastKey.value;
+ }
+
+ for (
+ let keyIndex = 0;
+ keyIndex < normalizedChannel.keys.length - 1;
+ keyIndex++
+ ) {
+ const leftKey = normalizedChannel.keys[keyIndex];
+ const rightKey = normalizedChannel.keys[keyIndex + 1];
+ if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
+ return rightKey.value;
+ }
+
+ if (
+ !isWithinTimePair({
+ time,
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ })
+ ) {
+ continue;
+ }
+
+ if (leftKey.segmentToNext === "step") {
+ return leftKey.value;
+ }
+
+ const span = rightKey.time - leftKey.time;
+ if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ return rightKey.value;
+ }
+
+ const progress = clamp01({
+ value: (time - leftKey.time) / span,
+ });
+ if (leftKey.segmentToNext === "linear") {
return lerpNumber({
- leftValue: leftKeyframe.value,
- rightValue: rightKeyframe.value,
+ leftValue: leftKey.value,
+ rightValue: rightKey.value,
progress,
});
- },
- });
+ }
+
+ const curveProgress = solveBezierProgressForTime({
+ time,
+ leftKey,
+ rightKey,
+ });
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+ return getBezierPoint({
+ progress: curveProgress,
+ p0: leftKey.value,
+ p1: leftKey.value + rightHandle.dv,
+ p2: rightKey.value + leftHandle.dv,
+ p3: rightKey.value,
+ });
+ }
+
+ return lastKey.value;
}
-export function getColorValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: ColorAnimationChannel | undefined;
- time: number;
- fallbackValue: string;
-}): string {
- return evaluateChannelValueAtTime({
- keyframes: channel?.keyframes,
- time,
- fallbackValue,
- getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
- if (leftKeyframe.interpolation === "hold") {
- return leftKeyframe.value;
- }
-
- return interpolateColor({
- leftColor: leftKeyframe.value,
- rightColor: rightKeyframe.value,
- progress,
- });
- },
- });
-}
-
-export function getVectorChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: VectorAnimationChannel | undefined;
- time: number;
- fallbackValue: VectorValue;
-}): VectorValue {
- return evaluateChannelValueAtTime({
- keyframes: channel?.keyframes,
- time,
- fallbackValue,
- getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
- if (leftKeyframe.interpolation === "hold") {
- return leftKeyframe.value;
- }
-
- return {
- x:
- leftKeyframe.value.x +
- (rightKeyframe.value.x - leftKeyframe.value.x) * progress,
- y:
- leftKeyframe.value.y +
- (rightKeyframe.value.y - leftKeyframe.value.y) * progress,
- };
- },
- });
-}
-
-function getDiscreteValueAtTime({
+export function getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
@@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
- return evaluateChannelValueAtTime({
- keyframes: channel?.keyframes,
- time,
- fallbackValue,
- getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ const normalizedChannel = normalizeChannel({
+ channel,
});
+ let currentValue = fallbackValue;
+ for (const key of normalizedChannel.keys) {
+ if (time + TIME_EPSILON_SECONDS < key.time) {
+ break;
+ }
+ currentValue = key.value;
+ }
+ return currentValue;
}
export function getChannelValueAtTime({
@@ -360,50 +354,25 @@ export function getChannelValueAtTime({
time: number;
fallbackValue: AnimationValue;
}): AnimationValue {
- if (!channel || channel.keyframes.length === 0) {
+ if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
- if (channel.valueKind === "number") {
- if (typeof fallbackValue !== "number") {
- return fallbackValue;
- }
-
- return getNumberChannelValueAtTime({
- channel,
- time,
- fallbackValue,
- });
- }
-
- if (channel.valueKind === "color") {
- if (typeof fallbackValue !== "string") {
- return fallbackValue;
- }
-
- return getColorValueAtTime({
- channel,
- time,
- fallbackValue,
- });
- }
-
- if (channel.valueKind === "vector") {
- if (!isVectorValue(fallbackValue)) {
- return fallbackValue;
- }
- return getVectorChannelValueAtTime({
- channel,
- time,
- fallbackValue: fallbackValue as VectorValue,
- });
+ if (channel.kind === "scalar") {
+ return typeof fallbackValue === "number"
+ ? getScalarChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ })
+ : fallbackValue;
}
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
- return getDiscreteValueAtTime({
+ return getDiscreteChannelValueAtTime({
channel,
time,
fallbackValue,
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 62c8a171..304a6698 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,72 +1,298 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import type {
- AnimationPath,
- ElementAnimations,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { isAnimationPath } from "./target-resolver";
-
-export function getElementKeyframes({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementKeyframe[] {
- if (!animations) {
- return [];
- }
-
- return Object.entries(animations.channels).flatMap(
- ([propertyPath, channel]) => {
- if (
- !channel ||
- channel.keyframes.length === 0 ||
- !isAnimationPath(propertyPath)
- ) {
- return [];
- }
-
- return channel.keyframes.map((keyframe) => ({
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- }));
- },
- );
-}
-
-export function hasKeyframesForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): boolean {
- const channel = animations?.channels[propertyPath];
- return Boolean(channel && channel.keyframes.length > 0);
-}
-
-export function getKeyframeAtTime({
- animations,
- propertyPath,
- time,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- time: number;
-}): ElementKeyframe | null {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.keyframes.length === 0) return null;
- const keyframe = channel.keyframes.find(
- (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
- );
- if (!keyframe) return null;
- return {
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- };
-}
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type {
+ AnimationBindingInstance,
+ AnimationChannel,
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+} from "./interpolation";
+import { isAnimationPath } from "./target-resolver";
+
+function getBindingFallbackValue({
+ channel,
+}: {
+ channel: ElementAnimations["channels"][string];
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return channel?.kind === "discrete" ? false : 0;
+ }
+
+ return channel.keys[0].value;
+}
+
+interface BindingKeyframeMatch {
+ componentIndex: number;
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}
+
+function getBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ return binding.components.flatMap((component, componentIndex) => {
+ const channel = animations.channels[component.channelId];
+ if (!channel || channel.keys.length === 0) {
+ return [];
+ }
+
+ return channel.keys.map((keyframe) => ({
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ });
+}
+
+function getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ const sortedMatches = getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).sort(
+ (leftMatch, rightMatch) =>
+ leftMatch.keyframe.time - rightMatch.keyframe.time ||
+ leftMatch.componentIndex - rightMatch.componentIndex,
+ );
+ const uniqueMatches: BindingKeyframeMatch[] = [];
+
+ for (const match of sortedMatches) {
+ const previousMatch = uniqueMatches[uniqueMatches.length - 1];
+ if (
+ !previousMatch ||
+ Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
+ TIME_EPSILON_SECONDS
+ ) {
+ uniqueMatches.push(match);
+ continue;
+ }
+
+ if (
+ previousMatch.componentIndex !== 0 &&
+ match.componentIndex === 0
+ ) {
+ uniqueMatches[uniqueMatches.length - 1] = match;
+ }
+ }
+
+ return uniqueMatches;
+}
+
+function getPreferredBindingKeyframeMatch({
+ matches,
+}: {
+ matches: BindingKeyframeMatch[];
+}): BindingKeyframeMatch | null {
+ return (
+ matches.find((match) => match.componentIndex === 0) ??
+ matches[0] ??
+ null
+ );
+}
+
+function getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ time: number;
+}) {
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: getBindingFallbackValue({ channel }),
+ }),
+ ];
+ }),
+ ) as Record;
+
+ return composeAnimationValue({
+ binding,
+ componentValues,
+ });
+}
+
+function getKeyframeInterpolation({
+ channel,
+ keyframe,
+}: {
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}) {
+ return channel.kind === "scalar" && "segmentToNext" in keyframe
+ ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
+ : "hold";
+}
+
+function toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ propertyPath: AnimationPath;
+ keyframeMatch: BindingKeyframeMatch;
+}): ElementKeyframe | null {
+ const value = getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time: keyframeMatch.keyframe.time,
+ });
+ if (value === null) {
+ return null;
+ }
+
+ return {
+ propertyPath,
+ id: keyframeMatch.keyframe.id,
+ time: keyframeMatch.keyframe.time,
+ value,
+ interpolation: getKeyframeInterpolation({
+ channel: keyframeMatch.channel,
+ keyframe: keyframeMatch.keyframe,
+ }),
+ };
+}
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.bindings).flatMap(
+ ([propertyPath, binding]) => {
+ if (!binding || !isAnimationPath(propertyPath)) {
+ return [];
+ }
+
+ return getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+ }).flatMap((keyframeMatch) => {
+ const keyframe = toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+ if (!keyframe) {
+ return [];
+ }
+
+ return [keyframe];
+ });
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return false;
+ }
+
+ return binding.components.some((component) =>
+ Boolean(animations?.channels[component.channelId]?.keys.length),
+ );
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) =>
+ Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ ),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
+
+export function getKeyframeById({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 13cdf238..7eaa5ed6 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -1,28 +1,40 @@
import type {
+ AnimationBindingInstance,
+ AnimationBindingKind,
AnimationChannel,
AnimationInterpolation,
- AnimationKeyframe,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
- AnimationValueKind,
- ColorAnimationChannel,
DiscreteAnimationChannel,
+ DiscreteAnimationKey,
ElementAnimations,
- NumberAnimationChannel,
- VectorAnimationChannel,
+ ScalarAnimationChannel,
+ ScalarAnimationKey,
+ ScalarCurveKeyframePatch,
+ ScalarSegmentType,
} from "@/lib/animation/types";
-import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
-import { snapToStep } from "@/utils/math";
-import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
+import {
+ cloneAnimationBinding,
+ createAnimationBinding,
+ decomposeAnimationValue,
+} from "./binding-values";
+import {
+ getBezierPoint,
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+ solveBezierProgressForTime,
+} from "./bezier";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+ normalizeChannel,
+} from "./interpolation";
import {
coerceAnimationValueForProperty,
- getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
- isAnimationPropertyPath,
- type NumericRange,
} from "./property-registry";
function isNearlySameTime({
@@ -35,32 +47,364 @@ function isNearlySameTime({
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
}
-function toAnimation({
- channelEntries,
+function hasChannelKeys({
+ channel,
}: {
- channelEntries: Array<[string, AnimationChannel]>;
+ channel: AnimationChannel | undefined;
+}): boolean {
+ return Boolean(channel && channel.keys.length > 0);
+}
+
+function toAnimation({
+ animations,
+}: {
+ animations: ElementAnimations;
}): ElementAnimations | undefined {
- if (channelEntries.length === 0) {
+ const nextBindings = Object.fromEntries(
+ Object.entries(animations.bindings).filter(([, binding]) => binding),
+ );
+ const nextChannels = Object.fromEntries(
+ Object.entries(animations.channels).filter(([, channel]) =>
+ hasChannelKeys({ channel }),
+ ),
+ );
+ if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
return undefined;
}
return {
- channels: Object.fromEntries(channelEntries),
+ bindings: nextBindings,
+ channels: nextChannels,
};
}
-function toChannel({
- keyframes,
- valueKind,
+function cloneAnimationsState({
+ animations,
}: {
- keyframes: AnimationKeyframe[];
- valueKind: AnimationValueKind;
+ animations: ElementAnimations | undefined;
+}): ElementAnimations {
+ return {
+ bindings: { ...(animations?.bindings ?? {}) },
+ channels: { ...(animations?.channels ?? {}) },
+ };
+}
+
+function getBindingChannelKind({
+ kind,
+}: {
+ kind: AnimationBindingKind;
+}): AnimationChannel["kind"] {
+ return kind === "discrete" ? "discrete" : "scalar";
+}
+
+function getPrimaryComponent({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}) {
+ return binding.components[0] ?? null;
+}
+
+function getPrimaryChannelId({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}) {
+ return getPrimaryComponent({ binding })?.channelId ?? null;
+}
+
+function getScalarSegmentType({
+ interpolation,
+}: {
+ interpolation: AnimationInterpolation;
+}): ScalarSegmentType {
+ if (interpolation === "hold") {
+ return "step";
+ }
+ return interpolation === "bezier" ? "bezier" : "linear";
+}
+
+function getInterpolationForBinding({
+ kind,
+ interpolation,
+}: {
+ kind: AnimationBindingKind;
+ interpolation: AnimationInterpolation | undefined;
+}): AnimationInterpolation {
+ if (kind === "discrete") {
+ return "hold";
+ }
+
+ if (
+ interpolation === "linear" ||
+ interpolation === "hold" ||
+ interpolation === "bezier"
+ ) {
+ return interpolation;
+ }
+
+ return "linear";
+}
+
+function createEmptyChannelForBindingKind({
+ kind,
+}: {
+ kind: AnimationBindingKind;
}): AnimationChannel {
+ if (kind === "discrete") {
+ return {
+ kind: "discrete",
+ keys: [],
+ } satisfies DiscreteAnimationChannel;
+ }
+
+ return {
+ kind: "scalar",
+ keys: [],
+ } satisfies ScalarAnimationChannel;
+}
+
+function createScalarKey({
+ id,
+ time,
+ value,
+ interpolation,
+ previousKey,
+}: {
+ id: string;
+ time: number;
+ value: number;
+ interpolation: AnimationInterpolation;
+ previousKey?: ScalarAnimationKey;
+}): ScalarAnimationKey {
+ return {
+ id,
+ time,
+ value,
+ leftHandle: previousKey?.leftHandle,
+ rightHandle: previousKey?.rightHandle,
+ segmentToNext:
+ previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
+ tangentMode: previousKey?.tangentMode ?? "flat",
+ };
+}
+
+function createDiscreteKey({
+ id,
+ time,
+ value,
+}: {
+ id: string;
+ time: number;
+ value: string | boolean;
+}): DiscreteAnimationKey {
+ return {
+ id,
+ time,
+ value,
+ };
+}
+
+function getBinding({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): AnimationBindingInstance | undefined {
+ return animations?.bindings[propertyPath];
+}
+
+function getChannelById({
+ animations,
+ channelId,
+}: {
+ animations: ElementAnimations | undefined;
+ channelId: string;
+}): AnimationChannel | undefined {
+ return animations?.channels[channelId];
+}
+
+function getBindingComponent({
+ binding,
+ componentKey,
+}: {
+ binding: AnimationBindingInstance;
+ componentKey: string;
+}) {
+ return binding.components.find((component) => component.key === componentKey) ?? null;
+}
+
+function getTargetKeyMetadata({
+ channel,
+ time,
+ keyframeId,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ keyframeId?: string;
+}) {
+ const normalizedChannel =
+ channel != null ? normalizeChannel({ channel }) : undefined;
+ const keys = normalizedChannel?.keys ?? [];
+ if (keyframeId) {
+ const keyById = keys.find((key) => key.id === keyframeId);
+ if (keyById) {
+ return {
+ id: keyById.id,
+ time,
+ };
+ }
+ }
+
+ const keyAtTime = keys.find((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (keyAtTime) {
+ return {
+ id: keyAtTime.id,
+ time: keyAtTime.time,
+ };
+ }
+
+ return {
+ id: keyframeId ?? generateUUID(),
+ time,
+ };
+}
+
+function upsertDiscreteChannelKey({
+ channel,
+ time,
+ value,
+ keyframeId,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ value: string | boolean;
+ keyframeId?: string;
+}): DiscreteAnimationChannel {
+ const normalizedChannel = normalizeChannel({
+ channel: channel ?? { kind: "discrete", keys: [] },
+ });
+ const keys = [...normalizedChannel.keys];
+ if (keyframeId) {
+ const existingIndex = keys.findIndex((key) => key.id === keyframeId);
+ if (existingIndex >= 0) {
+ keys[existingIndex] = createDiscreteKey({
+ id: keys[existingIndex].id,
+ time,
+ value,
+ });
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+ }
+ }
+
+ const existingAtTimeIndex = keys.findIndex((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (existingAtTimeIndex >= 0) {
+ keys[existingAtTimeIndex] = createDiscreteKey({
+ id: keys[existingAtTimeIndex].id,
+ time: keys[existingAtTimeIndex].time,
+ value,
+ });
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+ }
+
+ keys.push(
+ createDiscreteKey({
+ id: keyframeId ?? generateUUID(),
+ time,
+ value,
+ }),
+ );
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+}
+
+function upsertScalarChannelKey({
+ channel,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ time: number;
+ value: number;
+ interpolation: AnimationInterpolation;
+ keyframeId?: string;
+}): ScalarAnimationChannel {
+ const normalizedChannel = normalizeChannel({
+ channel: channel ?? { kind: "scalar", keys: [] },
+ });
+ const keys = [...normalizedChannel.keys];
+ if (keyframeId) {
+ const existingIndex = keys.findIndex((key) => key.id === keyframeId);
+ if (existingIndex >= 0) {
+ keys[existingIndex] = createScalarKey({
+ id: keys[existingIndex].id,
+ time,
+ value,
+ interpolation,
+ previousKey: {
+ ...keys[existingIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ },
+ });
+ return normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ });
+ }
+ }
+
+ const existingAtTimeIndex = keys.findIndex((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (existingAtTimeIndex >= 0) {
+ keys[existingAtTimeIndex] = createScalarKey({
+ id: keys[existingAtTimeIndex].id,
+ time: keys[existingAtTimeIndex].time,
+ value,
+ interpolation,
+ previousKey: {
+ ...keys[existingAtTimeIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ },
+ });
+ return normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ });
+ }
+
+ keys.push(
+ createScalarKey({
+ id: keyframeId ?? generateUUID(),
+ time,
+ value,
+ interpolation,
+ }),
+ );
return normalizeChannel({
channel: {
- valueKind,
- keyframes,
- } as AnimationChannel,
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
});
}
@@ -69,173 +413,12 @@ export function getChannel({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationChannel | undefined {
- return animations?.channels[propertyPath];
-}
-
-function getInterpolationForChannel({
- channel,
- interpolation,
-}: {
- channel: AnimationChannel;
- interpolation: AnimationInterpolation | undefined;
-}): AnimationInterpolation {
- if (channel.valueKind === "discrete") {
- return "hold";
- }
-
- if (interpolation === "linear" || interpolation === "hold") {
- return interpolation;
- }
-
- return "linear";
-}
-
-function buildKeyframe({
- channel,
- id,
- time,
- value,
- interpolation,
-}: {
- channel: AnimationChannel;
- id: string;
- time: number;
- value: AnimationValue;
- interpolation: AnimationInterpolation;
-}): AnimationKeyframe {
- if (channel.valueKind === "number") {
- if (typeof value !== "number") {
- throw new Error("Number channel keyframes require numeric values");
- }
-
- return {
- id,
- time,
- value,
- interpolation: interpolation === "hold" ? "hold" : "linear",
- };
- }
-
- if (channel.valueKind === "color") {
- if (typeof value !== "string") {
- throw new Error("Color channel keyframes require string values");
- }
-
- return {
- id,
- time,
- value,
- interpolation: interpolation === "hold" ? "hold" : "linear",
- };
- }
-
- if (channel.valueKind === "vector") {
- if (!isVectorValue(value)) {
- throw new Error("Vector channel keyframes require {x, y} values");
- }
-
- return {
- id,
- time,
- value,
- interpolation: interpolation === "hold" ? "hold" : "linear",
- };
- }
-
- if (typeof value !== "string" && typeof value !== "boolean") {
- throw new Error(
- "Discrete channel keyframes require boolean or string values",
- );
- }
-
- return {
- id,
- time,
- value,
- interpolation: "hold",
- };
-}
-
-function createEmptyChannelForValueKind({
- valueKind,
-}: {
- valueKind: AnimationValueKind;
-}): AnimationChannel {
- if (valueKind === "number") {
- return {
- valueKind: "number",
- keyframes: [],
- } satisfies NumberAnimationChannel;
- }
-
- if (valueKind === "color") {
- return {
- valueKind: "color",
- keyframes: [],
- } satisfies ColorAnimationChannel;
- }
-
- if (valueKind === "vector") {
- return {
- valueKind: "vector",
- keyframes: [],
- } satisfies VectorAnimationChannel;
- }
-
- return {
- valueKind: "discrete",
- keyframes: [],
- } satisfies DiscreteAnimationChannel;
-}
-
-function clampNumericRange({
- value,
- numericRange,
-}: {
- value: number;
- numericRange: NumericRange | undefined;
-}): number {
- if (!numericRange) {
- return value;
- }
-
- const steppedValue =
- numericRange.step != null
- ? snapToStep({ value, step: numericRange.step })
- : value;
- const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
- const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
-}
-
-function coerceAnimationValueForPath({
- value,
- valueKind,
- numericRange,
-}: {
- value: AnimationValue;
- valueKind: AnimationValueKind;
- numericRange?: NumericRange;
-}): AnimationValue | null {
- if (valueKind === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- return clampNumericRange({ value, numericRange });
- }
-
- if (valueKind === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (valueKind === "vector") {
- return isVectorValue(value) ? value : null;
- }
-
- return typeof value === "string" || typeof value === "boolean" ? value : null;
+ const binding = getBinding({ animations, propertyPath });
+ const primaryChannelId =
+ binding != null ? getPrimaryChannelId({ binding }) : null;
+ return primaryChannelId ? animations?.channels[primaryChannelId] : undefined;
}
export function upsertPathKeyframe({
@@ -245,9 +428,9 @@ export function upsertPathKeyframe({
value,
interpolation,
keyframeId,
- valueKind,
+ kind,
defaultInterpolation,
- numericRange,
+ coerceValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
@@ -255,39 +438,80 @@ export function upsertPathKeyframe({
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
- valueKind: AnimationValueKind;
+ kind: AnimationBindingKind;
defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericRange;
+ coerceValue: (value: AnimationValue) => AnimationValue | null;
}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForPath({
- value,
- valueKind,
- numericRange,
- });
+ const coercedValue = coerceValue(value);
if (coercedValue === null) {
return animations;
}
- const channel = getChannel({ animations, propertyPath });
- const targetChannel =
- channel && channel.valueKind === valueKind
- ? channel
- : createEmptyChannelForValueKind({ valueKind });
- const updatedChannel = upsertKeyframe({
- channel: targetChannel,
+ const nextAnimations = cloneAnimationsState({ animations });
+ const existingBinding = getBinding({
+ animations,
+ propertyPath,
+ });
+ const binding =
+ existingBinding && existingBinding.kind === kind
+ ? cloneAnimationBinding({ binding: existingBinding })
+ : createAnimationBinding({ path: propertyPath, kind });
+ const primaryChannel = getChannel({
+ animations,
+ propertyPath,
+ });
+ const targetKey = getTargetKeyMetadata({
+ channel: primaryChannel,
time,
- value: coercedValue,
- interpolation: interpolation ?? defaultInterpolation,
keyframeId,
});
+ const componentValues = decomposeAnimationValue({
+ kind,
+ value: coercedValue,
+ });
+ if (!componentValues) {
+ return animations;
+ }
- return (
- setChannel({
+ const nextInterpolation = getInterpolationForBinding({
+ kind,
+ interpolation: interpolation ?? defaultInterpolation,
+ });
+ nextAnimations.bindings[propertyPath] = binding;
+ for (const component of binding.components) {
+ const nextValue = componentValues[component.key];
+ if (nextValue == null) {
+ continue;
+ }
+
+ const currentChannel = getChannelById({
animations,
- propertyPath,
- channel: updatedChannel,
- }) ?? { channels: {} }
- );
+ channelId: component.channelId,
+ });
+ const targetChannel =
+ currentChannel?.kind === getBindingChannelKind({ kind })
+ ? currentChannel
+ : createEmptyChannelForBindingKind({ kind });
+ nextAnimations.channels[component.channelId] =
+ targetChannel.kind === "discrete"
+ ? upsertDiscreteChannelKey({
+ channel: targetChannel,
+ time: targetKey.time,
+ value: nextValue as string | boolean,
+ keyframeId: targetKey.id,
+ })
+ : upsertScalarChannelKey({
+ channel: targetChannel,
+ time: targetKey.time,
+ value: nextValue as number,
+ interpolation: nextInterpolation,
+ keyframeId: targetKey.id,
+ });
+ }
+
+ return toAnimation({
+ animations: nextAnimations,
+ });
}
export function upsertElementKeyframe({
@@ -321,15 +545,16 @@ export function upsertElementKeyframe({
value: coercedValue,
interpolation,
keyframeId,
- valueKind: propertyDefinition.valueKind,
- defaultInterpolation: getDefaultInterpolationForProperty({
- propertyPath,
- }),
- numericRange: propertyDefinition.numericRange,
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ coerceValue: (nextValue) =>
+ coerceAnimationValueForProperty({
+ propertyPath,
+ value: nextValue,
+ }),
});
}
-
export function upsertKeyframe({
channel,
time,
@@ -347,61 +572,29 @@ export function upsertKeyframe({
return undefined;
}
- const currentKeyframes = channel.keyframes;
- const nextKeyframes = [...currentKeyframes];
- const nextInterpolation = getInterpolationForChannel({
- channel,
- interpolation,
- });
- if (keyframeId) {
- const keyframeByIdIndex = nextKeyframes.findIndex(
- (keyframe) => keyframe.id === keyframeId,
- );
- if (keyframeByIdIndex >= 0) {
- nextKeyframes[keyframeByIdIndex] = buildKeyframe({
- channel,
- id: nextKeyframes[keyframeByIdIndex].id,
- time,
- value,
- interpolation: nextInterpolation,
- });
- return toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
- });
+ if (channel.kind === "discrete") {
+ if (typeof value !== "string" && typeof value !== "boolean") {
+ return channel;
}
- }
- const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
- isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
- );
- if (keyframeAtTimeIndex >= 0) {
- nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
+ return upsertDiscreteChannelKey({
channel,
- id: nextKeyframes[keyframeAtTimeIndex].id,
- time: nextKeyframes[keyframeAtTimeIndex].time,
- value,
- interpolation: nextInterpolation,
- });
- return toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
- });
- }
-
- nextKeyframes.push(
- buildKeyframe({
- channel,
- id: keyframeId ?? generateUUID(),
time,
value,
- interpolation: nextInterpolation,
- }),
- );
+ keyframeId,
+ });
+ }
- return toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
+ if (typeof value !== "number") {
+ return channel;
+ }
+
+ return upsertScalarChannelKey({
+ channel,
+ time,
+ value,
+ interpolation: interpolation ?? "linear",
+ keyframeId,
});
}
@@ -416,16 +609,16 @@ export function removeKeyframe({
return undefined;
}
- const nextKeyframes = channel.keyframes.filter(
- (keyframe) => keyframe.id !== keyframeId,
- );
- if (nextKeyframes.length === 0) {
+ const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
+ if (nextKeys.length === 0) {
return undefined;
}
- return toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
+ return normalizeChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ } as AnimationChannel,
});
}
@@ -442,22 +635,24 @@ export function retimeKeyframe({
return undefined;
}
- const keyframeByIdIndex = channel.keyframes.findIndex(
+ const keyframeByIdIndex = channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex < 0) {
return channel;
}
- const nextKeyframes = [...channel.keyframes];
- nextKeyframes[keyframeByIdIndex] = {
- ...nextKeyframes[keyframeByIdIndex],
+ const nextKeys = [...channel.keys];
+ nextKeys[keyframeByIdIndex] = {
+ ...nextKeys[keyframeByIdIndex],
time,
};
- return toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
+ return normalizeChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ } as AnimationChannel,
});
}
@@ -467,22 +662,145 @@ export function setChannel({
channel,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
- const currentChannels = animations?.channels ?? {};
-
- const nextChannelEntries = Object.entries(currentChannels)
- .filter(([path]) => path !== propertyPath)
- .filter(([, ch]) => ch && ch.keyframes.length > 0)
- .map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
-
- if (channel && channel.keyframes.length > 0) {
- nextChannelEntries.push([propertyPath, channel]);
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
}
+ if (binding.components.length !== 1) {
+ throw new Error(
+ `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
+ );
+ }
+
+ const primaryComponent = getPrimaryComponent({ binding });
+ if (!primaryComponent) {
+ return animations;
+ }
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey: primaryComponent.key,
+ channel,
+ });
+}
+
+export function setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ if (!channel || !hasChannelKeys({ channel })) {
+ delete nextAnimations.channels[component.channelId];
+ const hasRemainingKeys = binding.components.some((candidate) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[candidate.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
+ }
+ return toAnimation({
+ animations: nextAnimations,
+ });
+ }
+
+ nextAnimations.channels[component.channelId] = normalizeChannel({
+ channel,
+ });
return toAnimation({
- channelEntries: nextChannelEntries,
+ animations: nextAnimations,
+ });
+}
+
+export function updateScalarKeyframeCurve({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (channel?.kind !== "scalar") {
+ return animations;
+ }
+
+ const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
+ if (keyframeIndex < 0) {
+ return animations;
+ }
+
+ const nextKeys = [...channel.keys];
+ const currentKey = nextKeys[keyframeIndex];
+ nextKeys[keyframeIndex] = {
+ ...currentKey,
+ leftHandle:
+ patch.leftHandle === undefined
+ ? currentKey.leftHandle
+ : patch.leftHandle ?? undefined,
+ rightHandle:
+ patch.rightHandle === undefined
+ ? currentKey.rightHandle
+ : patch.rightHandle ?? undefined,
+ segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
+ tangentMode: patch.tangentMode ?? currentKey.tangentMode,
+ };
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel: {
+ kind: "scalar",
+ keys: nextKeys,
+ extrapolation: channel.extrapolation,
+ },
});
}
@@ -497,31 +815,57 @@ export function cloneAnimations({
return undefined;
}
- const clonedEntries = Object.entries(animations.channels).flatMap(
- ([propertyPath, channel]) => {
- if (!channel || channel.keyframes.length === 0) {
- return [];
+ const nextAnimations = cloneAnimationsState({ animations });
+ nextAnimations.bindings = Object.fromEntries(
+ Object.entries(animations.bindings).map(([path, binding]) => [
+ path,
+ binding ? cloneAnimationBinding({ binding }) : binding,
+ ]),
+ );
+ nextAnimations.channels = {};
+
+ for (const binding of Object.values(nextAnimations.bindings)) {
+ if (!binding) {
+ continue;
+ }
+
+ const primaryChannel = getChannelById({
+ animations,
+ channelId: getPrimaryChannelId({ binding }) ?? "",
+ });
+ const keyIdMap = new Map();
+ if (primaryChannel) {
+ for (const key of primaryChannel.keys) {
+ keyIdMap.set(
+ key.id,
+ shouldRegenerateKeyframeIds ? generateUUID() : key.id,
+ );
+ }
+ }
+
+ for (const component of binding.components) {
+ const currentChannel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (!currentChannel) {
+ continue;
}
- const clonedKeyframes = channel.keyframes.map((keyframe) => ({
- ...keyframe,
- id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
- }));
-
- return [
- [
- propertyPath,
- toChannel({
- keyframes: clonedKeyframes,
- valueKind: channel.valueKind,
- }),
- ] as [string, AnimationChannel],
- ];
- },
- );
+ nextAnimations.channels[component.channelId] = normalizeChannel({
+ channel: {
+ ...currentChannel,
+ keys: currentChannel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ } as AnimationChannel,
+ });
+ }
+ }
return toAnimation({
- channelEntries: clonedEntries,
+ animations: nextAnimations,
});
}
@@ -532,38 +876,310 @@ export function clampAnimationsToDuration({
animations: ElementAnimations | undefined;
duration: number;
}): ElementAnimations | undefined {
- if (!animations) {
+ if (!animations || duration <= 0) {
return undefined;
}
- const clampedEntries = Object.entries(animations.channels).flatMap(
- ([propertyPath, channel]) => {
- if (!channel) {
- return [];
- }
+ return splitAnimationsAtTime({
+ animations,
+ splitTime: duration,
+ shouldIncludeSplitBoundary: true,
+ }).leftAnimations;
+}
- const nextKeyframes = channel.keyframes.filter(
- (keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
- );
- if (nextKeyframes.length === 0) {
- return [];
- }
+function lerpPoint({
+ left,
+ right,
+ progress,
+}: {
+ left: { x: number; y: number };
+ right: { x: number; y: number };
+ progress: number;
+}) {
+ return {
+ x: left.x + (right.x - left.x) * progress,
+ y: left.y + (right.y - left.y) * progress,
+ };
+}
- return [
- [
- propertyPath,
- toChannel({
- keyframes: nextKeyframes,
- valueKind: channel.valueKind,
- }),
- ] as [string, AnimationChannel],
+function splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ splitTime: number;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return {
+ leftChannel: undefined,
+ rightChannel: undefined,
+ };
+ }
+
+ const normalizedChannel = normalizeChannel({ channel });
+ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
+ let rightKeys = normalizedChannel.keys
+ .filter((key) => key.time >= splitTime)
+ .map((key) => ({ ...key, time: key.time - splitTime }));
+
+ if (shouldIncludeSplitBoundary) {
+ const hasBoundaryOnLeft = leftKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
+ );
+ const hasBoundaryOnRight = rightKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
+ );
+ const boundaryValue = getChannelValueAtTime({
+ channel: normalizedChannel,
+ time: splitTime,
+ fallbackValue: normalizedChannel.keys[0].value,
+ });
+ if (!hasBoundaryOnLeft) {
+ leftKeys = [
+ ...leftKeys,
+ createDiscreteKey({
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue as string | boolean,
+ }),
];
- },
- );
+ }
+ if (!hasBoundaryOnRight) {
+ rightKeys = [
+ createDiscreteKey({
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue as string | boolean,
+ }),
+ ...rightKeys,
+ ];
+ }
+ }
- return toAnimation({
- channelEntries: clampedEntries,
- });
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } })
+ : undefined,
+ };
+}
+
+function splitScalarChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ splitTime: number;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return {
+ leftChannel: undefined,
+ rightChannel: undefined,
+ };
+ }
+
+ const normalizedChannel = normalizeChannel({ channel });
+ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
+ let rightKeys = normalizedChannel.keys
+ .filter((key) => key.time >= splitTime)
+ .map((key) => ({ ...key, time: key.time - splitTime }));
+
+ const hasBoundaryOnLeft = leftKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
+ );
+ const hasBoundaryOnRight = rightKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
+ );
+ if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) {
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ };
+ }
+
+ for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) {
+ const leftKey = normalizedChannel.keys[keyIndex];
+ const rightKey = normalizedChannel.keys[keyIndex + 1];
+ if (
+ !(
+ splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
+ splitTime < rightKey.time - TIME_EPSILON_SECONDS
+ )
+ ) {
+ continue;
+ }
+
+ const boundaryValue = getChannelValueAtTime({
+ channel: normalizedChannel,
+ time: splitTime,
+ fallbackValue: leftKey.value,
+ }) as number;
+
+ if (leftKey.segmentToNext === "bezier") {
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+ const progress = solveBezierProgressForTime({
+ time: splitTime,
+ leftKey,
+ rightKey,
+ });
+ const p0 = { x: leftKey.time, y: leftKey.value };
+ const p1 = {
+ x: leftKey.time + rightHandle.dt,
+ y: leftKey.value + rightHandle.dv,
+ };
+ const p2 = {
+ x: rightKey.time + leftHandle.dt,
+ y: rightKey.value + leftHandle.dv,
+ };
+ const p3 = { x: rightKey.time, y: rightKey.value };
+ const q0 = lerpPoint({ left: p0, right: p1, progress });
+ const q1 = lerpPoint({ left: p1, right: p2, progress });
+ const q2 = lerpPoint({ left: p2, right: p3, progress });
+ const r0 = lerpPoint({ left: q0, right: q1, progress });
+ const r1 = lerpPoint({ left: q1, right: q2, progress });
+ const splitPoint = lerpPoint({ left: r0, right: r1, progress });
+ leftKeys = [
+ ...normalizedChannel.keys.filter((key) => key.time < splitTime),
+ {
+ ...leftKey,
+ rightHandle: {
+ dt: q0.x - p0.x,
+ dv: q0.y - p0.y,
+ },
+ },
+ {
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ leftHandle: {
+ dt: r0.x - splitPoint.x,
+ dv: r0.y - splitPoint.y,
+ },
+ segmentToNext: leftKey.segmentToNext,
+ tangentMode: leftKey.tangentMode,
+ },
+ ];
+ rightKeys = [
+ {
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue,
+ rightHandle: {
+ dt: r1.x - splitPoint.x,
+ dv: r1.y - splitPoint.y,
+ },
+ segmentToNext: "bezier",
+ tangentMode: leftKey.tangentMode,
+ },
+ {
+ ...rightKey,
+ time: rightKey.time - splitTime,
+ leftHandle: {
+ dt: q2.x - p3.x,
+ dv: q2.y - p3.y,
+ },
+ },
+ ...normalizedChannel.keys
+ .filter((key) => key.time > rightKey.time)
+ .map((key) => ({
+ ...key,
+ time: key.time - splitTime,
+ })),
+ ];
+ } else {
+ leftKeys = [
+ ...leftKeys,
+ createScalarKey({
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ interpolation: "linear",
+ }),
+ ];
+ rightKeys = [
+ createScalarKey({
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue,
+ interpolation: getScalarSegmentInterpolation({
+ segment: leftKey.segmentToNext,
+ }),
+ }),
+ ...rightKeys,
+ ];
+ }
+
+ return {
+ leftChannel: normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ }),
+ rightChannel: normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ }),
+ };
+ }
+
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ };
}
export function splitAnimationsAtTime({
@@ -582,101 +1198,63 @@ export function splitAnimationsAtTime({
return { leftAnimations: undefined, rightAnimations: undefined };
}
- const leftChannels: Array<[string, AnimationChannel]> = [];
- const rightChannels: Array<[string, AnimationChannel]> = [];
+ const leftAnimations = cloneAnimationsState({ animations: undefined });
+ const rightAnimations = cloneAnimationsState({ animations: undefined });
- for (const [propertyPath, channel] of Object.entries(animations.channels)) {
- if (!channel || channel.keyframes.length === 0) {
+ for (const [propertyPath, binding] of Object.entries(animations.bindings)) {
+ if (!binding) {
continue;
}
- const normalizedChannel = normalizeChannel({ channel });
- let leftKeyframes = normalizedChannel.keyframes.filter(
- (keyframe) => keyframe.time <= splitTime,
- );
- let rightKeyframes = normalizedChannel.keyframes
- .filter((keyframe) => keyframe.time >= splitTime)
- .map((keyframe) => ({
- ...keyframe,
- time: keyframe.time - splitTime,
- }));
+ const leftBinding = cloneAnimationBinding({ binding });
+ const rightBinding = cloneAnimationBinding({ binding });
+ const leftBoundaryId = generateUUID();
+ const rightBoundaryId = generateUUID();
+ let hasLeftKeys = false;
+ let hasRightKeys = false;
- const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
- isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
- );
- const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
- isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
- );
- if (
- shouldIncludeSplitBoundary &&
- (!hasBoundaryOnLeft || !hasBoundaryOnRight)
- ) {
- const boundaryValue = getChannelValueAtTime({
- channel: normalizedChannel,
- time: splitTime,
- fallbackValue: normalizedChannel.keyframes[0].value,
+ for (const component of binding.components) {
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
});
- const knownPropertyPath = isAnimationPropertyPath(propertyPath)
- ? propertyPath
- : null;
- const boundaryInterpolation = knownPropertyPath
- ? getDefaultInterpolationForProperty({
- propertyPath: knownPropertyPath,
- })
- : normalizedChannel.valueKind === "discrete"
- ? "hold"
- : "linear";
-
- if (!hasBoundaryOnLeft) {
- leftKeyframes = [
- ...leftKeyframes,
- buildKeyframe({
- channel: normalizedChannel,
- id: generateUUID(),
- time: splitTime,
- value: boundaryValue,
- interpolation: boundaryInterpolation,
- }),
- ];
+ const splitResult =
+ channel?.kind === "discrete"
+ ? splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ })
+ : splitScalarChannelAtTime({
+ channel: channel as ScalarAnimationChannel | undefined,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ });
+ if (splitResult.leftChannel) {
+ leftAnimations.channels[component.channelId] = splitResult.leftChannel;
+ hasLeftKeys = true;
}
-
- if (!hasBoundaryOnRight) {
- rightKeyframes = [
- buildKeyframe({
- channel: normalizedChannel,
- id: generateUUID(),
- time: 0,
- value: boundaryValue,
- interpolation: boundaryInterpolation,
- }),
- ...rightKeyframes,
- ];
+ if (splitResult.rightChannel) {
+ rightAnimations.channels[component.channelId] = splitResult.rightChannel;
+ hasRightKeys = true;
}
}
- const leftChannel = leftKeyframes.length
- ? toChannel({
- keyframes: leftKeyframes,
- valueKind: normalizedChannel.valueKind,
- })
- : undefined;
- const rightChannel = rightKeyframes.length
- ? toChannel({
- keyframes: rightKeyframes,
- valueKind: normalizedChannel.valueKind,
- })
- : undefined;
- if (leftChannel) {
- leftChannels.push([propertyPath, leftChannel]);
+ if (hasLeftKeys) {
+ leftAnimations.bindings[propertyPath] = leftBinding;
}
- if (rightChannel) {
- rightChannels.push([propertyPath, rightChannel]);
+ if (hasRightKeys) {
+ rightAnimations.bindings[propertyPath] = rightBinding;
}
}
return {
- leftAnimations: toAnimation({ channelEntries: leftChannels }),
- rightAnimations: toAnimation({ channelEntries: rightChannels }),
+ leftAnimations: toAnimation({ animations: leftAnimations }),
+ rightAnimations: toAnimation({ animations: rightAnimations }),
};
}
@@ -689,15 +1267,31 @@ export function removeElementKeyframe({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
- const channel = getChannel({ animations, propertyPath });
- const updatedChannel = removeKeyframe({
- channel,
- keyframeId,
- });
- return setChannel({
- animations,
- propertyPath,
- channel: updatedChannel,
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ for (const component of binding.components) {
+ nextAnimations.channels[component.channelId] = removeKeyframe({
+ channel: nextAnimations.channels[component.channelId],
+ keyframeId,
+ });
+ }
+ const hasRemainingKeys = binding.components.some((component) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[component.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
+ for (const component of binding.components) {
+ delete nextAnimations.channels[component.channelId];
+ }
+ }
+ return toAnimation({
+ animations: nextAnimations,
});
}
@@ -712,15 +1306,20 @@ export function retimeElementKeyframe({
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
- const channel = getChannel({ animations, propertyPath });
- const updatedChannel = retimeKeyframe({
- channel,
- keyframeId,
- time,
- });
- return setChannel({
- animations,
- propertyPath,
- channel: updatedChannel,
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ for (const component of binding.components) {
+ nextAnimations.channels[component.channelId] = retimeKeyframe({
+ channel: nextAnimations.channels[component.channelId],
+ keyframeId,
+ time,
+ });
+ }
+ return toAnimation({
+ animations: nextAnimations,
});
}
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
deleted file mode 100644
index 3e193131..00000000
--- a/apps/web/src/lib/animation/number-channel.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
- NumberAnimationChannel,
-} from "@/lib/animation/types";
-
-export function getNumberChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): NumberAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "number") {
- return undefined;
- }
-
- return channel;
-}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 6d20e324..ac15805e 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -1,369 +1,377 @@
-import type {
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
- AnimationValueKind,
- DiscreteValue,
- VectorValue,
-} from "@/lib/animation/types";
-import { isVectorValue } from "./vector-channel";
-import type { TimelineElement } from "@/lib/timeline";
-import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/constants/text-constants";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/lib/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-export interface NumericSpec {
- min?: number;
- max?: number;
- step?: number;
-}
-
-export type NumericRange = NumericSpec;
-
-export interface AnimationPropertyDefinition {
- valueKind: AnimationValueKind;
- defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericSpec;
- supportsElement: ({ element }: { element: TimelineElement }) => boolean;
- getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
- setValue: ({
- element,
- value,
- }: {
- element: TimelineElement;
- value: AnimationValue;
- }) => TimelineElement;
-}
-
-const ANIMATION_PROPERTY_REGISTRY: Record<
- AnimationPropertyPath,
- AnimationPropertyDefinition
-> = {
- "transform.position": {
- valueKind: "vector",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: value as VectorValue,
- },
- }
- : element,
- },
- "transform.scaleX": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleX : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleX: value as number },
- }
- : element,
- },
- "transform.scaleY": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleY : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleY: value as number },
- }
- : element,
- },
- "transform.rotate": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: -360, max: 360, step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.rotate : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, rotate: value as number },
- }
- : element,
- },
- opacity: {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.opacity : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? { ...element, opacity: value as number }
- : element,
- },
- volume: {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
- supportsElement: ({ element }) => canElementHaveAudio(element),
- getValue: ({ element }) =>
- canElementHaveAudio(element) ? element.volume ?? 0 : null,
- setValue: ({ element, value }) =>
- canElementHaveAudio(element)
- ? { ...element, volume: value as number }
- : element,
- },
- color: {
- valueKind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) => (element.type === "text" ? element.color : null),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? { ...element, color: value as string }
- : element,
- },
- "background.color": {
- valueKind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text" ? element.background.color : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, color: value as string },
- }
- : element,
- },
- "background.paddingX": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingX: value as number },
- }
- : element,
- },
- "background.paddingY": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingY: value as number },
- }
- : element,
- },
- "background.offsetX": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetX: value as number },
- }
- : element,
- },
- "background.offsetY": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetY: value as number },
- }
- : element,
- },
- "background.cornerRadius": {
- valueKind: "number",
- defaultInterpolation: "linear",
- numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, cornerRadius: value as number },
- }
- : element,
- },
-};
-
-export function isAnimationPropertyPath(
- propertyPath: string,
-): propertyPath is AnimationPropertyPath {
- return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
-}
-
-export function getAnimationPropertyDefinition({
- propertyPath,
-}: {
- propertyPath: AnimationPropertyPath;
-}): AnimationPropertyDefinition {
- return ANIMATION_PROPERTY_REGISTRY[propertyPath];
-}
-
-export function supportsAnimationProperty({
- element,
- propertyPath,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
-}): boolean {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.supportsElement({ element });
-}
-
-export function getElementBaseValueForProperty({
- element,
- propertyPath,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
-}): AnimationValue | null {
- const definition = getAnimationPropertyDefinition({ propertyPath });
- if (!definition.supportsElement({ element })) {
- return null;
- }
- return definition.getValue({ element });
-}
-
-export function withElementBaseValueForProperty({
- element,
- propertyPath,
- value,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
- value: AnimationValue;
-}): TimelineElement {
- const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
- if (coercedValue === null) {
- return element;
- }
- const definition = getAnimationPropertyDefinition({ propertyPath });
- if (!definition.supportsElement({ element })) {
- return element;
- }
- return definition.setValue({ element, value: coercedValue });
-}
-
-export function getDefaultInterpolationForProperty({
- propertyPath,
-}: {
- propertyPath: AnimationPropertyPath;
-}): AnimationInterpolation {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.defaultInterpolation;
-}
-
-function applyNumericSpec({
- value,
- numericRange,
-}: {
- value: number;
- numericRange: NumericSpec | undefined;
-}): number {
- if (!numericRange) {
- return value;
- }
-
- const steppedValue =
- numericRange.step != null
- ? snapToStep({ value, step: numericRange.step })
- : value;
- const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
- const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
-}
-
-export function coerceAnimationValueForProperty({
- propertyPath,
- value,
-}: {
- propertyPath: AnimationPropertyPath;
- value: AnimationValue;
-}): AnimationValue | null {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
-
- if (propertyDefinition.valueKind === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- return applyNumericSpec({
- value,
- numericRange: propertyDefinition.numericRange,
- });
- }
-
- if (propertyDefinition.valueKind === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (propertyDefinition.valueKind === "vector") {
- return isVectorValue(value) ? value : null;
- }
-
- if (typeof value === "string" || typeof value === "boolean") {
- return value as DiscreteValue;
- }
-
- return null;
-}
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
+import type { TimelineElement } from "@/lib/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/constants/text-constants";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/lib/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { snapToStep } from "@/utils/math";
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export interface AnimationPropertyDefinition {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial>;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+function applyNumericSpec({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericSpec | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceNumberValue({
+ value,
+ numericRange,
+}: {
+ value: AnimationValue;
+ numericRange?: NumericSpec;
+}): number | null {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return applyNumericSpec({ value, numericRange });
+}
+
+function coerceColorValue({
+ value,
+}: {
+ value: AnimationValue;
+}): string | null {
+ return typeof value === "string" && parseColorToLinearRgba({ color: value })
+ ? value
+ : null;
+}
+
+function createNumberPropertyDefinition({
+ numericRange,
+ supportsElement,
+ getValue,
+ setValue,
+}: {
+ numericRange?: NumericSpec;
+ supportsElement: AnimationPropertyDefinition["supportsElement"];
+ getValue: AnimationPropertyDefinition["getValue"];
+ setValue: AnimationPropertyDefinition["setValue"];
+}): AnimationPropertyDefinition {
+ return {
+ kind: "number",
+ defaultInterpolation: "linear",
+ numericRanges: numericRange ? { value: numericRange } : undefined,
+ supportsElement,
+ getValue,
+ coerceValue: ({ value }) =>
+ coerceNumberValue({
+ value,
+ numericRange,
+ }),
+ setValue,
+ };
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.position": {
+ kind: "vector2",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position : null,
+ coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: value as VectorValue,
+ },
+ }
+ : element,
+ },
+ "transform.scaleX": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleX : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleX: value as number },
+ }
+ : element,
+ }),
+ "transform.scaleY": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleY : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleY: value as number },
+ }
+ : element,
+ }),
+ "transform.rotate": createNumberPropertyDefinition({
+ numericRange: { min: -360, max: 360, step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ }),
+ opacity: createNumberPropertyDefinition({
+ numericRange: { min: 0, max: 1, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ }),
+ volume: createNumberPropertyDefinition({
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
+ getValue: ({ element }) =>
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
+ setValue: ({ element, value }) =>
+ canElementHaveAudio(element)
+ ? { ...element, volume: value as number }
+ : element,
+ }),
+ color: {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ }),
+ "background.paddingY": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ }),
+ "background.offsetX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ }),
+ "background.offsetY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ }),
+ "background.cornerRadius": createNumberPropertyDefinition({
+ numericRange: {
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ step: 1,
+ },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ }),
+};
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
+}
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ const coercedValue = definition.coerceValue({ value });
+ if (coercedValue === null || !definition.supportsElement({ element })) {
+ return element;
+ }
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.coerceValue({ value });
+}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 5c356f28..67f4a487 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,135 +1,176 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { Transform } from "@/lib/rendering";
-import {
- getColorValueAtTime,
- getNumberChannelValueAtTime,
- getVectorChannelValueAtTime,
-} from "./interpolation";
-import { getColorChannelForPath } from "./color-channel";
-import { getNumberChannelForPath } from "./number-channel";
-import { getVectorChannelForPath } from "./vector-channel";
-
-export function getElementLocalTime({
- timelineTime,
- elementStartTime,
- elementDuration,
-}: {
- timelineTime: number;
- elementStartTime: number;
- elementDuration: number;
-}): number {
- const localTime = timelineTime - elementStartTime;
- if (localTime <= 0) {
- return 0;
- }
-
- if (localTime >= elementDuration) {
- return elementDuration;
- }
-
- return localTime;
-}
-
-export function resolveTransformAtTime({
- baseTransform,
- animations,
- localTime,
-}: {
- baseTransform: Transform;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): Transform {
- const safeLocalTime = Math.max(0, localTime);
- return {
- position: getVectorChannelValueAtTime({
- channel: getVectorChannelForPath({
- animations,
- propertyPath: "transform.position",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position,
- }),
- scaleX: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.scaleX",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.scaleX,
- }),
- scaleY: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.scaleY",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.scaleY,
- }),
- rotate: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.rotate",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.rotate,
- }),
- };
-}
-
-export function resolveOpacityAtTime({
- baseOpacity,
- animations,
- localTime,
-}: {
- baseOpacity: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "opacity",
- }),
- time: Math.max(0, localTime),
- fallbackValue: baseOpacity,
- });
-}
-
-export function resolveNumberAtTime({
- baseValue,
- animations,
- propertyPath,
- localTime,
-}: {
- baseValue: number;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({ animations, propertyPath }),
- time: Math.max(0, localTime),
- fallbackValue: baseValue,
- });
-}
-
-export function resolveColorAtTime({
- baseColor,
- animations,
- propertyPath,
- localTime,
-}: {
- baseColor: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
-}): string {
- return getColorValueAtTime({
- channel: getColorChannelForPath({ animations, propertyPath }),
- time: Math.max(0, localTime),
- fallbackValue: baseColor,
- });
-}
+import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ AnimationPath,
+ AnimationPropertyPath,
+ AnimationValueForPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { Transform } from "@/lib/rendering";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+ decomposeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+} from "./interpolation";
+
+export function getElementLocalTime({
+ timelineTime,
+ elementStartTime,
+ elementDuration,
+}: {
+ timelineTime: number;
+ elementStartTime: number;
+ elementDuration: number;
+}): number {
+ const localTime = timelineTime - elementStartTime;
+ if (localTime <= 0) {
+ return 0;
+ }
+
+ if (localTime >= elementDuration) {
+ return elementDuration;
+ }
+
+ return localTime;
+}
+
+export function resolveTransformAtTime({
+ baseTransform,
+ animations,
+ localTime,
+}: {
+ baseTransform: Transform;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): Transform {
+ const safeLocalTime = Math.max(0, localTime);
+ return {
+ position: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.position",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.position,
+ }),
+ scaleX: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleX",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleY",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleY,
+ }),
+ rotate: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.rotate",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "opacity",
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationNumericPropertyPath;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationColorPropertyPath;
+ localTime: number;
+}): string {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
+
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: TPath;
+ localTime: number;
+ fallbackValue: AnimationValueForPath;
+}): AnimationValueForPath {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return fallbackValue;
+ }
+
+ const fallbackComponents = decomposeAnimationValue({
+ kind: binding.kind,
+ value: fallbackValue,
+ });
+ if (!fallbackComponents) {
+ return fallbackValue;
+ }
+
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations?.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time: localTime,
+ fallbackValue:
+ fallbackComponents[component.key] ??
+ (channel?.kind === "discrete" ? false : 0),
+ }),
+ ];
+ }),
+ ) as Record;
+ return (composeAnimationValue({
+ binding,
+ componentValues,
+ }) ?? fallbackValue) as AnimationValueForPath;
+}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
index 6675d443..06c9e140 100644
--- a/apps/web/src/lib/animation/target-resolver.ts
+++ b/apps/web/src/lib/animation/target-resolver.ts
@@ -1,277 +1,285 @@
-import type {
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
- AnimationValueKind,
-} from "@/lib/animation/types";
-import {
- parseEffectParamPath,
- isEffectParamPath,
-} from "@/lib/animation/effect-param-channel";
-import {
- isGraphicParamPath,
- parseGraphicParamPath,
-} from "@/lib/animation/graphic-param-channel";
-import type { ParamDefinition } from "@/lib/params";
-import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
-import { getGraphicDefinition } from "@/lib/graphics";
-import type { TimelineElement } from "@/lib/timeline";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import { snapToStep } from "@/utils/math";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export interface AnimationPathDescriptor {
- valueKind: AnimationValueKind;
- defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericSpec;
- getBaseValue(): AnimationValue | null;
- setBaseValue(value: AnimationValue): TimelineElement;
-}
-
-export function getParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationValueKind {
- if (param.type === "number") {
- return "number";
- }
-
- if (param.type === "color") {
- return "color";
- }
-
- return "discrete";
-}
-
-export function getParamDefaultInterpolation({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationInterpolation {
- return param.type === "number" || param.type === "color" ? "linear" : "hold";
-}
-
-function getParamNumericRange({
- param,
-}: {
- param: ParamDefinition;
-}): NumericSpec | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- min: param.min,
- max: param.max,
- step: param.step,
- };
-}
-
-function coerceParamValue({
- param,
- value,
-}: {
- param: ParamDefinition;
- value: AnimationValue;
-}): number | string | boolean | null {
- if (param.type === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- const steppedValue = snapToStep({ value, step: param.step });
- const minValue = param.min;
- const maxValue = param.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
- }
-
- if (param.type === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (param.type === "boolean") {
- return typeof value === "boolean" ? value : null;
- }
-
- if (typeof value !== "string") {
- return null;
- }
-
- return param.options.some((option) => option.value === value) ? value : null;
-}
-
-function buildGraphicParamDescriptor({
- element,
- paramKey,
-}: {
- element: TimelineElement;
- paramKey: string;
-}): AnimationPathDescriptor | null {
- if (element.type !== "graphic") {
- return null;
- }
-
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const param = definition.params.find((candidate) => candidate.key === paramKey);
- if (!param) {
- return null;
- }
-
- return {
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRange: getParamNumericRange({ param }),
- getBaseValue: () => element.params[param.key] ?? param.default,
- setBaseValue: (value) => {
- const coercedValue = coerceParamValue({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- params: {
- ...element.params,
- [param.key]: coercedValue,
- },
- };
- },
- };
-}
-
-function buildEffectParamDescriptor({
- element,
- effectId,
- paramKey,
-}: {
- element: TimelineElement;
- effectId: string;
- paramKey: string;
-}): AnimationPathDescriptor | null {
- if (!isVisualElement(element)) {
- return null;
- }
-
- const effect = element.effects?.find((candidate) => candidate.id === effectId);
- if (!effect) {
- return null;
- }
-
- registerDefaultEffects();
- const definition = effectsRegistry.get(effect.type);
- const param = definition.params.find((candidate) => candidate.key === paramKey);
- if (!param) {
- return null;
- }
-
- return {
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRange: getParamNumericRange({ param }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: (value) => {
- const coercedValue = coerceParamValue({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- effects:
- element.effects?.map((candidate) =>
- candidate.id !== effectId
- ? candidate
- : {
- ...candidate,
- params: {
- ...candidate.params,
- [param.key]: coercedValue,
- },
- },
- ) ?? element.effects,
- };
- },
- };
-}
-
-export function isAnimationPath(
- propertyPath: string,
-): propertyPath is AnimationPath {
- return (
- isAnimationPropertyPath(propertyPath) ||
- isGraphicParamPath(propertyPath) ||
- isEffectParamPath(propertyPath)
- );
-}
-
-export function resolveAnimationTarget({
- element,
- path,
-}: {
- element: TimelineElement;
- path: AnimationPath;
-}): AnimationPathDescriptor | null {
- if (isAnimationPropertyPath(path)) {
- const propertyDefinition = getAnimationPropertyDefinition({
- propertyPath: path,
- });
- if (!propertyDefinition.supportsElement({ element })) {
- return null;
- }
-
- return {
- valueKind: propertyDefinition.valueKind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- numericRange: propertyDefinition.numericRange,
- getBaseValue: () =>
- getElementBaseValueForProperty({
- element,
- propertyPath: path,
- }),
- setBaseValue: (value) => {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath: path,
- value,
- });
- if (coercedValue === null) {
- return element;
- }
-
- return withElementBaseValueForProperty({
- element,
- propertyPath: path,
- value: coercedValue,
- });
- },
- };
- }
-
- const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
- if (graphicParamTarget) {
- return buildGraphicParamDescriptor({
- element,
- paramKey: graphicParamTarget.paramKey,
- });
- }
-
- const effectParamTarget = parseEffectParamPath({ propertyPath: path });
- if (effectParamTarget) {
- return buildEffectParamDescriptor({
- element,
- effectId: effectParamTarget.effectId,
- paramKey: effectParamTarget.paramKey,
- });
- }
-
- return null;
-}
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+} from "@/lib/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/lib/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/lib/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/lib/params";
+import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
+import { getGraphicDefinition } from "@/lib/graphics";
+import type { TimelineElement } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+import { parseColorToLinearRgba } from "./binding-values";
+
+export interface AnimationPathDescriptor {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial>;
+ coerceValue(value: AnimationValue): AnimationValue | null;
+ getBaseValue(): AnimationValue | null;
+ setBaseValue(value: AnimationValue): TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationBindingKind {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationInterpolation {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+function getParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): Partial> | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ value: {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ },
+ };
+}
+
+export function coerceAnimationValueForParam({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: AnimationValue;
+}): number | string | boolean | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
+
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: getParamNumericRange({ param }),
+ coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForParam({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: coercedValue,
+ },
+ };
+ },
+ };
+}
+
+function buildEffectParamDescriptor({
+ element,
+ effectId,
+ paramKey,
+}: {
+ element: TimelineElement;
+ effectId: string;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (!isVisualElement(element)) {
+ return null;
+ }
+
+ const effect = element.effects?.find((candidate) => candidate.id === effectId);
+ if (!effect) {
+ return null;
+ }
+
+ registerDefaultEffects();
+ const definition = effectsRegistry.get(effect.type);
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: getParamNumericRange({ param }),
+ coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForParam({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ [param.key]: coercedValue,
+ },
+ },
+ ) ?? element.effects,
+ };
+ },
+ };
+}
+
+export function isAnimationPath(
+ propertyPath: string,
+): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath(propertyPath) ||
+ isGraphicParamPath(propertyPath) ||
+ isEffectParamPath(propertyPath)
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath(path)) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRanges: propertyDefinition.numericRanges,
+ coerceValue: (value) =>
+ coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ }),
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: (value) => {
+ const coercedValue = propertyDefinition.coerceValue({ value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return withElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ value: coercedValue,
+ });
+ },
+ };
+ }
+
+ const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
+ if (graphicParamTarget) {
+ return buildGraphicParamDescriptor({
+ element,
+ paramKey: graphicParamTarget.paramKey,
+ });
+ }
+
+ const effectParamTarget = parseEffectParamPath({ propertyPath: path });
+ if (effectParamTarget) {
+ return buildEffectParamDescriptor({
+ element,
+ effectId: effectParamTarget.effectId,
+ paramKey: effectParamTarget.paramKey,
+ });
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
index ac2e0e78..453723b0 100644
--- a/apps/web/src/lib/animation/types.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,119 +1,220 @@
-export const ANIMATION_PROPERTY_PATHS = [
- "transform.position",
- "transform.scaleX",
- "transform.scaleY",
- "transform.rotate",
- "opacity",
- "volume",
- "color",
- "background.color",
- "background.paddingX",
- "background.paddingY",
- "background.offsetX",
- "background.offsetY",
- "background.cornerRadius",
-] as const;
-
-export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
-export type GraphicParamPath = `params.${string}`;
-export type EffectParamPath = `effects.${string}.params.${string}`;
-export type AnimationPath =
- | AnimationPropertyPath
- | GraphicParamPath
- | EffectParamPath;
-
-export const ANIMATION_PROPERTY_GROUPS = {
- "transform.scale": ["transform.scaleX", "transform.scaleY"],
-} as const satisfies Record>;
-
-export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
-
-export type VectorValue = { x: number; y: number };
-
-export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
-export type DiscreteValue = boolean | string;
-export type AnimationValue = number | string | boolean | VectorValue;
-
-export type ContinuousKeyframeInterpolation = "linear" | "hold";
-export type DiscreteKeyframeInterpolation = "hold";
-export type AnimationInterpolation =
- | ContinuousKeyframeInterpolation
- | DiscreteKeyframeInterpolation;
-
-interface BaseAnimationKeyframe<
- TValue extends AnimationValue,
- TInterpolation extends AnimationInterpolation,
-> {
- id: string;
- time: number; // relative to element start time
- value: TValue;
- interpolation: TInterpolation;
-}
-
-export interface NumberKeyframe
- extends BaseAnimationKeyframe {}
-
-export interface ColorKeyframe
- extends BaseAnimationKeyframe {}
-
-export interface DiscreteKeyframe
- extends BaseAnimationKeyframe {}
-
-export interface VectorKeyframe
- extends BaseAnimationKeyframe {}
-
-export type AnimationKeyframe =
- | NumberKeyframe
- | ColorKeyframe
- | DiscreteKeyframe
- | VectorKeyframe;
-
-interface BaseAnimationChannel<
- TValueKind extends AnimationValueKind,
- TKeyframe extends AnimationKeyframe,
-> {
- valueKind: TValueKind;
- keyframes: TKeyframe[];
-}
-
-export interface NumberAnimationChannel
- extends BaseAnimationChannel<"number", NumberKeyframe> {}
-
-export interface ColorAnimationChannel
- extends BaseAnimationChannel<"color", ColorKeyframe> {}
-
-export interface DiscreteAnimationChannel
- extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
-
-export interface VectorAnimationChannel
- extends BaseAnimationChannel<"vector", VectorKeyframe> {}
-
-export type AnimationChannel =
- | NumberAnimationChannel
- | ColorAnimationChannel
- | DiscreteAnimationChannel
- | VectorAnimationChannel;
-
-export type ElementAnimationChannelMap = Record<
- string,
- AnimationChannel | undefined
->;
-
-export interface ElementAnimations {
- channels: ElementAnimationChannelMap;
-}
-
-export interface ElementKeyframe {
- propertyPath: AnimationPath;
- id: string;
- time: number;
- value: AnimationValue;
- interpolation: AnimationInterpolation;
-}
-
-export interface SelectedKeyframeRef {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- keyframeId: string;
-}
+import type { ParamValues } from "@/lib/params";
+
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.position",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+] as const;
+
+export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+export type GraphicParamPath = `params.${string}`;
+export type EffectParamPath = `effects.${string}.params.${string}`;
+export type AnimationPath =
+ | AnimationPropertyPath
+ | GraphicParamPath
+ | EffectParamPath;
+
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+export type DiscreteValue = boolean | string;
+export type AnimationValue = number | string | boolean | VectorValue;
+export interface AnimationPropertyValueMap {
+ "transform.position": VectorValue;
+ "transform.scaleX": number;
+ "transform.scaleY": number;
+ "transform.rotate": number;
+ opacity: number;
+ volume: number;
+ color: string;
+ "background.color": string;
+ "background.paddingX": number;
+ "background.paddingY": number;
+ "background.offsetX": number;
+ "background.offsetY": number;
+ "background.cornerRadius": number;
+}
+export type DynamicAnimationPathValue = ParamValues[string];
+export type AnimationValueForPath =
+ TPath extends AnimationPropertyPath
+ ? AnimationPropertyValueMap[TPath]
+ : TPath extends GraphicParamPath | EffectParamPath
+ ? DynamicAnimationPathValue
+ : never;
+export type AnimationNumericPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never;
+}[AnimationPropertyPath];
+export type AnimationColorPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never;
+}[AnimationPropertyPath];
+
+export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | ContinuousKeyframeInterpolation
+ | DiscreteKeyframeInterpolation;
+
+export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
+export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
+export type ScalarSegmentType = "step" | "linear" | "bezier";
+export type TangentMode = "auto" | "aligned" | "broken" | "flat";
+export type ChannelExtrapolationMode = "hold" | "linear";
+
+export interface CurveHandle {
+ dt: number;
+ dv: number;
+}
+
+interface BaseAnimationKeyframe {
+ id: string;
+ time: number; // relative to element start time
+ value: TValue;
+}
+
+export interface ScalarAnimationKey extends BaseAnimationKeyframe {
+ leftHandle?: CurveHandle;
+ rightHandle?: CurveHandle;
+ segmentToNext: ScalarSegmentType;
+ tangentMode: TangentMode;
+}
+
+export interface DiscreteAnimationKey
+ extends BaseAnimationKeyframe {}
+
+export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
+
+export interface ScalarAnimationChannel {
+ kind: "scalar";
+ keys: ScalarAnimationKey[];
+ extrapolation?: {
+ before: ChannelExtrapolationMode;
+ after: ChannelExtrapolationMode;
+ };
+}
+
+export interface DiscreteAnimationChannel {
+ kind: "discrete";
+ keys: DiscreteAnimationKey[];
+}
+
+export type AnimationChannel =
+ | ScalarAnimationChannel
+ | DiscreteAnimationChannel;
+
+export type ElementAnimationChannelMap = Record<
+ string,
+ AnimationChannel | undefined
+>;
+
+export interface AnimationBindingComponent {
+ key: TKey;
+ channelId: string;
+}
+
+interface BaseAnimationBinding<
+ TKind extends AnimationBindingKind,
+ TComponentKey extends string,
+> {
+ path: AnimationPath;
+ kind: TKind;
+ components: AnimationBindingComponent[];
+}
+
+export interface NumberAnimationBinding
+ extends BaseAnimationBinding<"number", "value"> {}
+
+export interface Vector2AnimationBinding
+ extends BaseAnimationBinding<"vector2", "x" | "y"> {}
+
+export interface ColorAnimationBinding
+ extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
+ colorSpace: "srgb-linear";
+}
+
+export interface DiscreteAnimationBinding
+ extends BaseAnimationBinding<"discrete", "value"> {}
+
+export type AnimationBindingInstance =
+ | NumberAnimationBinding
+ | Vector2AnimationBinding
+ | ColorAnimationBinding
+ | DiscreteAnimationBinding;
+
+export interface AnimationBindingByKind {
+ number: NumberAnimationBinding;
+ vector2: Vector2AnimationBinding;
+ color: ColorAnimationBinding;
+ discrete: DiscreteAnimationBinding;
+}
+
+export type AnimationBindingOfKind =
+ AnimationBindingByKind[TKind];
+
+export type ElementAnimationBindingMap = Record<
+ string,
+ AnimationBindingInstance | undefined
+>;
+
+export interface ElementAnimations {
+ bindings: ElementAnimationBindingMap;
+ channels: ElementAnimationChannelMap;
+}
+
+export type NormalizedCubicBezier = [number, number, number, number];
+
+export interface ScalarGraphChannelTarget {
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channelId: string;
+}
+
+export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
+ channel: ScalarAnimationChannel;
+}
+
+export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
+ keyframeId: string;
+}
+
+export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
+ keyframe: ScalarAnimationKey;
+ keyframeIndex: number;
+ previousKey: ScalarAnimationKey | null;
+ nextKey: ScalarAnimationKey | null;
+}
+
+export interface ScalarCurveKeyframePatch {
+ leftHandle?: CurveHandle | null;
+ rightHandle?: CurveHandle | null;
+ segmentToNext?: ScalarSegmentType;
+ tangentMode?: TangentMode;
+}
+
+export interface ElementKeyframe {
+ propertyPath: AnimationPath;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}
+
+export interface SelectedKeyframeRef {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts
deleted file mode 100644
index f7f65049..00000000
--- a/apps/web/src/lib/animation/vector-channel.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
- VectorAnimationChannel,
- VectorValue,
-} from "@/lib/animation/types";
-
-export function isVectorValue(value: unknown): value is VectorValue {
- return (
- typeof value === "object" &&
- value !== null &&
- "x" in value &&
- "y" in value &&
- typeof (value as VectorValue).x === "number" &&
- typeof (value as VectorValue).y === "number"
- );
-}
-
-export function getVectorChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): VectorAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "vector") {
- return undefined;
- }
- return channel;
-}
-
-export function getVectorChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: VectorAnimationChannel | undefined;
- time: number;
- fallbackValue: VectorValue;
-}): VectorValue {
- if (!channel || channel.keyframes.length === 0) {
- return fallbackValue;
- }
-
- const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
- const first = keyframes[0];
- const last = keyframes[keyframes.length - 1];
-
- if (!first || !last) return fallbackValue;
- if (time <= first.time) return first.value;
- if (time >= last.time) return last.value;
-
- for (let i = 0; i < keyframes.length - 1; i++) {
- const left = keyframes[i];
- const right = keyframes[i + 1];
- if (time < left.time || time > right.time) continue;
-
- if (left.interpolation === "hold") return left.value;
-
- const span = right.time - left.time;
- if (span === 0) return right.value;
-
- const t = (time - left.time) / span;
- return {
- x: left.value.x + (right.value.x - left.value.x) * t,
- y: left.value.y + (right.value.y - left.value.y) * t,
- };
- }
-
- return last.value;
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
index ea19286c..319a827b 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -1,5 +1,6 @@
-export * from "./remove-effect-param-keyframe";
-export * from "./remove-keyframe";
-export * from "./retime-keyframe";
-export * from "./upsert-effect-param-keyframe";
-export * from "./upsert-keyframe";
+export * from "./remove-effect-param-keyframe";
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./update-scalar-keyframe-curve";
+export * from "./upsert-effect-param-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
index b5c7550d..71b2ac21 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -1,8 +1,9 @@
import { EditorCore } from "@/core";
import {
- getChannel,
- getChannelValueAtTime,
+ hasKeyframesForPath,
+ getKeyframeById,
removeElementKeyframe,
+ resolveAnimationPathValueAtTime,
resolveAnimationTarget,
} from "@/lib/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command";
@@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({
propertyPath: AnimationPath;
keyframeId: string;
}): AnimationValue | null {
- const channel = getChannel({
- animations: element.animations,
- propertyPath,
- });
- const keyframe = channel?.keyframes.find(
- (candidate) => candidate.id === keyframeId,
- );
- if (!channel || !keyframe) {
- return null;
- }
-
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
return null;
@@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({
return null;
}
- return getChannelValueAtTime({
- channel,
- time: keyframe.time,
+ const keyframe = getKeyframeById({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+ if (!keyframe) {
+ return null;
+ }
+
+ return resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath,
+ localTime: keyframe.time,
fallbackValue: baseValue,
});
}
@@ -72,8 +72,10 @@ function removeKeyframeAndPersist({
keyframeId,
});
- const isChannelNowEmpty =
- getChannel({ animations: nextAnimations, propertyPath }) === undefined;
+ const isChannelNowEmpty = !hasKeyframesForPath({
+ animations: nextAnimations,
+ propertyPath,
+ });
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
new file mode 100644
index 00000000..f1eaab9d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
@@ -0,0 +1,85 @@
+import { EditorCore } from "@/core";
+import {
+ resolveAnimationTarget,
+ updateScalarKeyframeCurve,
+} from "@/lib/animation";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type {
+ AnimationPath,
+ ScalarCurveKeyframePatch,
+} from "@/lib/animation/types";
+import type { TimelineTrack } from "@/lib/timeline";
+
+export class UpdateScalarKeyframeCurveCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly componentKey: string;
+ private readonly keyframeId: string;
+ private readonly patch: ScalarCurveKeyframePatch;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.componentKey = componentKey;
+ this.keyframeId = keyframeId;
+ this.patch = patch;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: updateScalarKeyframeCurve({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ componentKey: this.componentKey,
+ keyframeId: this.keyframeId,
+ patch: this.patch,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index edbf5198..d0f01104 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -1,8 +1,13 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import {
+ buildEffectParamPath,
+ resolveAnimationTarget,
+ upsertPathKeyframe,
+} from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { AnimationInterpolation } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
@@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly effectId: string;
private readonly paramKey: string;
private readonly time: number;
- private readonly value: number;
- private readonly interpolation: "linear" | "hold" | undefined;
+ private readonly value: number | string | boolean;
+ private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
constructor({
@@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
effectId: string;
paramKey: string;
time: number;
- value: number;
- interpolation?: "linear" | "hold";
+ value: number | string | boolean;
+ interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
super();
@@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementPredicate: isVisualElement,
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
- const animations = upsertEffectParamKeyframe({
- animations: element.animations,
+ const propertyPath = buildEffectParamPath({
effectId: this.effectId,
paramKey: this.paramKey,
+ });
+ const target = resolveAnimationTarget({
+ element,
+ path: propertyPath,
+ });
+ if (!target) {
+ return element;
+ }
+
+ const animations = upsertPathKeyframe({
+ animations: element.animations,
+ propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
+ kind: target.kind,
+ defaultInterpolation: target.defaultInterpolation,
+ coerceValue: target.coerceValue,
});
return { ...element, animations };
},
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
index 98589987..bd56749a 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
- valueKind: target.valueKind,
+ kind: target.kind,
defaultInterpolation: target.defaultInterpolation,
- numericRange: target.numericRange,
+ coerceValue: target.coerceValue,
}),
};
},
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
index c14a5a42..fc195cba 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
@@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
return;
}
- if (canRecoverSourceAudio({ element: sourceElement })) {
+ if (canRecoverSourceAudio(sourceElement)) {
editor.timeline.updateTracks(
updateSourceAudioEnabled({
tracks: this.savedState,
@@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
.media
.getAssets()
.find((asset) => asset.id === sourceElement.mediaId);
- if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
+ if (!canExtractSourceAudio(sourceElement, mediaAsset)) {
return;
}
if (sourceElement.duration <= 0) {
diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts
index 591b36ad..aa051615 100644
--- a/apps/web/src/lib/env/web.ts
+++ b/apps/web/src/lib/env/web.ts
@@ -23,11 +23,6 @@ const webEnvSchema = z.object({
MARBLE_WORKSPACE_KEY: z.string(),
FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: z.string(),
- CLOUDFLARE_ACCOUNT_ID: z.string(),
- R2_ACCESS_KEY_ID: z.string(),
- R2_SECRET_ACCESS_KEY: z.string(),
- R2_BUCKET_NAME: z.string(),
- MODAL_TRANSCRIPTION_URL: z.url(),
});
export type WebEnv = z.infer;
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
index 41093c88..72b2ca80 100644
--- a/apps/web/src/lib/masks/__tests__/snap.test.ts
+++ b/apps/web/src/lib/masks/__tests__/snap.test.ts
@@ -1,235 +1,237 @@
-import { describe, expect, test } from "bun:test";
-import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
-import { getMaskSnapGeometry } from "@/lib/masks/geometry";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
-
-const bounds: ElementBounds = {
- cx: 200,
- cy: 150,
- width: 200,
- height: 100,
- rotation: 0,
-};
-
-const canvasSize = {
- width: 400,
- height: 300,
-};
-
-const snapThreshold = {
- x: 8,
- y: 8,
-};
-
-function buildSplitParams(
- overrides: Partial = {},
-): SplitMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- ...overrides,
- };
-}
-
-function buildRectangleParams(
- overrides: Partial = {},
-): RectangleMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- centerX: 0,
- centerY: 0,
- width: 0.4,
- height: 0.2,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function sortSegment(
- segment: [{ x: number; y: number }, { x: number; y: number }],
-): [{ x: number; y: number }, { x: number; y: number }] {
- return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
- { x: number; y: number },
- { x: number; y: number },
- ];
-}
-
-describe("mask geometry", () => {
- test("resolves split mask center from centerX and centerY", () => {
- expect(
- getMaskSnapGeometry({
- params: buildSplitParams({
- centerX: 0.25,
- centerY: -0.5,
- rotation: 45,
- }),
- bounds,
- }),
- ).toEqual({
- position: { x: 50, y: -50 },
- size: { width: 0, height: 0 },
- rotation: 45,
- });
- });
-
- test("resolves box mask center and size from centerX and centerY", () => {
- expect(
- getMaskSnapGeometry({
- params: buildRectangleParams({
- centerX: -0.25,
- centerY: 0.5,
- width: 0.5,
- height: 0.6,
- rotation: 30,
- }),
- bounds,
- }),
- ).toEqual({
- position: { x: -50, y: 50 },
- size: { width: 100, height: 60 },
- rotation: 30,
- });
- });
-
- test("returns a vertical split stroke segment for rotation 0", () => {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams: buildSplitParams(),
- width: bounds.width,
- height: bounds.height,
- });
-
- expect(segment).not.toBeNull();
- if (!segment) {
- throw new Error("Expected split stroke segment for rotation 0");
- }
- expect(sortSegment(segment)).toEqual([
- { x: bounds.width / 2, y: 0 },
- { x: bounds.width / 2, y: bounds.height },
- ]);
- });
-
- test("returns a horizontal split stroke segment for rotation 90", () => {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams: buildSplitParams({ rotation: 90 }),
- width: bounds.width,
- height: bounds.height,
- });
-
- expect(segment).not.toBeNull();
- if (!segment) {
- throw new Error("Expected split stroke segment for rotation 90");
- }
- expect(sortSegment(segment)).toEqual([
- { x: 0, y: bounds.height / 2 },
- { x: bounds.width, y: bounds.height / 2 },
- ]);
- });
-});
-
-describe("mask snapping", () => {
- test("snaps split mask movement using the shared position pipeline", () => {
- const result = snapMaskInteraction({
- handleId: "position",
- startParams: buildSplitParams({
- centerX: 0.03,
- centerY: -0.04,
- }),
- proposedParams: buildSplitParams({
- centerX: 0.03,
- centerY: -0.04,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.centerX).toBe(0);
- expect(result.params.centerY).toBe(0);
- expect(result.activeLines).toEqual([
- { type: "vertical", position: 0 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("snaps box mask movement against element center and edges", () => {
- const result = snapMaskInteraction({
- handleId: "position",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- centerX: 0.29,
- centerY: 0.03,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.centerX).toBeCloseTo(0.3);
- expect(result.params.centerY).toBe(0);
- expect(result.activeLines).toEqual([
- { type: "vertical", position: 100 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("snaps mask rotation through the shared rotation path", () => {
- const result = snapMaskInteraction({
- handleId: "rotation",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- rotation: 88,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.rotation).toBe(90);
- expect(result.activeLines).toEqual([]);
- });
-
- test("snaps edge resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "right",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- width: 0.98,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.width).toBe(1);
- expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
- });
-
- test("snaps corner resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "bottom-right",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- width: 0.99,
- height: 0.495,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.width).toBe(1);
- expect(result.params.height).toBe(0.5);
- expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
- });
-});
+import { describe, expect, test } from "bun:test";
+import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
+import { getMaskSnapGeometry } from "@/lib/masks/geometry";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
+
+const bounds: ElementBounds = {
+ cx: 200,
+ cy: 150,
+ width: 200,
+ height: 100,
+ rotation: 0,
+};
+
+const canvasSize = {
+ width: 400,
+ height: 300,
+};
+
+const snapThreshold = {
+ x: 8,
+ y: 8,
+};
+
+function buildSplitParams(
+ overrides: Partial = {},
+): SplitMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ ...overrides,
+ };
+}
+
+function buildRectangleParams(
+ overrides: Partial = {},
+): RectangleMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ width: 0.4,
+ height: 0.2,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function sortSegment(
+ segment: [{ x: number; y: number }, { x: number; y: number }],
+): [{ x: number; y: number }, { x: number; y: number }] {
+ return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
+ { x: number; y: number },
+ { x: number; y: number },
+ ];
+}
+
+describe("mask geometry", () => {
+ test("resolves split mask center from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildSplitParams({
+ centerX: 0.25,
+ centerY: -0.5,
+ rotation: 45,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: 50, y: -50 },
+ size: { width: 0, height: 0 },
+ rotation: 45,
+ });
+ });
+
+ test("resolves box mask center and size from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildRectangleParams({
+ centerX: -0.25,
+ centerY: 0.5,
+ width: 0.5,
+ height: 0.6,
+ rotation: 30,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: -50, y: 50 },
+ size: { width: 100, height: 60 },
+ rotation: 30,
+ });
+ });
+
+ test("returns a vertical split stroke segment for rotation 0", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams(),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 0");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: bounds.width / 2, y: 0 },
+ { x: bounds.width / 2, y: bounds.height },
+ ]);
+ });
+
+ test("returns a horizontal split stroke segment for rotation 90", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams({ rotation: 90 }),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 90");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: 0, y: bounds.height / 2 },
+ { x: bounds.width, y: bounds.height / 2 },
+ ]);
+ });
+});
+
+describe("mask snapping", () => {
+ test("snaps split mask movement using the shared position pipeline", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ proposedParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBe(0);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps box mask movement against element center and edges", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ centerX: 0.29,
+ centerY: 0.03,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBeCloseTo(0.3);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 100 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps mask rotation through the shared rotation path", () => {
+ const result = snapMaskInteraction({
+ handleId: "rotation",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ rotation: 88,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.rotation).toBe(90);
+ expect(result.activeLines).toEqual([]);
+ });
+
+ test("snaps edge resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.98,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+
+ test("snaps corner resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "bottom-right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.99,
+ height: 0.495,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.params.height).toBe(0.5);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+});
diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
index 1946f1e0..f2b17d1a 100644
--- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
+++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
+import { upsertElementKeyframe } from "@/lib/animation";
import type { UploadAudioElement, VideoElement } from "@/lib/timeline";
import {
buildSeparatedAudioElement,
@@ -25,32 +26,7 @@ describe("audio separation", () => {
volume: -6,
muted: true,
retime: { rate: 1.25, maintainPitch: true },
- animations: {
- channels: {
- volume: {
- valueKind: "number",
- keyframes: [
- {
- id: "volume-keyframe",
- time: 2,
- value: -12,
- interpolation: "linear",
- },
- ],
- },
- opacity: {
- valueKind: "number",
- keyframes: [
- {
- id: "opacity-keyframe",
- time: 1,
- value: 0.5,
- interpolation: "linear",
- },
- ],
- },
- },
- },
+ animations: buildAnimations(),
});
const separatedAudioElement = buildSeparatedAudioElement({
@@ -71,11 +47,14 @@ describe("audio separation", () => {
muted: true,
retime: { rate: 1.25, maintainPitch: true },
});
- expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
+ expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([
"volume",
]);
+ expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
+ "volume:value",
+ ]);
expect(
- separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
+ separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id,
).not.toBe("volume-keyframe");
});
@@ -143,3 +122,23 @@ function buildAudioElement(
...overrides,
} satisfies UploadAudioElement;
}
+
+function buildAnimations() {
+ const withVolume = upsertElementKeyframe({
+ animations: undefined,
+ propertyPath: "volume",
+ time: 2,
+ value: -12,
+ interpolation: "linear",
+ keyframeId: "volume-keyframe",
+ });
+
+ return upsertElementKeyframe({
+ animations: withVolume,
+ propertyPath: "opacity",
+ time: 1,
+ value: 0.5,
+ interpolation: "linear",
+ keyframeId: "opacity-keyframe",
+ });
+}
diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts
index 093edfbc..0ae1321f 100644
--- a/apps/web/src/lib/timeline/audio-separation/index.ts
+++ b/apps/web/src/lib/timeline/audio-separation/index.ts
@@ -1,4 +1,4 @@
-import { cloneAnimations, getChannel } from "@/lib/animation";
+import { cloneAnimations } from "@/lib/animation";
import type { ElementAnimations } from "@/lib/animation/types";
import type { MediaAsset } from "@/lib/media/types";
import { DEFAULTS } from "@/lib/timeline/defaults";
@@ -42,11 +42,9 @@ export function canExtractSourceAudio({
);
}
-export function canRecoverSourceAudio({
- element,
-}: {
- element: TimelineElement;
-}): element is VideoElement {
+export function canRecoverSourceAudio(
+ element: TimelineElement,
+): element is VideoElement {
return element.type === "video" && isSourceAudioSeparated({ element });
}
@@ -119,16 +117,27 @@ function cloneVolumeAnimations({
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations | undefined {
- const volumeChannel = getChannel({ animations, propertyPath: "volume" });
- if (!volumeChannel) {
+ const volumeBinding = animations?.bindings.volume;
+ if (!volumeBinding) {
+ return undefined;
+ }
+
+ const subsetChannels = Object.fromEntries(
+ volumeBinding.components.flatMap((component) => {
+ const channel = animations?.channels[component.channelId];
+ return channel ? [[component.channelId, channel] as const] : [];
+ }),
+ );
+ if (Object.keys(subsetChannels).length === 0) {
return undefined;
}
return cloneAnimations({
animations: {
- channels: {
- volume: volumeChannel,
+ bindings: {
+ volume: volumeBinding,
},
+ channels: subsetChannels,
},
shouldRegenerateKeyframeIds: true,
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
new file mode 100644
index 00000000..18b815b0
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV21ToV22 } from "../transformers/v21-to-v22";
+
+describe("V21 to V22 Migration", () => {
+ test("migrates legacy animation channels to bindings and component channels", () => {
+ const result = transformProjectV21ToV22({
+ project: {
+ id: "project-v21-animations",
+ version: 21,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: [
+ {
+ id: "track-1",
+ elements: [
+ {
+ id: "element-1",
+ type: "text",
+ animations: {
+ channels: {
+ opacity: {
+ valueKind: "number",
+ keyframes: [
+ {
+ id: "opacity-1",
+ time: 1,
+ value: 0.5,
+ interpolation: "linear",
+ },
+ ],
+ },
+ "transform.position": {
+ valueKind: "vector",
+ keyframes: [
+ {
+ id: "position-1",
+ time: 2,
+ value: { x: 10, y: 20 },
+ interpolation: "hold",
+ },
+ ],
+ },
+ color: {
+ valueKind: "color",
+ keyframes: [
+ {
+ id: "color-1",
+ time: 3,
+ value: "#ff0000",
+ interpolation: "linear",
+ },
+ ],
+ },
+ "effects.effect-1.params.enabled": {
+ valueKind: "discrete",
+ keyframes: [
+ {
+ id: "enabled-1",
+ time: 4,
+ value: true,
+ interpolation: "hold",
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(22);
+
+ const scenes = result.project.scenes as Array>;
+ const tracks = scenes[0].tracks as Array>;
+ const elements = tracks[0].elements as Array>;
+ const animations = elements[0].animations as Record;
+ const bindings = animations.bindings as Record>;
+ const channels = animations.channels as Record>;
+
+ expect(bindings.opacity).toEqual({
+ path: "opacity",
+ kind: "number",
+ components: [{ key: "value", channelId: "opacity:value" }],
+ });
+ expect(bindings["transform.position"]).toEqual({
+ path: "transform.position",
+ kind: "vector2",
+ components: [
+ { key: "x", channelId: "transform.position:x" },
+ { key: "y", channelId: "transform.position:y" },
+ ],
+ });
+ expect(bindings.color).toEqual({
+ path: "color",
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ { key: "r", channelId: "color:r" },
+ { key: "g", channelId: "color:g" },
+ { key: "b", channelId: "color:b" },
+ { key: "a", channelId: "color:a" },
+ ],
+ });
+ expect(bindings["effects.effect-1.params.enabled"]).toEqual({
+ path: "effects.effect-1.params.enabled",
+ kind: "discrete",
+ components: [
+ {
+ key: "value",
+ channelId: "effects.effect-1.params.enabled:value",
+ },
+ ],
+ });
+
+ expect(channels["opacity:value"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "opacity-1",
+ time: 1,
+ value: 0.5,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["transform.position:x"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "position-1",
+ time: 2,
+ value: 10,
+ segmentToNext: "step",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["transform.position:y"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "position-1",
+ time: 2,
+ value: 20,
+ segmentToNext: "step",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["color:r"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "color-1",
+ time: 3,
+ value: 1,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["color:g"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "color-1",
+ time: 3,
+ value: 0,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["color:b"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "color-1",
+ time: 3,
+ value: 0,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["color:a"]).toEqual({
+ kind: "scalar",
+ keys: [
+ {
+ id: "color-1",
+ time: 3,
+ value: 1,
+ segmentToNext: "linear",
+ tangentMode: "flat",
+ },
+ ],
+ });
+ expect(channels["effects.effect-1.params.enabled:value"]).toEqual({
+ kind: "discrete",
+ keys: [
+ {
+ id: "enabled-1",
+ time: 4,
+ value: true,
+ },
+ ],
+ });
+ });
+
+ test("skips projects already on v22", () => {
+ const result = transformProjectV21ToV22({
+ project: {
+ id: "project-v22",
+ version: 22,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v22");
+ });
+
+ test("skips projects not on v21", () => {
+ const result = transformProjectV21ToV22({
+ project: {
+ id: "project-v20",
+ version: 20,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v21");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
index 66e87713..b05d1837 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
@@ -1,92 +1,94 @@
-import { describe, expect, test } from "bun:test";
-import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
-import { v5Project } from "./fixtures";
-
-describe("V5 to V6 Migration", () => {
- test("converts number bookmarks to Bookmark objects", async () => {
- const result = transformProjectV5ToV6({
- project: v5Project as Parameters<
- typeof transformProjectV5ToV6
- >[0]["project"],
- });
-
- expect(result.skipped).toBe(false);
- expect(result.project.version).toBe(6);
-
- const mainScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[0];
- expect(mainScene.bookmarks).toEqual([
- { time: 2.0 },
- { time: 5.5 },
- { time: 12.0 },
- ]);
-
- const introScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[1];
- expect(introScene.bookmarks).toEqual([]);
- });
-
- test("skips projects that are already v6", () => {
- const result = transformProjectV5ToV6({
- project: {
- ...v5Project,
- version: 6,
- scenes: [
- {
- ...(v5Project as { scenes: unknown[] }).scenes[0],
- bookmarks: [{ time: 2 }, { time: 5 }],
- },
- ],
- } as Parameters[0]["project"],
- });
-
- expect(result.skipped).toBe(true);
- expect(result.reason).toBe("already v6");
- });
-
- test("skips projects with no id", () => {
- const result = transformProjectV5ToV6({
- project: {
- version: 5,
- scenes: [],
- } as Parameters[0]["project"],
- });
-
- expect(result.skipped).toBe(true);
- expect(result.reason).toBe("no project id");
- });
-
- test("preserves existing Bookmark objects with note, color, duration", () => {
- const projectWithRichBookmarks = {
- ...v5Project,
- version: 5,
- scenes: [
- {
- ...(v5Project as { scenes: Array> })
- .scenes[0],
- bookmarks: [
- { time: 1, note: "Intro", color: "#ef4444" },
- { time: 5.5, duration: 2 },
- ],
- },
- ],
- };
-
- const result = transformProjectV5ToV6({
- project: projectWithRichBookmarks as Parameters<
- typeof transformProjectV5ToV6
- >[0]["project"],
- });
-
- expect(result.skipped).toBe(false);
- const mainScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[0];
- expect(mainScene.bookmarks).toEqual([
- { time: 1, note: "Intro", color: "#ef4444" },
- { time: 5.5, duration: 2 },
- ]);
- });
-});
+import { describe, expect, test } from "bun:test";
+import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
+import { v5Project } from "./fixtures";
+
+describe("V5 to V6 Migration", () => {
+ test("converts number bookmarks to Bookmark objects", async () => {
+ const result = transformProjectV5ToV6({
+ project: v5Project as Parameters<
+ typeof transformProjectV5ToV6
+ >[0]["project"],
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(6);
+
+ const mainScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[0];
+ expect(mainScene.bookmarks).toEqual([
+ { time: 2.0 },
+ { time: 5.5 },
+ { time: 12.0 },
+ ]);
+
+ const introScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[1];
+ expect(introScene.bookmarks).toEqual([]);
+ });
+
+ test("skips projects that are already v6", () => {
+ const firstScene = (v5Project as { scenes: Array> })
+ .scenes[0];
+ const result = transformProjectV5ToV6({
+ project: {
+ ...v5Project,
+ version: 6,
+ scenes: [
+ {
+ ...firstScene,
+ bookmarks: [{ time: 2 }, { time: 5 }],
+ },
+ ],
+ } as Parameters[0]["project"],
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v6");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV5ToV6({
+ project: {
+ version: 5,
+ scenes: [],
+ } as Parameters[0]["project"],
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+
+ test("preserves existing Bookmark objects with note, color, duration", () => {
+ const projectWithRichBookmarks = {
+ ...v5Project,
+ version: 5,
+ scenes: [
+ {
+ ...(v5Project as { scenes: Array> })
+ .scenes[0],
+ bookmarks: [
+ { time: 1, note: "Intro", color: "#ef4444" },
+ { time: 5.5, duration: 2 },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV5ToV6({
+ project: projectWithRichBookmarks as Parameters<
+ typeof transformProjectV5ToV6
+ >[0]["project"],
+ });
+
+ expect(result.skipped).toBe(false);
+ const mainScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[0];
+ expect(mainScene.bookmarks).toEqual([
+ { time: 1, note: "Intro", color: "#ef4444" },
+ { time: 5.5, duration: 2 },
+ ]);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index ea596435..ef41673e 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
+import { V21toV22Migration } from "./v21-to-v22";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 21;
+export const CURRENT_PROJECT_VERSION = 22;
export const migrations = [
new V0toV1Migration(),
@@ -47,4 +48,5 @@ export const migrations = [
new V18toV19Migration(),
new V19toV20Migration(),
new V20toV21Migration(),
+ new V21toV22Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts
new file mode 100644
index 00000000..15fd9edf
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts
@@ -0,0 +1,511 @@
+import { parseColorToLinearRgba } from "@/lib/animation/binding-values";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+const COLOR_COMPONENT_KEYS = ["r", "g", "b", "a"] as const;
+type LegacyInterpolation = "linear" | "hold";
+
+interface LegacyScalarKeyframe {
+ id: string;
+ time: number;
+ value: number;
+ interpolation: LegacyInterpolation;
+}
+
+interface LegacyDiscreteKeyframe {
+ id: string;
+ time: number;
+ value: string | boolean;
+}
+
+interface LegacyVectorValue {
+ x: number;
+ y: number;
+}
+
+interface LegacyVectorKeyframe {
+ id: string;
+ time: number;
+ value: LegacyVectorValue;
+ interpolation: LegacyInterpolation;
+}
+
+interface MigratedAnimationChannel {
+ binding: ProjectRecord;
+ channels: Record;
+}
+
+export function transformProjectV21ToV22({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 22) {
+ return { project, skipped: true, reason: "already v22" };
+ }
+ if (version !== 21) {
+ return { project, skipped: true, reason: "not v21" };
+ }
+
+ return {
+ project: {
+ ...migrateProjectAnimations({ project }),
+ version: 22,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProjectAnimations({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenes = project.scenes;
+ if (!Array.isArray(scenes)) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: scenes.map((scene) => migrateSceneAnimations({ scene })),
+ };
+}
+
+function migrateSceneAnimations({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const tracks = scene.tracks;
+ if (!Array.isArray(tracks)) {
+ return scene;
+ }
+
+ return {
+ ...scene,
+ tracks: tracks.map((track) => migrateTrackAnimations({ track })),
+ };
+}
+
+function migrateTrackAnimations({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ const elements = track.elements;
+ if (!Array.isArray(elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: elements.map((element) => migrateElementAnimations({ element })),
+ };
+}
+
+function migrateElementAnimations({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const animations = element.animations;
+ if (!isRecord(animations)) {
+ return element;
+ }
+
+ if (isRecord(animations.bindings)) {
+ return element;
+ }
+
+ const migratedAnimations = migrateLegacyAnimations({ animations });
+ if (!migratedAnimations) {
+ const { animations: _unusedAnimations, ...elementWithoutAnimations } = element;
+ return elementWithoutAnimations;
+ }
+
+ return {
+ ...element,
+ animations: migratedAnimations,
+ };
+}
+
+function migrateLegacyAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord | null {
+ const legacyChannels = animations.channels;
+ if (!isRecord(legacyChannels)) {
+ return null;
+ }
+
+ const nextBindings: Record = {};
+ const nextChannels: Record = {};
+
+ for (const [propertyPath, channel] of Object.entries(legacyChannels)) {
+ const migratedChannel = migrateLegacyChannel({
+ propertyPath,
+ channel,
+ });
+ if (!migratedChannel) {
+ continue;
+ }
+
+ nextBindings[propertyPath] = migratedChannel.binding;
+ Object.assign(nextChannels, migratedChannel.channels);
+ }
+
+ if (Object.keys(nextBindings).length === 0) {
+ return null;
+ }
+
+ return {
+ bindings: nextBindings,
+ channels: nextChannels,
+ };
+}
+
+function migrateLegacyChannel({
+ propertyPath,
+ channel,
+}: {
+ propertyPath: string;
+ channel: unknown;
+}): MigratedAnimationChannel | null {
+ if (!isRecord(channel)) {
+ return null;
+ }
+
+ switch (channel.valueKind) {
+ case "number":
+ return migrateNumberChannel({ propertyPath, channel });
+ case "discrete":
+ return migrateDiscreteChannel({ propertyPath, channel });
+ case "vector":
+ return migrateVectorChannel({ propertyPath, channel });
+ case "color":
+ return migrateColorChannel({ propertyPath, channel });
+ default:
+ return null;
+ }
+}
+
+function migrateNumberChannel({
+ propertyPath,
+ channel,
+}: {
+ propertyPath: string;
+ channel: ProjectRecord;
+}): MigratedAnimationChannel | null {
+ const legacyKeys = getLegacyScalarKeyframes({
+ channel,
+ isValidValue: (value): value is number =>
+ typeof value === "number" && Number.isFinite(value),
+ });
+ if (legacyKeys.length === 0) {
+ return null;
+ }
+
+ return {
+ binding: {
+ path: propertyPath,
+ kind: "number",
+ components: [
+ {
+ key: "value",
+ channelId: buildChannelId({
+ propertyPath,
+ componentKey: "value",
+ }),
+ },
+ ],
+ },
+ channels: {
+ [buildChannelId({ propertyPath, componentKey: "value" })]: {
+ kind: "scalar",
+ keys: legacyKeys.map((keyframe) => toScalarKeyframe({ keyframe })),
+ },
+ },
+ };
+}
+
+function migrateDiscreteChannel({
+ propertyPath,
+ channel,
+}: {
+ propertyPath: string;
+ channel: ProjectRecord;
+}): MigratedAnimationChannel | null {
+ const legacyKeys = getLegacyDiscreteKeyframes({ channel });
+ if (legacyKeys.length === 0) {
+ return null;
+ }
+
+ return {
+ binding: {
+ path: propertyPath,
+ kind: "discrete",
+ components: [
+ {
+ key: "value",
+ channelId: buildChannelId({
+ propertyPath,
+ componentKey: "value",
+ }),
+ },
+ ],
+ },
+ channels: {
+ [buildChannelId({ propertyPath, componentKey: "value" })]: {
+ kind: "discrete",
+ keys: legacyKeys.map((keyframe) => ({
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ })),
+ },
+ },
+ };
+}
+
+function migrateVectorChannel({
+ propertyPath,
+ channel,
+}: {
+ propertyPath: string;
+ channel: ProjectRecord;
+}): MigratedAnimationChannel | null {
+ const legacyKeys = getLegacyScalarKeyframes({
+ channel,
+ isValidValue: isLegacyVectorValue,
+ });
+ if (legacyKeys.length === 0) {
+ return null;
+ }
+
+ const xChannelId = buildChannelId({ propertyPath, componentKey: "x" });
+ const yChannelId = buildChannelId({ propertyPath, componentKey: "y" });
+
+ return {
+ binding: {
+ path: propertyPath,
+ kind: "vector2",
+ components: [
+ { key: "x", channelId: xChannelId },
+ { key: "y", channelId: yChannelId },
+ ],
+ },
+ channels: {
+ [xChannelId]: {
+ kind: "scalar",
+ keys: legacyKeys.map((keyframe) =>
+ toScalarKeyframe({
+ keyframe: {
+ ...keyframe,
+ value: keyframe.value.x,
+ },
+ }),
+ ),
+ },
+ [yChannelId]: {
+ kind: "scalar",
+ keys: legacyKeys.map((keyframe) =>
+ toScalarKeyframe({
+ keyframe: {
+ ...keyframe,
+ value: keyframe.value.y,
+ },
+ }),
+ ),
+ },
+ },
+ };
+}
+
+function migrateColorChannel({
+ propertyPath,
+ channel,
+}: {
+ propertyPath: string;
+ channel: ProjectRecord;
+}): MigratedAnimationChannel | null {
+ const legacyKeys = getLegacyScalarKeyframes({
+ channel,
+ isValidValue: (value): value is string => typeof value === "string",
+ });
+ if (legacyKeys.length === 0) {
+ return null;
+ }
+
+ const colorKeys = legacyKeys.flatMap((keyframe) => {
+ const linearRgba = parseColorToLinearRgba({ color: keyframe.value });
+ if (!linearRgba) {
+ return [];
+ }
+
+ return [
+ {
+ id: keyframe.id,
+ time: keyframe.time,
+ interpolation: keyframe.interpolation,
+ values: linearRgba,
+ },
+ ];
+ });
+ if (colorKeys.length === 0) {
+ return null;
+ }
+
+ const channels = Object.fromEntries(
+ COLOR_COMPONENT_KEYS.map((componentKey) => [
+ buildChannelId({ propertyPath, componentKey }),
+ {
+ kind: "scalar",
+ keys: colorKeys.map((keyframe) =>
+ toScalarKeyframe({
+ keyframe: {
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.values[componentKey],
+ interpolation: keyframe.interpolation,
+ },
+ }),
+ ),
+ },
+ ]),
+ );
+
+ return {
+ binding: {
+ path: propertyPath,
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: COLOR_COMPONENT_KEYS.map((componentKey) => ({
+ key: componentKey,
+ channelId: buildChannelId({ propertyPath, componentKey }),
+ })),
+ },
+ channels,
+ };
+}
+
+function getLegacyScalarKeyframes({
+ channel,
+ isValidValue,
+}: {
+ channel: ProjectRecord;
+ isValidValue: (value: unknown) => value is TValue;
+}): Array<{
+ id: string;
+ time: number;
+ value: TValue;
+ interpolation: LegacyInterpolation;
+}> {
+ const keyframes = channel.keyframes;
+ if (!Array.isArray(keyframes)) {
+ return [];
+ }
+
+ return keyframes.flatMap((keyframe) => {
+ if (!isRecord(keyframe)) {
+ return [];
+ }
+
+ if (
+ typeof keyframe.id !== "string" ||
+ typeof keyframe.time !== "number" ||
+ !Number.isFinite(keyframe.time) ||
+ !isValidValue(keyframe.value)
+ ) {
+ return [];
+ }
+
+ return [
+ {
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation:
+ keyframe.interpolation === "hold" ? "hold" : "linear",
+ },
+ ];
+ });
+}
+
+function getLegacyDiscreteKeyframes({
+ channel,
+}: {
+ channel: ProjectRecord;
+}): LegacyDiscreteKeyframe[] {
+ const keyframes = channel.keyframes;
+ if (!Array.isArray(keyframes)) {
+ return [];
+ }
+
+ return keyframes.flatMap((keyframe) => {
+ if (!isRecord(keyframe)) {
+ return [];
+ }
+
+ if (
+ typeof keyframe.id !== "string" ||
+ typeof keyframe.time !== "number" ||
+ !Number.isFinite(keyframe.time) ||
+ (typeof keyframe.value !== "string" && typeof keyframe.value !== "boolean")
+ ) {
+ return [];
+ }
+
+ return [
+ {
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ },
+ ];
+ });
+}
+
+function isLegacyVectorValue(value: unknown): value is LegacyVectorValue {
+ return (
+ isRecord(value) &&
+ typeof value.x === "number" &&
+ Number.isFinite(value.x) &&
+ typeof value.y === "number" &&
+ Number.isFinite(value.y)
+ );
+}
+
+function toScalarKeyframe({
+ keyframe,
+}: {
+ keyframe: LegacyScalarKeyframe;
+}): ProjectRecord {
+ return {
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ segmentToNext:
+ keyframe.interpolation === "hold" ? "step" : "linear",
+ tangentMode: "flat",
+ };
+}
+
+function buildChannelId({
+ propertyPath,
+ componentKey,
+}: {
+ propertyPath: string;
+ componentKey: string;
+}) {
+ return `${propertyPath}:${componentKey}`;
+}
diff --git a/apps/web/src/services/storage/migrations/v21-to-v22.ts b/apps/web/src/services/storage/migrations/v21-to-v22.ts
new file mode 100644
index 00000000..212109a5
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v21-to-v22.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV21ToV22 } from "./transformers/v21-to-v22";
+
+export class V21toV22Migration extends StorageMigration {
+ from = 21;
+ to = 22;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV21ToV22({ project });
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index a20c2654..0c4983b3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -70,12 +70,6 @@ services:
- MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder}
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
- # Transcription (Optional - leave blank to disable auto-captions)
- - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder}
- - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder}
- - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder}
- - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription}
- - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0}
depends_on:
db:
condition: service_healthy
diff --git a/turbo.json b/turbo.json
index 244b2f95..f1431ec3 100644
--- a/turbo.json
+++ b/turbo.json
@@ -17,12 +17,7 @@
"UPSTASH_REDIS_REST_TOKEN",
"MARBLE_WORKSPACE_KEY",
"FREESOUND_CLIENT_ID",
- "FREESOUND_API_KEY",
- "CLOUDFLARE_ACCOUNT_ID",
- "R2_ACCESS_KEY_ID",
- "R2_SECRET_ACCESS_KEY",
- "R2_BUCKET_NAME",
- "MODAL_TRANSCRIPTION_URL"
+ "FREESOUND_API_KEY"
]
},
"check-types": {