summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx249
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx328
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts90
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts448
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts153
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx70
-rw-r--r--apps/web/src/components/ui/tabs.tsx2
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts41
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts82
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts95
-rw-r--r--apps/web/src/lib/animation/index.ts13
-rw-r--r--apps/web/src/lib/animation/keyframes.ts141
-rw-r--r--apps/web/src/lib/animation/resolve.ts20
-rw-r--r--apps/web/src/lib/animation/types.ts60
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts11
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts85
16 files changed, 1838 insertions, 50 deletions
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<SVGSVGElement>(null);
+ const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(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<SVGCircleElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setActiveHandle(handle);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ }
+
+ function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
+ 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 (
+ <svg
+ ref={svgRef}
+ viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
+ className="bg-foreground/3 w-full cursor-crosshair select-none"
+ onPointerMove={(event) => onPointerMove({ event })}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerCancel}
+ >
+ <title>Bezier curve editor</title>
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={p1.x}
+ y2={p1.y}
+ className="stroke-foreground/8"
+ strokeWidth={1}
+ strokeDasharray="3 3"
+ />
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={c1Clamped.x}
+ y2={c1Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <line
+ x1={p1.x}
+ y1={p1.y}
+ x2={c2Clamped.x}
+ y2={c2Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <path
+ d={path}
+ fill="none"
+ className="stroke-primary"
+ strokeWidth={2}
+ strokeLinecap="round"
+ />
+ <circle
+ cx={p0.x}
+ cy={p0.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={p1.x}
+ cy={p1.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={c1Clamped.x}
+ cy={c1Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c1" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c1" })}
+ />
+ <circle
+ cx={c2Clamped.x}
+ cy={c2Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c2" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c2" })}
+ />
+ </svg>
+ );
+}
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 (
+ <Popover
+ open={open}
+ onOpenChange={(nextOpen) => {
+ if (!nextOpen) {
+ onCancelPreview?.();
+ }
+ onOpenChange?.(nextOpen);
+ }}
+ >
+ {children}
+ <PopoverContent
+ side={side}
+ sideOffset={8}
+ className="w-60 overflow-hidden px-0"
+ >
+ {componentOptions.length > 1 && (
+ <div className="border-b px-3 py-2">
+ <div className="bg-muted/40 inline-flex rounded-md p-0.5">
+ {componentOptions.map((component) => (
+ <button
+ key={component.key}
+ type="button"
+ onClick={() => onActiveComponentKeyChange?.(component.key)}
+ className={cn(
+ "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
+ activeComponentKey === component.key
+ ? "bg-background text-foreground shadow-xs"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {component.label}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="px-3 py-3">
+ {value ? (
+ <BezierGraph
+ value={value}
+ onChange={onPreviewValue}
+ onChangeEnd={onCommitValue}
+ onCancel={onCancelPreview}
+ />
+ ) : (
+ <GraphEditorEmptyState message={message} />
+ )}
+ </div>
+
+ <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
+ <TabsList className="px-3">
+ <TabsTrigger value="presets" className="text-xs">
+ Presets
+ </TabsTrigger>
+ <TabsTrigger value="saved" className="text-xs">
+ Saved
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent value="presets" className="px-3 pb-0">
+ <ExpandableGrid
+ isExpanded={isExpanded}
+ shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
+ onExpand={() => setIsExpanded(true)}
+ >
+ {BUILTIN_PRESETS.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ />
+ ))}
+ </ExpandableGrid>
+ </TabsContent>
+ <TabsContent value="saved" className="px-3">
+ <div className="grid grid-cols-3 gap-1">
+ {custom.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ onDelete={() => removePreset(preset.id)}
+ />
+ ))}
+ <button
+ type="button"
+ onClick={() => value && savePreset(value)}
+ disabled={!canEdit}
+ className={cn(
+ "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
+ canEdit
+ ? "hover:bg-foreground/5 cursor-pointer"
+ : "cursor-not-allowed opacity-50",
+ )}
+ >
+ <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
+ <HugeiconsIcon
+ icon={PlusSignIcon}
+ className="size-3.5 opacity-40"
+ />
+ </div>
+ <span className="text-[10px] leading-tight">Save</span>
+ </button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GraphEditorEmptyState({ message }: { message: string }) {
+ return (
+ <div
+ style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
+ className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
+ >
+ {message}
+ </div>
+ );
+}
+
+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 (
+ <div className="relative">
+ <div className="grid grid-cols-3 gap-1" style={gridStyle}>
+ {children}
+ </div>
+ {!isExpanded && shouldExpand && (
+ <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5"
+ onClick={onExpand}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown01Icon}
+ className="text-muted-foreground size-3"
+ />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function PresetItem({
+ preset,
+ isActive,
+ onSelect,
+ onDelete,
+ disabled,
+}: {
+ preset: EasingPreset;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onSelect}
+ disabled={disabled}
+ className={cn(
+ "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
+ disabled
+ ? "cursor-not-allowed opacity-50"
+ : "hover:bg-foreground/5 cursor-pointer",
+ isActive && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
+ isActive && "bg-primary/5!",
+ )}
+ >
+ <CurveThumb value={preset.value} />
+ </div>
+ <span
+ className={cn(
+ "text-[10px] leading-tight",
+ isActive ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {preset.label}
+ </span>
+ {onDelete && (
+ <Button
+ variant="destructive"
+ size="icon"
+ className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
+ onClick={(event) => {
+ event.stopPropagation();
+ onDelete();
+ }}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ )}
+ </button>
+ );
+}
+
+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 (
+ <svg
+ width={THUMB_WIDTH}
+ height={THUMB_HEIGHT}
+ viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
+ >
+ <title>Curve preset preview</title>
+ <path
+ d={`M${points.join("L")}`}
+ fill="none"
+ className="stroke-current"
+ strokeWidth={1.5}
+ strokeLinecap="round"
+ />
+ </svg>
+ );
+}
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<ElementAnimations | undefined>(
+ (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<string | null>(
+ null,
+ );
+ const hasPreviewRef = useRef(false);
+
+ const state = useMemo<GraphEditorSelectionState>(
+ () =>
+ 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-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index cb636d3c..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,7 +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,
@@ -63,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 });
};
@@ -91,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() }),
);
@@ -164,11 +163,11 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
- icon={
- <HugeiconsIcon
- icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
- />
- }
+ icon={
+ <HugeiconsIcon
+ icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
+ />
+ }
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
@@ -212,13 +211,35 @@ function ToolbarLeftSection() {
/>
</Tooltip>
- <Tooltip>
+ <GraphEditorPopover
+ open={graphEditor.open}
+ onOpenChange={graphEditor.onOpenChange}
+ value={
+ graphEditor.state.status === "ready"
+ ? graphEditor.state.cubicBezier
+ : null
+ }
+ message={graphEditor.state.message}
+ componentOptions={graphEditor.state.componentOptions}
+ activeComponentKey={graphEditor.state.activeComponentKey}
+ onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
+ onPreviewValue={graphEditor.onPreviewValue}
+ onCommitValue={graphEditor.onCommitValue}
+ onCancelPreview={graphEditor.onCancelPreview}
+ >
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
- tooltip="Open graph editor"
- onClick={() => {}}
+ tooltip={graphEditor.tooltip}
+ disabled={!graphEditor.canOpen}
+ buttonWrapper={(button) =>
+ graphEditor.canOpen ? (
+ <PopoverTrigger asChild>{button}</PopoverTrigger>
+ ) : (
+ button
+ )
+ }
/>
- </Tooltip>
+ </GraphEditorPopover>
</TooltipProvider>
</div>
);
@@ -338,12 +359,17 @@ function ToolbarButton({
{icon}
</Button>
);
+ const trigger = disabled ? (
+ <span className="inline-flex">{button}</span>
+ ) : buttonWrapper ? (
+ buttonWrapper(button)
+ ) : (
+ button
+ );
return (
<Tooltip delayDuration={200}>
- <TooltipTrigger asChild>
- {buttonWrapper ? buttonWrapper(button) : button}
- </TooltipTrigger>
+ <TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index 4fb028c8..5e70f88d 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
- "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
+ "ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
variant === "underline" && "px-4",
className,
)}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 6f262e57..d5da1003 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -11,6 +11,7 @@ import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
+ ScalarCurveKeyframePatch,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "opencut-wasm";
@@ -36,6 +37,7 @@ import {
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
+ UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
@@ -549,6 +551,45 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ updateKeyframeCurves({
+ keyframes,
+ }: {
+ keyframes: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+ }>;
+ }): 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/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/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/index.ts b/apps/web/src/lib/animation/index.ts
index 9cb85d2a..7d2646d9 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -12,8 +12,10 @@ export {
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
+ setBindingComponentChannel,
setChannel,
splitAnimationsAtTime,
+ updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
@@ -47,6 +49,17 @@ export {
} from "./keyframe-query";
export {
+ getEditableScalarChannel,
+ getEditableScalarChannels,
+ getScalarKeyframeContext,
+} from "./graph-channels";
+
+export {
+ getCurveHandlesForNormalizedCubicBezier,
+ getNormalizedCubicBezierForScalarSegment,
+} from "./curve-bridge";
+
+export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 8c17f8a6..7eaa5ed6 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -11,6 +11,7 @@ import type {
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
+ ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/lib/animation/types";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
@@ -209,7 +210,7 @@ function getBinding({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationBindingInstance | undefined {
return animations?.bindings[propertyPath];
}
@@ -224,6 +225,16 @@ function getChannelById({
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,
@@ -402,7 +413,7 @@ export function getChannel({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationChannel | undefined {
const binding = getBinding({ animations, propertyPath });
const primaryChannelId =
@@ -651,7 +662,7 @@ export function setChannel({
channel,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath });
@@ -659,29 +670,66 @@ export function setChannel({
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 })) {
- for (const component of binding.components) {
- delete nextAnimations.channels[component.channelId];
+ delete nextAnimations.channels[component.channelId];
+ const hasRemainingKeys = binding.components.some((candidate) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[candidate.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
}
- delete nextAnimations.bindings[propertyPath];
return toAnimation({
animations: nextAnimations,
});
}
- if (binding.components.length !== 1) {
- throw new Error(
- `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
- );
- }
-
- nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({
+ nextAnimations.channels[component.channelId] = normalizeChannel({
channel,
});
return toAnimation({
@@ -689,6 +737,73 @@ export function setChannel({
});
}
+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,
+ },
+ });
+}
+
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 3de97026..67f4a487 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,5 +1,9 @@
import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ AnimationPath,
AnimationPropertyPath,
+ AnimationValueForPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { Transform } from "@/lib/rendering";
@@ -96,7 +100,7 @@ export function resolveNumberAtTime({
}: {
baseValue: number;
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
@@ -115,7 +119,7 @@ export function resolveColorAtTime({
}: {
baseColor: string;
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
@@ -126,19 +130,17 @@ export function resolveColorAtTime({
});
}
-export function resolveAnimationPathValueAtTime<
- T extends number | string | boolean | Transform["position"],
->({
+export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: TPath;
localTime: number;
- fallbackValue: T;
-}): T {
+ fallbackValue: AnimationValueForPath<TPath>;
+}): AnimationValueForPath<TPath> {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return fallbackValue;
@@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime<
return (composeAnimationValue({
binding,
componentValues,
- }) ?? fallbackValue) as T;
+ }) ?? fallbackValue) as AnimationValueForPath<TPath>;
}
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
index 1d07e81d..453723b0 100644
--- a/apps/web/src/lib/animation/types.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,3 +1,5 @@
+import type { ParamValues } from "@/lib/params";
+
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.scaleX",
@@ -31,6 +33,34 @@ 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 AnimationPath> =
+ TPath extends AnimationPropertyPath
+ ? AnimationPropertyValueMap[TPath]
+ : TPath extends GraphicParamPath | EffectParamPath
+ ? DynamicAnimationPathValue
+ : never;
+export type AnimationNumericPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
+}[AnimationPropertyPath];
+export type AnimationColorPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
+}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
@@ -144,6 +174,36 @@ export interface ElementAnimations {
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;
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/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);
+ }
+}