Merge branch 'temp'

This commit is contained in:
Maze Winther 2026-04-06 01:23:47 +02:00
commit fcc09e80e2
54 changed files with 7069 additions and 3313 deletions

View File

@ -31,11 +31,6 @@ jobs:
MARBLE_WORKSPACE_KEY: "placeholder" MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder" FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "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: steps:
- name: Checkout repository - name: Checkout repository

View File

@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_here FREESOUND_CLIENT_ID=your_client_id_here
FREESOUND_API_KEY=your_api_key_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

View File

@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000" ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY 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_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY ENV FREESOUND_API_KEY=$FREESOUND_API_KEY

View File

@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import { import {
@ -70,41 +71,25 @@ export function StickersView() {
/> />
</div> </div>
<div className="mt-2 flex min-h-0 flex-1 flex-col"> <Tabs
<div className="border-b border-border px-2"> value={selectedCategory}
<div onValueChange={(value) => {
role="tablist" setSelectedCategory({ category: value as StickerCategory });
aria-label="Sticker categories" }}
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0" variant="underline"
> className="mt-2 flex min-h-0 flex-1 flex-col"
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => { >
const isActive = key === selectedCategory; <TabsList aria-label="Sticker categories">
return ( {Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
<button <TabsTrigger key={key} value={key}>
key={key} {label}
type="button" </TabsTrigger>
role="tab" ))}
aria-selected={isActive} </TabsList>
onClick={() =>
setSelectedCategory({
category: key as StickerCategory,
})
}
className={cn(
"text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
isActive && "text-primary border-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
<StickersContentView /> <StickersContentView />
</div> </div>
</div> </Tabs>
</div> </div>
); );
} }

View File

@ -9,8 +9,7 @@ export function useElementPlayhead({
startTime: number; startTime: number;
duration: number; duration: number;
}) { }) {
const editor = useEditor(); const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const playheadTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({ const localTime = getElementLocalTime({
timelineTime: playheadTime, timelineTime: playheadTime,
elementStartTime: startTime, elementStartTime: startTime,

View File

@ -1,148 +1,150 @@
"use client"; "use client";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import {
buildGraphicParamPath, buildGraphicParamPath,
getKeyframeAtTime, coerceAnimationValueForParam,
getParamDefaultInterpolation, getKeyframeAtTime,
getParamValueKind, getParamDefaultInterpolation,
hasKeyframesForPath, getParamValueKind,
upsertPathKeyframe, hasKeyframesForPath,
} from "@/lib/animation"; upsertPathKeyframe,
import type { } from "@/lib/animation";
ElementAnimations, import type {
} from "@/lib/animation/types"; ElementAnimations,
import type { ParamDefinition } from "@/lib/params"; } from "@/lib/animation/types";
import type { TimelineElement } from "@/lib/timeline"; import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean; export interface KeyframedParamPropertyResult {
isKeyframedAtTime: boolean; hasAnimatedKeyframes: boolean;
keyframeIdAtTime: string | null; isKeyframedAtTime: boolean;
onPreview: (value: number | string | boolean) => void; keyframeIdAtTime: string | null;
onCommit: () => void; onPreview: (value: number | string | boolean) => void;
toggleKeyframe: () => void; onCommit: () => void;
} toggleKeyframe: () => void;
}
export function useKeyframedParamProperty({
param, export function useKeyframedParamProperty({
trackId, param,
elementId, trackId,
animations, elementId,
localTime, animations,
isPlayheadWithinElementRange, localTime,
resolvedValue, isPlayheadWithinElementRange,
buildBaseUpdates, resolvedValue,
}: { buildBaseUpdates,
param: ParamDefinition; }: {
trackId: string; param: ParamDefinition;
elementId: string; trackId: string;
animations: ElementAnimations | undefined; elementId: string;
localTime: number; animations: ElementAnimations | undefined;
isPlayheadWithinElementRange: boolean; localTime: number;
resolvedValue: number | string | boolean; isPlayheadWithinElementRange: boolean;
buildBaseUpdates: ({ resolvedValue: number | string | boolean;
value, buildBaseUpdates: ({
}: { value,
value: number | string | boolean; }: {
}) => Partial<TimelineElement>; value: number | string | boolean;
}): KeyframedParamPropertyResult { }) => Partial<TimelineElement>;
const editor = useEditor(); }): KeyframedParamPropertyResult {
const propertyPath = buildGraphicParamPath({ paramKey: param.key }); const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
animations, const hasAnimatedKeyframes = hasKeyframesForPath({
propertyPath, animations,
}); propertyPath,
const keyframeAtTime = isPlayheadWithinElementRange });
? getKeyframeAtTime({ const keyframeAtTime = isPlayheadWithinElementRange
animations, ? getKeyframeAtTime({
propertyPath, animations,
time: localTime, propertyPath,
}) time: localTime,
: null; })
const keyframeIdAtTime = keyframeAtTime?.id ?? null; : null;
const isKeyframedAtTime = keyframeAtTime !== null; const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const shouldUseAnimatedChannel = const isKeyframedAtTime = keyframeAtTime !== null;
hasAnimatedKeyframes && isPlayheadWithinElementRange; const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
if (shouldUseAnimatedChannel) { const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
editor.timeline.previewElements({ if (shouldUseAnimatedChannel) {
updates: [ editor.timeline.previewElements({
{ updates: [
trackId, {
elementId, trackId,
updates: { elementId,
animations: upsertPathKeyframe({ updates: {
animations, animations: upsertPathKeyframe({
propertyPath, animations,
time: localTime, propertyPath,
value, time: localTime,
valueKind: getParamValueKind({ param }), value,
defaultInterpolation: getParamDefaultInterpolation({ kind: getParamValueKind({ param }),
param, defaultInterpolation: getParamDefaultInterpolation({
}), param,
numericRange: }),
param.type === "number" coerceValue: (nextValue) =>
? { min: param.min, max: param.max, step: param.step } coerceAnimationValueForParam({
: undefined, param,
}), value: nextValue,
}, }),
}, }),
], },
}); },
return; ],
} });
return;
editor.timeline.previewElements({ }
updates: [
{ editor.timeline.previewElements({
trackId, updates: [
elementId, {
updates: buildBaseUpdates({ value }), trackId,
}, elementId,
], updates: buildBaseUpdates({ value }),
}); },
}; ],
});
const toggleKeyframe = () => { };
if (!isPlayheadWithinElementRange) {
return; const toggleKeyframe = () => {
} if (!isPlayheadWithinElementRange) {
return;
if (keyframeIdAtTime) { }
editor.timeline.removeKeyframes({
keyframes: [ if (keyframeIdAtTime) {
{ editor.timeline.removeKeyframes({
trackId, keyframes: [
elementId, {
propertyPath, trackId,
keyframeId: keyframeIdAtTime, elementId,
}, propertyPath,
], keyframeId: keyframeIdAtTime,
}); },
return; ],
} });
return;
editor.timeline.upsertKeyframes({ }
keyframes: [
{ editor.timeline.upsertKeyframes({
trackId, keyframes: [
elementId, {
propertyPath, trackId,
time: localTime, elementId,
value: resolvedValue, propertyPath,
}, time: localTime,
], value: resolvedValue,
}); },
}; ],
});
return { };
hasAnimatedKeyframes,
isKeyframedAtTime, return {
keyframeIdAtTime, hasAnimatedKeyframes,
onPreview: previewValue, isKeyframedAtTime,
onCommit: () => editor.timeline.commitPreview(), keyframeIdAtTime,
toggleKeyframe, onPreview: previewValue,
}; onCommit: () => editor.timeline.commitPreview(),
} toggleKeyframe,
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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();
}

View File

@ -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,
);
}

View File

@ -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,
};
}

View File

@ -292,10 +292,7 @@ export function TimelineElement({
const canToggleCurrentSourceAudio = const canToggleCurrentSourceAudio =
selectedElements.length === 1 && selectedElements.length === 1 &&
isCurrentElementSelected && isCurrentElementSelected &&
canToggleSourceAudio({ canToggleSourceAudio(element, mediaAsset);
element,
mediaAsset,
});
const sourceAudioLabel = const sourceAudioLabel =
element.type === "video" element.type === "video"
? getSourceAudioActionLabel({ element }) ? getSourceAudioActionLabel({ element })

View File

@ -14,9 +14,7 @@ import {
SplitButtonSeparator, SplitButtonSeparator,
} from "@/components/ui/split-button"; } from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
TIMELINE_ZOOM_BUTTON_FACTOR,
} from "./interaction";
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view"; import { ScenesView } from "@/components/editor/scenes-view";
@ -36,7 +34,6 @@ import {
SnowIcon, SnowIcon,
ScissorIcon, ScissorIcon,
MagnetIcon, MagnetIcon,
Link04Icon,
SearchAddIcon, SearchAddIcon,
SearchMinusIcon, SearchMinusIcon,
Copy01Icon, Copy01Icon,
@ -49,6 +46,9 @@ import {
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons"; 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({ export function TimelineToolbar({
zoomLevel, zoomLevel,
@ -62,10 +62,7 @@ export function TimelineToolbar({
const handleZoom = ({ direction }: { direction: "in" | "out" }) => { const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
const newZoomLevel = const newZoomLevel =
direction === "in" direction === "in"
? Math.min( ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
TIMELINE_ZOOM_MAX,
zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR,
)
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel }); setZoomLevel({ zoom: newZoomLevel });
}; };
@ -90,8 +87,11 @@ export function TimelineToolbar({
function ToolbarLeftSection() { function ToolbarLeftSection() {
const editor = useEditor(); const editor = useEditor();
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets()); const mediaAssets = useEditor((currentEditor) =>
currentEditor.media.getAssets(),
);
const { selectedElements } = useElementSelection(); const { selectedElements } = useElementSelection();
const graphEditor = useGraphEditorController();
const isCurrentlyBookmarked = useEditor((e) => const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
); );
@ -115,10 +115,7 @@ function ToolbarLeftSection() {
})(); })();
const canToggleSelectedSourceAudio = const canToggleSelectedSourceAudio =
!!selectedElement && !!selectedElement &&
canToggleSourceAudio({ canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
element: selectedElement.element,
mediaAsset: selectedMediaAsset,
});
const sourceAudioLabel = const sourceAudioLabel =
selectedElement?.element.type === "video" selectedElement?.element.type === "video"
? getSourceAudioActionLabel({ ? getSourceAudioActionLabel({
@ -166,11 +163,11 @@ function ToolbarLeftSection() {
/> />
<ToolbarButton <ToolbarButton
icon={ icon={
<HugeiconsIcon <HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon} icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/> />
} }
tooltip={sourceAudioLabel} tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio} disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) => onClick={({ event }) =>
@ -214,13 +211,35 @@ function ToolbarLeftSection() {
/> />
</Tooltip> </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 <ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />} icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Open graph editor" tooltip={graphEditor.tooltip}
onClick={() => {}} disabled={!graphEditor.canOpen}
buttonWrapper={(button) =>
graphEditor.canOpen ? (
<PopoverTrigger asChild>{button}</PopoverTrigger>
) : (
button
)
}
/> />
</Tooltip> </GraphEditorPopover>
</TooltipProvider> </TooltipProvider>
</div> </div>
); );
@ -317,29 +336,40 @@ function ToolbarButton({
onClick, onClick,
disabled, disabled,
isActive, isActive,
buttonWrapper,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
tooltip: string; tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void; onClick?: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean; disabled?: boolean;
isActive?: boolean; isActive?: boolean;
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
}) { }) {
const button = (
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={onClick ? (event) => onClick({ event }) : undefined}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
);
const trigger = disabled ? (
<span className="inline-flex">{button}</span>
) : buttonWrapper ? (
buttonWrapper(button)
) : (
button
);
return ( return (
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>{trigger}</TooltipTrigger>
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent> <TooltipContent>{tooltip}</TooltipContent>
</Tooltip> </Tooltip>
); );

View File

@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent", "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
variant === "default" && variant === "default" &&
"data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5", "border border-transparent data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
variant === "underline" && variant === "underline" &&
"text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px", "text-muted-foreground data-[state=active]:text-primary border-x-0 border-t-0 border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
className, className,
)} )}
{...props} {...props}
@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( 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", variant === "underline" && "px-4",
className, className,
)} )}

View File

@ -16,6 +16,7 @@ import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
AnimationValue, AnimationValue,
ScalarCurveKeyframePatch,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { getLastFrameTime } from "opencut-wasm"; import { getLastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/lib/commands"; import { BatchCommand } from "@/lib/commands";
@ -35,6 +36,7 @@ import {
UpsertKeyframeCommand, UpsertKeyframeCommand,
RemoveKeyframeCommand, RemoveKeyframeCommand,
RetimeKeyframeCommand, RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand, AddClipEffectCommand,
RemoveClipEffectCommand, RemoveClipEffectCommand,
UpdateClipEffectParamsCommand, UpdateClipEffectParamsCommand,
@ -512,6 +514,45 @@ export class TimelineManager {
this.editor.command.execute({ command }); 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({ upsertEffectParamKeyframe({
trackId, trackId,
elementId, elementId,

View File

@ -292,12 +292,7 @@ export function useEditorActions() {
null null
); );
})(); })();
if ( if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
return; return;
} }

View File

@ -6,6 +6,7 @@ import {
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
} from "react"; } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection"; import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { timelineTimeToSnappedPixels } from "@/lib/timeline";
@ -84,10 +85,11 @@ export function useKeyframeDrag({
deltaTime: number; deltaTime: number;
}) => { }) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
const channel = element.animations?.channels[keyframeRef.propertyPath]; const keyframe = getKeyframeById({
const keyframe = channel?.keyframes.find( animations: element.animations,
(keyframe) => keyframe.id === keyframeRef.keyframeId, propertyPath: keyframeRef.propertyPath,
); keyframeId: keyframeRef.keyframeId,
});
if (!keyframe) return []; if (!keyframe) return [];
const nextTime = Math.max( const nextTime = Math.max(
0, 0,

File diff suppressed because it is too large Load Diff

View File

@ -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");
});
});

View File

@ -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,
});
});
});

View File

@ -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;
}

View File

@ -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<string, unknown> {
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<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
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<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
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<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
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<string, AnimationComponentValue> | 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<string, AnimationComponentValue | undefined>;
}): 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;
}

View File

@ -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;
}

View File

@ -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),
},
};
}

View File

@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
import type { import type {
ElementAnimations, ElementAnimations,
EffectParamPath, EffectParamPath,
NumberAnimationChannel,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { import { removeElementKeyframe } from "./keyframes";
getChannel, import { resolveAnimationPathValueAtTime } from "./resolve";
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
export const EFFECT_PARAM_PATH_PREFIX = "effects."; export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params."; export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(effect.params)) { for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey }); const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path }); resolved[paramKey] = animations?.bindings[path]
if (channel && channel.keyframes.length > 0) { ? resolveAnimationPathValueAtTime({
resolved[paramKey] = getChannelValueAtTime({ animations,
channel, propertyPath: path,
time: localTime, localTime,
fallbackValue: staticValue, fallbackValue: staticValue,
}) as number | string | boolean; })
} else { : staticValue;
resolved[paramKey] = staticValue;
}
} }
return resolved; 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({ export function removeEffectParamKeyframe({
animations, animations,
effectId, effectId,
@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
paramKey: string; paramKey: string;
keyframeId: string; keyframeId: string;
}): ElementAnimations | undefined { }): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey }); return removeElementKeyframe({
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
animations, animations,
propertyPath: path, propertyPath: buildEffectParamPath({ effectId, paramKey }),
channel: updatedChannel, keyframeId,
}); });
} }

View File

@ -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,
};
}

View File

@ -1,77 +1,73 @@
import type { import type {
ElementAnimations, ElementAnimations,
GraphicParamPath, GraphicParamPath,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
import { import {
getGraphicDefinition, getGraphicDefinition,
resolveGraphicParams, resolveGraphicParams,
} from "@/lib/graphics"; } from "@/lib/graphics";
import { getChannel } from "./keyframes"; import { resolveAnimationPathValueAtTime } from "./resolve";
import { getChannelValueAtTime } from "./interpolation";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export function buildGraphicParamPath({
export function buildGraphicParamPath({ paramKey,
paramKey, }: {
}: { paramKey: string;
paramKey: string; }): GraphicParamPath {
}): GraphicParamPath { return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; }
}
export function isGraphicParamPath(
export function isGraphicParamPath( propertyPath: string,
propertyPath: string, ): propertyPath is GraphicParamPath {
): propertyPath is GraphicParamPath { return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); }
}
export function parseGraphicParamPath({
export function parseGraphicParamPath({ propertyPath,
propertyPath, }: {
}: { propertyPath: string;
propertyPath: string; }): { paramKey: string } | null {
}): { paramKey: string } | null { if (!isGraphicParamPath(propertyPath)) {
if (!isGraphicParamPath(propertyPath)) { return null;
return null; }
}
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); return paramKey.length > 0 ? { paramKey } : null;
return paramKey.length > 0 ? { paramKey } : null; }
}
export function resolveGraphicParamsAtTime({
export function resolveGraphicParamsAtTime({ element,
element, localTime,
localTime, }: {
}: { element: {
element: { definitionId: string;
definitionId: string; params: ParamValues;
params: ParamValues; animations?: ElementAnimations;
animations?: ElementAnimations; };
}; localTime: number;
localTime: number; }): ParamValues {
}): ParamValues { const definition = getGraphicDefinition({
const definition = getGraphicDefinition({ definitionId: element.definitionId,
definitionId: element.definitionId, });
}); const baseParams = resolveGraphicParams(definition, element.params);
const baseParams = resolveGraphicParams(definition, element.params); const resolved: ParamValues = { ...baseParams };
const resolved: ParamValues = { ...baseParams };
for (const param of definition.params) {
for (const param of definition.params) { const path = buildGraphicParamPath({ paramKey: param.key });
const path = buildGraphicParamPath({ paramKey: param.key }); if (!element.animations?.bindings[path]) {
const channel = getChannel({ continue;
animations: element.animations, }
propertyPath: path,
}); resolved[param.key] = resolveAnimationPathValueAtTime({
if (!channel || channel.keyframes.length === 0) { animations: element.animations,
continue; propertyPath: path,
} localTime: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default,
resolved[param.key] = getChannelValueAtTime({ });
channel, }
time: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default, return resolved;
}) as number | string | boolean; }
}
return resolved;
}

View File

@ -1,71 +1,94 @@
export { export {
getChannelValueAtTime, getChannelValueAtTime,
getNumberChannelValueAtTime, getDiscreteChannelValueAtTime,
getVectorChannelValueAtTime, getScalarChannelValueAtTime,
normalizeChannel, getScalarSegmentInterpolation,
} from "./interpolation"; normalizeChannel,
} from "./interpolation";
export {
clampAnimationsToDuration, export {
cloneAnimations, clampAnimationsToDuration,
getChannel, cloneAnimations,
removeElementKeyframe, getChannel,
retimeElementKeyframe, removeElementKeyframe,
setChannel, retimeElementKeyframe,
splitAnimationsAtTime, setBindingComponentChannel,
upsertElementKeyframe, setChannel,
upsertPathKeyframe, splitAnimationsAtTime,
} from "./keyframes"; updateScalarKeyframeCurve,
upsertElementKeyframe,
export { upsertPathKeyframe,
getElementLocalTime, } from "./keyframes";
resolveColorAtTime,
resolveNumberAtTime, export {
resolveOpacityAtTime, getElementLocalTime,
resolveTransformAtTime, resolveAnimationPathValueAtTime,
} from "./resolve"; resolveColorAtTime,
resolveNumberAtTime,
export { resolveOpacityAtTime,
coerceAnimationValueForProperty, resolveTransformAtTime,
getAnimationPropertyDefinition, } from "./resolve";
getDefaultInterpolationForProperty,
getElementBaseValueForProperty, export {
isAnimationPropertyPath, coerceAnimationValueForProperty,
supportsAnimationProperty, getAnimationPropertyDefinition,
type AnimationPropertyDefinition, getDefaultInterpolationForProperty,
type NumericSpec, getElementBaseValueForProperty,
type NumericRange, isAnimationPropertyPath,
withElementBaseValueForProperty, supportsAnimationProperty,
} from "./property-registry"; type AnimationPropertyDefinition,
type NumericSpec,
export { withElementBaseValueForProperty,
getElementKeyframes, } from "./property-registry";
getKeyframeAtTime,
hasKeyframesForPath, export {
} from "./keyframe-query"; getElementKeyframes,
getKeyframeById,
export { getKeyframeAtTime,
buildGraphicParamPath, hasKeyframesForPath,
isGraphicParamPath, } from "./keyframe-query";
parseGraphicParamPath,
resolveGraphicParamsAtTime, export {
} from "./graphic-param-channel"; getEditableScalarChannel,
getEditableScalarChannels,
export { getScalarKeyframeContext,
isAnimationPath, } from "./graph-channels";
resolveAnimationTarget,
getParamValueKind, export {
getParamDefaultInterpolation, getCurveHandlesForNormalizedCubicBezier,
type AnimationPathDescriptor, getNormalizedCubicBezierForScalarSegment,
} from "./target-resolver"; } from "./curve-bridge";
export { export {
getGroupKeyframesAtTime, buildGraphicParamPath,
hasGroupKeyframeAtTime, isGraphicParamPath,
type GroupKeyframeRef, parseGraphicParamPath,
} from "./property-groups"; resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
getVectorChannelForPath, export {
isVectorValue, buildEffectParamPath,
} from "./vector-channel"; 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";

View File

@ -1,15 +1,20 @@
import type { import type {
AnimationChannel, AnimationChannel,
AnimationInterpolation,
AnimationValue, AnimationValue,
ColorAnimationChannel,
DiscreteValue,
DiscreteAnimationChannel, DiscreteAnimationChannel,
NumberAnimationChannel, DiscreteValue,
VectorAnimationChannel, ScalarAnimationChannel,
VectorValue, ScalarAnimationKey,
ScalarSegmentType,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
function byTimeAscending({ function byTimeAscending({
leftTime, leftTime,
@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
return Math.max(0, Math.min(1, value)); 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({ function lerpNumber({
leftValue, leftValue,
rightValue, rightValue,
@ -115,43 +57,99 @@ function lerpNumber({
return leftValue + (rightValue - leftValue) * progress; return leftValue + (rightValue - leftValue) * progress;
} }
function interpolateColor({ function normalizeRightHandle({
leftColor, handle,
rightColor, leftKey,
progress, rightKey,
}: { }: {
leftColor: string; handle: ScalarAnimationKey["rightHandle"];
rightColor: string; leftKey: ScalarAnimationKey;
progress: number; rightKey: ScalarAnimationKey;
}): string { }) {
const leftParsed = parseHexColor({ color: leftColor }); if (!handle) {
const rightParsed = parseHexColor({ color: rightColor }); return undefined;
if (!leftParsed || !rightParsed) {
return progress >= 1 ? rightColor : leftColor;
} }
return formatRgbaColor({ const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
red: lerpNumber({ return {
leftValue: leftParsed.red, dt: Math.min(span, Math.max(0, handle.dt)),
rightValue: rightParsed.red, dv: handle.dv,
progress, };
}), }
green: lerpNumber({
leftValue: leftParsed.green, function normalizeLeftHandle({
rightValue: rightParsed.green, handle,
progress, leftKey,
}), rightKey,
blue: lerpNumber({ }: {
leftValue: leftParsed.blue, handle: ScalarAnimationKey["leftHandle"];
rightValue: rightParsed.blue, leftKey: ScalarAnimationKey;
progress, rightKey: ScalarAnimationKey;
}), }) {
alpha: lerpNumber({ if (!handle) {
leftValue: leftParsed.alpha, return undefined;
rightValue: rightParsed.alpha, }
progress,
}), 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<TChannel extends AnimationChannel>({ export function normalizeChannel<TChannel extends AnimationChannel>({
@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
}: { }: {
channel: TChannel; channel: TChannel;
}): TChannel { }): TChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
return { return {
...channel, ...channel,
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
byTimeAscending({ byTimeAscending({
leftTime: leftKeyframe.time, leftTime: leftKeyframe.time,
rightTime: rightKeyframe.time, rightTime: rightKeyframe.time,
@ -170,171 +174,152 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel; } as TChannel;
} }
function evaluateChannelValueAtTime< function extrapolateScalarEdge({
TKeyframe extends { time: number; value: TValue }, mode,
TValue, edgeKey,
>({ neighborKey,
keyframes,
time, time,
fallbackValue,
getInterpolatedValue,
}: { }: {
keyframes: TKeyframe[] | undefined; mode: "hold" | "linear";
edgeKey: ScalarAnimationKey;
neighborKey: ScalarAnimationKey | undefined;
time: number; time: number;
fallbackValue: TValue; }) {
getInterpolatedValue: ({ if (mode === "hold" || !neighborKey) {
leftKeyframe, return edgeKey.value;
rightKeyframe,
progress,
}: {
leftKeyframe: TKeyframe;
rightKeyframe: TKeyframe;
progress: number;
}) => TValue;
}): TValue {
if (!keyframes || keyframes.length === 0) {
return fallbackValue;
} }
const firstKeyframe = keyframes[0]; const span = neighborKey.time - edgeKey.time;
const lastKeyframe = keyframes[keyframes.length - 1]; if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
if (!firstKeyframe || !lastKeyframe) { return edgeKey.value;
return fallbackValue;
} }
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
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;
} }
export function getNumberChannelValueAtTime({ export function getScalarSegmentInterpolation({
segment,
}: {
segment: ScalarSegmentType;
}): AnimationInterpolation {
if (segment === "step") {
return "hold";
}
return segment === "bezier" ? "bezier" : "linear";
}
export function getScalarChannelValueAtTime({
channel, channel,
time, time,
fallbackValue, fallbackValue,
}: { }: {
channel: NumberAnimationChannel | undefined; channel: ScalarAnimationChannel | undefined;
time: number; time: number;
fallbackValue: number; fallbackValue: number;
}): number { }): number {
return evaluateChannelValueAtTime({ if (!channel || channel.keys.length === 0) {
keyframes: channel?.keyframes, return fallbackValue;
time, }
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
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({ return lerpNumber({
leftValue: leftKeyframe.value, leftValue: leftKey.value,
rightValue: rightKeyframe.value, rightValue: rightKey.value,
progress, 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({ export function getDiscreteChannelValueAtTime({
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({
channel, channel,
time, time,
fallbackValue, fallbackValue,
@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
time: number; time: number;
fallbackValue: DiscreteValue; fallbackValue: DiscreteValue;
}): DiscreteValue { }): DiscreteValue {
return evaluateChannelValueAtTime({ if (!channel || channel.keys.length === 0) {
keyframes: channel?.keyframes, return fallbackValue;
time, }
fallbackValue,
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, 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({ export function getChannelValueAtTime({
@ -360,50 +354,25 @@ export function getChannelValueAtTime({
time: number; time: number;
fallbackValue: AnimationValue; fallbackValue: AnimationValue;
}): AnimationValue { }): AnimationValue {
if (!channel || channel.keyframes.length === 0) { if (!channel || channel.keys.length === 0) {
return fallbackValue; return fallbackValue;
} }
if (channel.valueKind === "number") { if (channel.kind === "scalar") {
if (typeof fallbackValue !== "number") { return typeof fallbackValue === "number"
return fallbackValue; ? getScalarChannelValueAtTime({
} channel,
time,
return getNumberChannelValueAtTime({ fallbackValue,
channel, })
time, : fallbackValue;
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 (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue; return fallbackValue;
} }
return getDiscreteValueAtTime({ return getDiscreteChannelValueAtTime({
channel, channel,
time, time,
fallbackValue, fallbackValue,

View File

@ -1,72 +1,298 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type { import type {
AnimationPath, AnimationBindingInstance,
ElementAnimations, AnimationChannel,
ElementKeyframe, AnimationPath,
} from "@/lib/animation/types"; ElementAnimations,
import { isAnimationPath } from "./target-resolver"; ElementKeyframe,
} from "@/lib/animation/types";
export function getElementKeyframes({ import {
animations, type AnimationComponentValue,
}: { composeAnimationValue,
animations: ElementAnimations | undefined; } from "./binding-values";
}): ElementKeyframe[] { import {
if (!animations) { getChannelValueAtTime,
return []; getScalarSegmentInterpolation,
} } from "./interpolation";
import { isAnimationPath } from "./target-resolver";
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => { function getBindingFallbackValue({
if ( channel,
!channel || }: {
channel.keyframes.length === 0 || channel: ElementAnimations["channels"][string];
!isAnimationPath(propertyPath) }) {
) { if (!channel || channel.keys.length === 0) {
return []; return channel?.kind === "discrete" ? false : 0;
} }
return channel.keyframes.map((keyframe) => ({ return channel.keys[0].value;
propertyPath, }
id: keyframe.id,
time: keyframe.time, interface BindingKeyframeMatch {
value: keyframe.value, componentIndex: number;
interpolation: keyframe.interpolation, channel: AnimationChannel;
})); keyframe: AnimationChannel["keys"][number];
}, }
);
} function getBindingKeyframeMatches({
animations,
export function hasKeyframesForPath({ binding,
animations, }: {
propertyPath, animations: ElementAnimations;
}: { binding: AnimationBindingInstance;
animations: ElementAnimations | undefined; }): BindingKeyframeMatch[] {
propertyPath: AnimationPath; return binding.components.flatMap((component, componentIndex) => {
}): boolean { const channel = animations.channels[component.channelId];
const channel = animations?.channels[propertyPath]; if (!channel || channel.keys.length === 0) {
return Boolean(channel && channel.keyframes.length > 0); return [];
} }
export function getKeyframeAtTime({ return channel.keys.map((keyframe) => ({
animations, componentIndex,
propertyPath, channel,
time, keyframe,
}: { }));
animations: ElementAnimations | undefined; });
propertyPath: AnimationPath; }
time: number;
}): ElementKeyframe | null { function getUniqueBindingKeyframeMatches({
const channel = animations?.channels[propertyPath]; animations,
if (!channel || channel.keyframes.length === 0) return null; binding,
const keyframe = channel.keyframes.find( }: {
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, animations: ElementAnimations;
); binding: AnimationBindingInstance;
if (!keyframe) return null; }): BindingKeyframeMatch[] {
return { const sortedMatches = getBindingKeyframeMatches({
propertyPath, animations,
id: keyframe.id, binding,
time: keyframe.time, }).sort(
value: keyframe.value, (leftMatch, rightMatch) =>
interpolation: keyframe.interpolation, 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<string, AnimationComponentValue | undefined>;
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,
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -1,369 +1,377 @@
import type { import type {
AnimationInterpolation, AnimationBindingKind,
AnimationPropertyPath, AnimationInterpolation,
AnimationValue, AnimationPropertyPath,
AnimationValueKind, AnimationValue,
DiscreteValue, VectorValue,
VectorValue, } from "@/lib/animation/types";
} from "@/lib/animation/types"; import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
import { isVectorValue } from "./vector-channel"; import type { TimelineElement } from "@/lib/timeline";
import type { TimelineElement } from "@/lib/timeline"; import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; import {
import { CORNER_RADIUS_MAX,
CORNER_RADIUS_MAX, CORNER_RADIUS_MIN,
CORNER_RADIUS_MIN, } from "@/constants/text-constants";
} from "@/constants/text-constants"; import {
import { canElementHaveAudio,
canElementHaveAudio, isVisualElement,
isVisualElement, } from "@/lib/timeline/element-utils";
} from "@/lib/timeline/element-utils"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { snapToStep } from "@/utils/math";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
export interface NumericSpec { min?: number;
min?: number; max?: number;
max?: number; step?: number;
step?: number; }
}
export interface AnimationPropertyDefinition {
export type NumericRange = NumericSpec; kind: AnimationBindingKind;
defaultInterpolation: AnimationInterpolation;
export interface AnimationPropertyDefinition { numericRanges?: Partial<Record<string, NumericSpec>>;
valueKind: AnimationValueKind; supportsElement: ({ element }: { element: TimelineElement }) => boolean;
defaultInterpolation: AnimationInterpolation; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
numericRange?: NumericSpec; coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
supportsElement: ({ element }: { element: TimelineElement }) => boolean; setValue: ({
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; element,
setValue: ({ value,
element, }: {
value, element: TimelineElement;
}: { value: AnimationValue;
element: TimelineElement; }) => TimelineElement;
value: AnimationValue; }
}) => TimelineElement;
} function applyNumericSpec({
value,
const ANIMATION_PROPERTY_REGISTRY: Record< numericRange,
AnimationPropertyPath, }: {
AnimationPropertyDefinition value: number;
> = { numericRange: NumericSpec | undefined;
"transform.position": { }): number {
valueKind: "vector", if (!numericRange) {
defaultInterpolation: "linear", return value;
supportsElement: ({ element }) => isVisualElement(element), }
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position : null, const steppedValue =
setValue: ({ element, value }) => numericRange.step != null
isVisualElement(element) ? snapToStep({ value, step: numericRange.step })
? { : value;
...element, const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
transform: { const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
...element.transform, return Math.min(maxValue, Math.max(minValue, steppedValue));
position: value as VectorValue, }
},
} function coerceNumberValue({
: element, value,
}, numericRange,
"transform.scaleX": { }: {
valueKind: "number", value: AnimationValue;
defaultInterpolation: "linear", numericRange?: NumericSpec;
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, }): number | null {
supportsElement: ({ element }) => isVisualElement(element), if (typeof value !== "number" || Number.isNaN(value)) {
getValue: ({ element }) => return null;
isVisualElement(element) ? element.transform.scaleX : null, }
setValue: ({ element, value }) =>
isVisualElement(element) return applyNumericSpec({ value, numericRange });
? { }
...element,
transform: { ...element.transform, scaleX: value as number }, function coerceColorValue({
} value,
: element, }: {
}, value: AnimationValue;
"transform.scaleY": { }): string | null {
valueKind: "number", return typeof value === "string" && parseColorToLinearRgba({ color: value })
defaultInterpolation: "linear", ? value
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, : null;
supportsElement: ({ element }) => isVisualElement(element), }
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleY : null, function createNumberPropertyDefinition({
setValue: ({ element, value }) => numericRange,
isVisualElement(element) supportsElement,
? { getValue,
...element, setValue,
transform: { ...element.transform, scaleY: value as number }, }: {
} numericRange?: NumericSpec;
: element, supportsElement: AnimationPropertyDefinition["supportsElement"];
}, getValue: AnimationPropertyDefinition["getValue"];
"transform.rotate": { setValue: AnimationPropertyDefinition["setValue"];
valueKind: "number", }): AnimationPropertyDefinition {
defaultInterpolation: "linear", return {
numericRange: { min: -360, max: 360, step: 1 }, kind: "number",
supportsElement: ({ element }) => isVisualElement(element), defaultInterpolation: "linear",
getValue: ({ element }) => numericRanges: numericRange ? { value: numericRange } : undefined,
isVisualElement(element) ? element.transform.rotate : null, supportsElement,
setValue: ({ element, value }) => getValue,
isVisualElement(element) coerceValue: ({ value }) =>
? { coerceNumberValue({
...element, value,
transform: { ...element.transform, rotate: value as number }, numericRange,
} }),
: element, setValue,
}, };
opacity: { }
valueKind: "number",
defaultInterpolation: "linear", const ANIMATION_PROPERTY_REGISTRY: Record<
numericRange: { min: 0, max: 1, step: 0.01 }, AnimationPropertyPath,
supportsElement: ({ element }) => isVisualElement(element), AnimationPropertyDefinition
getValue: ({ element }) => > = {
isVisualElement(element) ? element.opacity : null, "transform.position": {
setValue: ({ element, value }) => kind: "vector2",
isVisualElement(element) defaultInterpolation: "linear",
? { ...element, opacity: value as number } supportsElement: ({ element }) => isVisualElement(element),
: element, getValue: ({ element }) =>
}, isVisualElement(element) ? element.transform.position : null,
volume: { coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
valueKind: "number", setValue: ({ element, value }) =>
defaultInterpolation: "linear", isVisualElement(element)
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, ? {
supportsElement: ({ element }) => canElementHaveAudio(element), ...element,
getValue: ({ element }) => transform: {
canElementHaveAudio(element) ? element.volume ?? 0 : null, ...element.transform,
setValue: ({ element, value }) => position: value as VectorValue,
canElementHaveAudio(element) },
? { ...element, volume: value as number } }
: element, : element,
}, },
color: { "transform.scaleX": createNumberPropertyDefinition({
valueKind: "color", numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
defaultInterpolation: "linear", supportsElement: ({ element }) => isVisualElement(element),
supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) =>
getValue: ({ element }) => (element.type === "text" ? element.color : null), isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) => setValue: ({ element, value }) =>
element.type === "text" isVisualElement(element)
? { ...element, color: value as string } ? {
: element, ...element,
}, transform: { ...element.transform, scaleX: value as number },
"background.color": { }
valueKind: "color", : element,
defaultInterpolation: "linear", }),
supportsElement: ({ element }) => element.type === "text", "transform.scaleY": createNumberPropertyDefinition({
getValue: ({ element }) => numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
element.type === "text" ? element.background.color : null, supportsElement: ({ element }) => isVisualElement(element),
setValue: ({ element, value }) => getValue: ({ element }) =>
element.type === "text" isVisualElement(element) ? element.transform.scaleY : null,
? { setValue: ({ element, value }) =>
...element, isVisualElement(element)
background: { ...element.background, color: value as string }, ? {
} ...element,
: element, transform: { ...element.transform, scaleY: value as number },
}, }
"background.paddingX": { : element,
valueKind: "number", }),
defaultInterpolation: "linear", "transform.rotate": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 }, numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => element.type === "text", supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) => getValue: ({ element }) =>
element.type === "text" isVisualElement(element) ? element.transform.rotate : null,
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) setValue: ({ element, value }) =>
: null, isVisualElement(element)
setValue: ({ element, value }) => ? {
element.type === "text" ...element,
? { transform: { ...element.transform, rotate: value as number },
...element, }
background: { ...element.background, paddingX: value as number }, : element,
} }),
: element, opacity: createNumberPropertyDefinition({
}, numericRange: { min: 0, max: 1, step: 0.01 },
"background.paddingY": { supportsElement: ({ element }) => isVisualElement(element),
valueKind: "number", getValue: ({ element }) =>
defaultInterpolation: "linear", isVisualElement(element) ? element.opacity : null,
numericRange: { min: 0, step: 1 }, setValue: ({ element, value }) =>
supportsElement: ({ element }) => element.type === "text", isVisualElement(element)
getValue: ({ element }) => ? { ...element, opacity: value as number }
element.type === "text" : element,
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) }),
: null, volume: createNumberPropertyDefinition({
setValue: ({ element, value }) => numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
element.type === "text" supportsElement: ({ element }) => canElementHaveAudio(element),
? { getValue: ({ element }) =>
...element, canElementHaveAudio(element) ? element.volume ?? 0 : null,
background: { ...element.background, paddingY: value as number }, setValue: ({ element, value }) =>
} canElementHaveAudio(element)
: element, ? { ...element, volume: value as number }
}, : element,
"background.offsetX": { }),
valueKind: "number", color: {
defaultInterpolation: "linear", kind: "color",
numericRange: { step: 1 }, defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text", supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => getValue: ({ element }) => (element.type === "text" ? element.color : null),
element.type === "text" coerceValue: ({ value }) => coerceColorValue({ value }),
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) setValue: ({ element, value }) =>
: null, element.type === "text"
setValue: ({ element, value }) => ? { ...element, color: value as string }
element.type === "text" : element,
? { },
...element, "background.color": {
background: { ...element.background, offsetX: value as number }, kind: "color",
} defaultInterpolation: "linear",
: element, supportsElement: ({ element }) => element.type === "text",
}, getValue: ({ element }) =>
"background.offsetY": { element.type === "text" ? element.background.color : null,
valueKind: "number", coerceValue: ({ value }) => coerceColorValue({ value }),
defaultInterpolation: "linear", setValue: ({ element, value }) =>
numericRange: { step: 1 }, element.type === "text"
supportsElement: ({ element }) => element.type === "text", ? {
getValue: ({ element }) => ...element,
element.type === "text" background: { ...element.background, color: value as string },
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) }
: null, : element,
setValue: ({ element, value }) => },
element.type === "text" "background.paddingX": createNumberPropertyDefinition({
? { numericRange: { min: 0, step: 1 },
...element, supportsElement: ({ element }) => element.type === "text",
background: { ...element.background, offsetY: value as number }, getValue: ({ element }) =>
} element.type === "text"
: element, ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
}, : null,
"background.cornerRadius": { setValue: ({ element, value }) =>
valueKind: "number", element.type === "text"
defaultInterpolation: "linear", ? {
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, ...element,
supportsElement: ({ element }) => element.type === "text", background: { ...element.background, paddingX: value as number },
getValue: ({ element }) => }
element.type === "text" : element,
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) }),
: null, "background.paddingY": createNumberPropertyDefinition({
setValue: ({ element, value }) => numericRange: { min: 0, step: 1 },
element.type === "text" supportsElement: ({ element }) => element.type === "text",
? { getValue: ({ element }) =>
...element, element.type === "text"
background: { ...element.background, cornerRadius: value as number }, ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
} : null,
: element, setValue: ({ element, value }) =>
}, element.type === "text"
}; ? {
...element,
export function isAnimationPropertyPath( background: { ...element.background, paddingY: value as number },
propertyPath: string, }
): propertyPath is AnimationPropertyPath { : element,
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); }),
} "background.offsetX": createNumberPropertyDefinition({
numericRange: { step: 1 },
export function getAnimationPropertyDefinition({ supportsElement: ({ element }) => element.type === "text",
propertyPath, getValue: ({ element }) =>
}: { element.type === "text"
propertyPath: AnimationPropertyPath; ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
}): AnimationPropertyDefinition { : null,
return ANIMATION_PROPERTY_REGISTRY[propertyPath]; setValue: ({ element, value }) =>
} element.type === "text"
? {
export function supportsAnimationProperty({ ...element,
element, background: { ...element.background, offsetX: value as number },
propertyPath, }
}: { : element,
element: TimelineElement; }),
propertyPath: AnimationPropertyPath; "background.offsetY": createNumberPropertyDefinition({
}): boolean { numericRange: { step: 1 },
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); supportsElement: ({ element }) => element.type === "text",
return propertyDefinition.supportsElement({ element }); getValue: ({ element }) =>
} element.type === "text"
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
export function getElementBaseValueForProperty({ : null,
element, setValue: ({ element, value }) =>
propertyPath, element.type === "text"
}: { ? {
element: TimelineElement; ...element,
propertyPath: AnimationPropertyPath; background: { ...element.background, offsetY: value as number },
}): AnimationValue | null { }
const definition = getAnimationPropertyDefinition({ propertyPath }); : element,
if (!definition.supportsElement({ element })) { }),
return null; "background.cornerRadius": createNumberPropertyDefinition({
} numericRange: {
return definition.getValue({ element }); min: CORNER_RADIUS_MIN,
} max: CORNER_RADIUS_MAX,
step: 1,
export function withElementBaseValueForProperty({ },
element, supportsElement: ({ element }) => element.type === "text",
propertyPath, getValue: ({ element }) =>
value, element.type === "text"
}: { ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
element: TimelineElement; : null,
propertyPath: AnimationPropertyPath; setValue: ({ element, value }) =>
value: AnimationValue; element.type === "text"
}): TimelineElement { ? {
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); ...element,
if (coercedValue === null) { background: { ...element.background, cornerRadius: value as number },
return element; }
} : element,
const definition = getAnimationPropertyDefinition({ propertyPath }); }),
if (!definition.supportsElement({ element })) { };
return element;
} export function isAnimationPropertyPath(
return definition.setValue({ element, value: coercedValue }); propertyPath: string,
} ): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
export function getDefaultInterpolationForProperty({ }
propertyPath,
}: { export function getAnimationPropertyDefinition({
propertyPath: AnimationPropertyPath; propertyPath,
}): AnimationInterpolation { }: {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); propertyPath: AnimationPropertyPath;
return propertyDefinition.defaultInterpolation; }): AnimationPropertyDefinition {
} return ANIMATION_PROPERTY_REGISTRY[propertyPath];
}
function applyNumericSpec({
value, export function supportsAnimationProperty({
numericRange, element,
}: { propertyPath,
value: number; }: {
numericRange: NumericSpec | undefined; element: TimelineElement;
}): number { propertyPath: AnimationPropertyPath;
if (!numericRange) { }): boolean {
return value; const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
} return propertyDefinition.supportsElement({ element });
}
const steppedValue =
numericRange.step != null export function getElementBaseValueForProperty({
? snapToStep({ value, step: numericRange.step }) element,
: value; propertyPath,
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; }: {
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; element: TimelineElement;
return Math.min(maxValue, Math.max(minValue, steppedValue)); propertyPath: AnimationPropertyPath;
} }): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
export function coerceAnimationValueForProperty({ if (!definition.supportsElement({ element })) {
propertyPath, return null;
value, }
}: { return definition.getValue({ element });
propertyPath: AnimationPropertyPath; }
value: AnimationValue;
}): AnimationValue | null { export function withElementBaseValueForProperty({
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); element,
propertyPath,
if (propertyDefinition.valueKind === "number") { value,
if (typeof value !== "number" || Number.isNaN(value)) { }: {
return null; element: TimelineElement;
} propertyPath: AnimationPropertyPath;
value: AnimationValue;
return applyNumericSpec({ }): TimelineElement {
value, const definition = getAnimationPropertyDefinition({ propertyPath });
numericRange: propertyDefinition.numericRange, const coercedValue = definition.coerceValue({ value });
}); if (coercedValue === null || !definition.supportsElement({ element })) {
} return element;
}
if (propertyDefinition.valueKind === "color") { return definition.setValue({ element, value: coercedValue });
return typeof value === "string" ? value : null; }
}
export function getDefaultInterpolationForProperty({
if (propertyDefinition.valueKind === "vector") { propertyPath,
return isVectorValue(value) ? value : null; }: {
} propertyPath: AnimationPropertyPath;
}): AnimationInterpolation {
if (typeof value === "string" || typeof value === "boolean") { const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return value as DiscreteValue; return propertyDefinition.defaultInterpolation;
} }
return null; export function coerceAnimationValueForProperty({
} propertyPath,
value,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.coerceValue({ value });
}

View File

@ -1,135 +1,176 @@
import type { import type {
AnimationPropertyPath, AnimationColorPropertyPath,
ElementAnimations, AnimationNumericPropertyPath,
} from "@/lib/animation/types"; AnimationPath,
import type { Transform } from "@/lib/rendering"; AnimationPropertyPath,
import { AnimationValueForPath,
getColorValueAtTime, ElementAnimations,
getNumberChannelValueAtTime, } from "@/lib/animation/types";
getVectorChannelValueAtTime, import type { Transform } from "@/lib/rendering";
} from "./interpolation"; import {
import { getColorChannelForPath } from "./color-channel"; type AnimationComponentValue,
import { getNumberChannelForPath } from "./number-channel"; composeAnimationValue,
import { getVectorChannelForPath } from "./vector-channel"; decomposeAnimationValue,
} from "./binding-values";
export function getElementLocalTime({ import {
timelineTime, getChannelValueAtTime,
elementStartTime, } from "./interpolation";
elementDuration,
}: { export function getElementLocalTime({
timelineTime: number; timelineTime,
elementStartTime: number; elementStartTime,
elementDuration: number; elementDuration,
}): number { }: {
const localTime = timelineTime - elementStartTime; timelineTime: number;
if (localTime <= 0) { elementStartTime: number;
return 0; elementDuration: number;
} }): number {
const localTime = timelineTime - elementStartTime;
if (localTime >= elementDuration) { if (localTime <= 0) {
return elementDuration; return 0;
} }
return localTime; if (localTime >= elementDuration) {
} return elementDuration;
}
export function resolveTransformAtTime({
baseTransform, return localTime;
animations, }
localTime,
}: { export function resolveTransformAtTime({
baseTransform: Transform; baseTransform,
animations: ElementAnimations | undefined; animations,
localTime: number; localTime,
}): Transform { }: {
const safeLocalTime = Math.max(0, localTime); baseTransform: Transform;
return { animations: ElementAnimations | undefined;
position: getVectorChannelValueAtTime({ localTime: number;
channel: getVectorChannelForPath({ }): Transform {
animations, const safeLocalTime = Math.max(0, localTime);
propertyPath: "transform.position", return {
}), position: resolveAnimationPathValueAtTime({
time: safeLocalTime, animations,
fallbackValue: baseTransform.position, propertyPath: "transform.position",
}), localTime: safeLocalTime,
scaleX: getNumberChannelValueAtTime({ fallbackValue: baseTransform.position,
channel: getNumberChannelForPath({ }),
animations, scaleX: resolveAnimationPathValueAtTime({
propertyPath: "transform.scaleX", animations,
}), propertyPath: "transform.scaleX",
time: safeLocalTime, localTime: safeLocalTime,
fallbackValue: baseTransform.scaleX, fallbackValue: baseTransform.scaleX,
}), }),
scaleY: getNumberChannelValueAtTime({ scaleY: resolveAnimationPathValueAtTime({
channel: getNumberChannelForPath({ animations,
animations, propertyPath: "transform.scaleY",
propertyPath: "transform.scaleY", localTime: safeLocalTime,
}), fallbackValue: baseTransform.scaleY,
time: safeLocalTime, }),
fallbackValue: baseTransform.scaleY, rotate: resolveAnimationPathValueAtTime({
}), animations,
rotate: getNumberChannelValueAtTime({ propertyPath: "transform.rotate",
channel: getNumberChannelForPath({ localTime: safeLocalTime,
animations, fallbackValue: baseTransform.rotate,
propertyPath: "transform.rotate", }),
}), };
time: safeLocalTime, }
fallbackValue: baseTransform.rotate,
}), export function resolveOpacityAtTime({
}; baseOpacity,
} animations,
localTime,
export function resolveOpacityAtTime({ }: {
baseOpacity, baseOpacity: number;
animations, animations: ElementAnimations | undefined;
localTime, localTime: number;
}: { }): number {
baseOpacity: number; return resolveAnimationPathValueAtTime({
animations: ElementAnimations | undefined; animations,
localTime: number; propertyPath: "opacity",
}): number { localTime: Math.max(0, localTime),
return getNumberChannelValueAtTime({ fallbackValue: baseOpacity,
channel: getNumberChannelForPath({ });
animations, }
propertyPath: "opacity",
}), export function resolveNumberAtTime({
time: Math.max(0, localTime), baseValue,
fallbackValue: baseOpacity, animations,
}); propertyPath,
} localTime,
}: {
export function resolveNumberAtTime({ baseValue: number;
baseValue, animations: ElementAnimations | undefined;
animations, propertyPath: AnimationNumericPropertyPath;
propertyPath, localTime: number;
localTime, }): number {
}: { return resolveAnimationPathValueAtTime({
baseValue: number; animations,
animations: ElementAnimations | undefined; propertyPath,
propertyPath: AnimationPropertyPath; localTime: Math.max(0, localTime),
localTime: number; fallbackValue: baseValue,
}): number { });
return getNumberChannelValueAtTime({ }
channel: getNumberChannelForPath({ animations, propertyPath }),
time: Math.max(0, localTime), export function resolveColorAtTime({
fallbackValue: baseValue, baseColor,
}); animations,
} propertyPath,
localTime,
export function resolveColorAtTime({ }: {
baseColor, baseColor: string;
animations, animations: ElementAnimations | undefined;
propertyPath, propertyPath: AnimationColorPropertyPath;
localTime, localTime: number;
}: { }): string {
baseColor: string; return resolveAnimationPathValueAtTime({
animations: ElementAnimations | undefined; animations,
propertyPath: AnimationPropertyPath; propertyPath,
localTime: number; localTime: Math.max(0, localTime),
}): string { fallbackValue: baseColor,
return getColorValueAtTime({ });
channel: getColorChannelForPath({ animations, propertyPath }), }
time: Math.max(0, localTime),
fallbackValue: baseColor, export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
}); animations,
} propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: TPath;
localTime: number;
fallbackValue: AnimationValueForPath<TPath>;
}): AnimationValueForPath<TPath> {
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<string, AnimationComponentValue | undefined>;
return (composeAnimationValue({
binding,
componentValues,
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
}

View File

@ -1,277 +1,285 @@
import type { import type {
AnimationInterpolation, AnimationBindingKind,
AnimationPath, AnimationInterpolation,
AnimationValue, AnimationPath,
AnimationValueKind, AnimationValue,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { import {
parseEffectParamPath, parseEffectParamPath,
isEffectParamPath, isEffectParamPath,
} from "@/lib/animation/effect-param-channel"; } from "@/lib/animation/effect-param-channel";
import { import {
isGraphicParamPath, isGraphicParamPath,
parseGraphicParamPath, parseGraphicParamPath,
} from "@/lib/animation/graphic-param-channel"; } from "@/lib/animation/graphic-param-channel";
import type { ParamDefinition } from "@/lib/params"; import type { ParamDefinition } from "@/lib/params";
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
import { getGraphicDefinition } from "@/lib/graphics"; import { getGraphicDefinition } from "@/lib/graphics";
import type { TimelineElement } from "@/lib/timeline"; import type { TimelineElement } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils"; import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapToStep } from "@/utils/math"; import { snapToStep } from "@/utils/math";
import { import {
coerceAnimationValueForProperty, coerceAnimationValueForProperty,
getAnimationPropertyDefinition, getAnimationPropertyDefinition,
getElementBaseValueForProperty, getElementBaseValueForProperty,
isAnimationPropertyPath, isAnimationPropertyPath,
type NumericSpec, type NumericSpec,
withElementBaseValueForProperty, withElementBaseValueForProperty,
} from "./property-registry"; } from "./property-registry";
import { parseColorToLinearRgba } from "./binding-values";
export interface AnimationPathDescriptor {
valueKind: AnimationValueKind; export interface AnimationPathDescriptor {
defaultInterpolation: AnimationInterpolation; kind: AnimationBindingKind;
numericRange?: NumericSpec; defaultInterpolation: AnimationInterpolation;
getBaseValue(): AnimationValue | null; numericRanges?: Partial<Record<string, NumericSpec>>;
setBaseValue(value: AnimationValue): TimelineElement; coerceValue(value: AnimationValue): AnimationValue | null;
} getBaseValue(): AnimationValue | null;
setBaseValue(value: AnimationValue): TimelineElement;
export function getParamValueKind({ }
param,
}: { export function getParamValueKind({
param: ParamDefinition; param,
}): AnimationValueKind { }: {
if (param.type === "number") { param: ParamDefinition;
return "number"; }): AnimationBindingKind {
} if (param.type === "number") {
return "number";
if (param.type === "color") { }
return "color";
} if (param.type === "color") {
return "color";
return "discrete"; }
}
return "discrete";
export function getParamDefaultInterpolation({ }
param,
}: { export function getParamDefaultInterpolation({
param: ParamDefinition; param,
}): AnimationInterpolation { }: {
return param.type === "number" || param.type === "color" ? "linear" : "hold"; param: ParamDefinition;
} }): AnimationInterpolation {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
function getParamNumericRange({ }
param,
}: { function getParamNumericRange({
param: ParamDefinition; param,
}): NumericSpec | undefined { }: {
if (param.type !== "number") { param: ParamDefinition;
return undefined; }): Partial<Record<string, NumericSpec>> | undefined {
} if (param.type !== "number") {
return undefined;
return { }
min: param.min,
max: param.max, return {
step: param.step, value: {
}; min: param.min,
} max: param.max,
step: param.step,
function coerceParamValue({ },
param, };
value, }
}: {
param: ParamDefinition; export function coerceAnimationValueForParam({
value: AnimationValue; param,
}): number | string | boolean | null { value,
if (param.type === "number") { }: {
if (typeof value !== "number" || Number.isNaN(value)) { param: ParamDefinition;
return null; value: AnimationValue;
} }): number | string | boolean | null {
if (param.type === "number") {
const steppedValue = snapToStep({ value, step: param.step }); if (typeof value !== "number" || Number.isNaN(value)) {
const minValue = param.min; return null;
const maxValue = param.max ?? Number.POSITIVE_INFINITY; }
return Math.min(maxValue, Math.max(minValue, steppedValue));
} const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
if (param.type === "color") { const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return typeof value === "string" ? value : null; return Math.min(maxValue, Math.max(minValue, steppedValue));
} }
if (param.type === "boolean") { if (param.type === "color") {
return typeof value === "boolean" ? value : null; return typeof value === "string" ? value : null;
} }
if (typeof value !== "string") { if (param.type === "boolean") {
return null; return typeof value === "boolean" ? value : null;
} }
return param.options.some((option) => option.value === value) ? value : null; if (typeof value !== "string") {
} return null;
}
function buildGraphicParamDescriptor({
element, return param.options.some((option) => option.value === value) ? value : null;
paramKey, }
}: {
element: TimelineElement; function buildGraphicParamDescriptor({
paramKey: string; element,
}): AnimationPathDescriptor | null { paramKey,
if (element.type !== "graphic") { }: {
return null; element: TimelineElement;
} paramKey: string;
}): AnimationPathDescriptor | null {
const definition = getGraphicDefinition({ if (element.type !== "graphic") {
definitionId: element.definitionId, return null;
}); }
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) { const definition = getGraphicDefinition({
return null; definitionId: element.definitionId,
} });
const param = definition.params.find((candidate) => candidate.key === paramKey);
return { if (!param) {
valueKind: getParamValueKind({ param }), return null;
defaultInterpolation: getParamDefaultInterpolation({ param }), }
numericRange: getParamNumericRange({ param }),
getBaseValue: () => element.params[param.key] ?? param.default, return {
setBaseValue: (value) => { kind: getParamValueKind({ param }),
const coercedValue = coerceParamValue({ param, value }); defaultInterpolation: getParamDefaultInterpolation({ param }),
if (coercedValue === null) { numericRanges: getParamNumericRange({ param }),
return element; coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
} getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: (value) => {
return { const coercedValue = coerceAnimationValueForParam({ param, value });
...element, if (coercedValue === null) {
params: { return element;
...element.params, }
[param.key]: coercedValue,
}, return {
}; ...element,
}, params: {
}; ...element.params,
} [param.key]: coercedValue,
},
function buildEffectParamDescriptor({ };
element, },
effectId, };
paramKey, }
}: {
element: TimelineElement; function buildEffectParamDescriptor({
effectId: string; element,
paramKey: string; effectId,
}): AnimationPathDescriptor | null { paramKey,
if (!isVisualElement(element)) { }: {
return null; element: TimelineElement;
} effectId: string;
paramKey: string;
const effect = element.effects?.find((candidate) => candidate.id === effectId); }): AnimationPathDescriptor | null {
if (!effect) { if (!isVisualElement(element)) {
return null; return null;
} }
registerDefaultEffects(); const effect = element.effects?.find((candidate) => candidate.id === effectId);
const definition = effectsRegistry.get(effect.type); if (!effect) {
const param = definition.params.find((candidate) => candidate.key === paramKey); return null;
if (!param) { }
return null;
} registerDefaultEffects();
const definition = effectsRegistry.get(effect.type);
return { const param = definition.params.find((candidate) => candidate.key === paramKey);
valueKind: getParamValueKind({ param }), if (!param) {
defaultInterpolation: getParamDefaultInterpolation({ param }), return null;
numericRange: getParamNumericRange({ param }), }
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: (value) => { return {
const coercedValue = coerceParamValue({ param, value }); kind: getParamValueKind({ param }),
if (coercedValue === null) { defaultInterpolation: getParamDefaultInterpolation({ param }),
return element; numericRanges: getParamNumericRange({ param }),
} coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
getBaseValue: () => effect.params[param.key] ?? param.default,
return { setBaseValue: (value) => {
...element, const coercedValue = coerceAnimationValueForParam({ param, value });
effects: if (coercedValue === null) {
element.effects?.map((candidate) => return element;
candidate.id !== effectId }
? candidate
: { return {
...candidate, ...element,
params: { effects:
...candidate.params, element.effects?.map((candidate) =>
[param.key]: coercedValue, candidate.id !== effectId
}, ? candidate
}, : {
) ?? element.effects, ...candidate,
}; params: {
}, ...candidate.params,
}; [param.key]: coercedValue,
} },
},
export function isAnimationPath( ) ?? element.effects,
propertyPath: string, };
): propertyPath is AnimationPath { },
return ( };
isAnimationPropertyPath(propertyPath) || }
isGraphicParamPath(propertyPath) ||
isEffectParamPath(propertyPath) export function isAnimationPath(
); propertyPath: string,
} ): propertyPath is AnimationPath {
return (
export function resolveAnimationTarget({ isAnimationPropertyPath(propertyPath) ||
element, isGraphicParamPath(propertyPath) ||
path, isEffectParamPath(propertyPath)
}: { );
element: TimelineElement; }
path: AnimationPath;
}): AnimationPathDescriptor | null { export function resolveAnimationTarget({
if (isAnimationPropertyPath(path)) { element,
const propertyDefinition = getAnimationPropertyDefinition({ path,
propertyPath: path, }: {
}); element: TimelineElement;
if (!propertyDefinition.supportsElement({ element })) { path: AnimationPath;
return null; }): AnimationPathDescriptor | null {
} if (isAnimationPropertyPath(path)) {
const propertyDefinition = getAnimationPropertyDefinition({
return { propertyPath: path,
valueKind: propertyDefinition.valueKind, });
defaultInterpolation: propertyDefinition.defaultInterpolation, if (!propertyDefinition.supportsElement({ element })) {
numericRange: propertyDefinition.numericRange, return null;
getBaseValue: () => }
getElementBaseValueForProperty({
element, return {
propertyPath: path, kind: propertyDefinition.kind,
}), defaultInterpolation: propertyDefinition.defaultInterpolation,
setBaseValue: (value) => { numericRanges: propertyDefinition.numericRanges,
const coercedValue = coerceAnimationValueForProperty({ coerceValue: (value) =>
propertyPath: path, coerceAnimationValueForProperty({
value, propertyPath: path,
}); value,
if (coercedValue === null) { }),
return element; getBaseValue: () =>
} getElementBaseValueForProperty({
element,
return withElementBaseValueForProperty({ propertyPath: path,
element, }),
propertyPath: path, setBaseValue: (value) => {
value: coercedValue, const coercedValue = propertyDefinition.coerceValue({ value });
}); if (coercedValue === null) {
}, return element;
}; }
}
return withElementBaseValueForProperty({
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); element,
if (graphicParamTarget) { propertyPath: path,
return buildGraphicParamDescriptor({ value: coercedValue,
element, });
paramKey: graphicParamTarget.paramKey, },
}); };
} }
const effectParamTarget = parseEffectParamPath({ propertyPath: path }); const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
if (effectParamTarget) { if (graphicParamTarget) {
return buildEffectParamDescriptor({ return buildGraphicParamDescriptor({
element, element,
effectId: effectParamTarget.effectId, paramKey: graphicParamTarget.paramKey,
paramKey: effectParamTarget.paramKey, });
}); }
}
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
return null; if (effectParamTarget) {
} return buildEffectParamDescriptor({
element,
effectId: effectParamTarget.effectId,
paramKey: effectParamTarget.paramKey,
});
}
return null;
}

View File

@ -1,119 +1,220 @@
export const ANIMATION_PROPERTY_PATHS = [ import type { ParamValues } from "@/lib/params";
"transform.position",
"transform.scaleX", export const ANIMATION_PROPERTY_PATHS = [
"transform.scaleY", "transform.position",
"transform.rotate", "transform.scaleX",
"opacity", "transform.scaleY",
"volume", "transform.rotate",
"color", "opacity",
"background.color", "volume",
"background.paddingX", "color",
"background.paddingY", "background.color",
"background.offsetX", "background.paddingX",
"background.offsetY", "background.paddingY",
"background.cornerRadius", "background.offsetX",
] as const; "background.offsetY",
"background.cornerRadius",
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; ] as const;
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`; export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type AnimationPath = export type GraphicParamPath = `params.${string}`;
| AnimationPropertyPath export type EffectParamPath = `effects.${string}.params.${string}`;
| GraphicParamPath export type AnimationPath =
| EffectParamPath; | AnimationPropertyPath
| GraphicParamPath
export const ANIMATION_PROPERTY_GROUPS = { | EffectParamPath;
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>; export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; } as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type VectorValue = { x: number; y: number }; export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type AnimationValueKind = "number" | "color" | "discrete" | "vector"; export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string; export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue; export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
export type ContinuousKeyframeInterpolation = "linear" | "hold"; "transform.position": VectorValue;
export type DiscreteKeyframeInterpolation = "hold"; "transform.scaleX": number;
export type AnimationInterpolation = "transform.scaleY": number;
| ContinuousKeyframeInterpolation "transform.rotate": number;
| DiscreteKeyframeInterpolation; opacity: number;
volume: number;
interface BaseAnimationKeyframe< color: string;
TValue extends AnimationValue, "background.color": string;
TInterpolation extends AnimationInterpolation, "background.paddingX": number;
> { "background.paddingY": number;
id: string; "background.offsetX": number;
time: number; // relative to element start time "background.offsetY": number;
value: TValue; "background.cornerRadius": number;
interpolation: TInterpolation; }
} export type DynamicAnimationPathValue = ParamValues[string];
export type AnimationValueForPath<TPath extends AnimationPath> =
export interface NumberKeyframe TPath extends AnimationPropertyPath
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {} ? AnimationPropertyValueMap[TPath]
: TPath extends GraphicParamPath | EffectParamPath
export interface ColorKeyframe ? DynamicAnimationPathValue
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {} : never;
export type AnimationNumericPropertyPath = {
export interface DiscreteKeyframe [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {} }[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
export interface VectorKeyframe [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {} }[AnimationPropertyPath];
export type AnimationKeyframe = export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
| NumberKeyframe export type DiscreteKeyframeInterpolation = "hold";
| ColorKeyframe export type AnimationInterpolation =
| DiscreteKeyframe | ContinuousKeyframeInterpolation
| VectorKeyframe; | DiscreteKeyframeInterpolation;
interface BaseAnimationChannel< export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
TValueKind extends AnimationValueKind, export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
TKeyframe extends AnimationKeyframe, export type ScalarSegmentType = "step" | "linear" | "bezier";
> { export type TangentMode = "auto" | "aligned" | "broken" | "flat";
valueKind: TValueKind; export type ChannelExtrapolationMode = "hold" | "linear";
keyframes: TKeyframe[];
} export interface CurveHandle {
dt: number;
export interface NumberAnimationChannel dv: number;
extends BaseAnimationChannel<"number", NumberKeyframe> {} }
export interface ColorAnimationChannel interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
extends BaseAnimationChannel<"color", ColorKeyframe> {} id: string;
time: number; // relative to element start time
export interface DiscreteAnimationChannel value: TValue;
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} }
export interface VectorAnimationChannel export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
extends BaseAnimationChannel<"vector", VectorKeyframe> {} leftHandle?: CurveHandle;
rightHandle?: CurveHandle;
export type AnimationChannel = segmentToNext: ScalarSegmentType;
| NumberAnimationChannel tangentMode: TangentMode;
| ColorAnimationChannel }
| DiscreteAnimationChannel
| VectorAnimationChannel; export interface DiscreteAnimationKey
extends BaseAnimationKeyframe<DiscreteValue> {}
export type ElementAnimationChannelMap = Record<
string, export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
AnimationChannel | undefined
>; export interface ScalarAnimationChannel {
kind: "scalar";
export interface ElementAnimations { keys: ScalarAnimationKey[];
channels: ElementAnimationChannelMap; extrapolation?: {
} before: ChannelExtrapolationMode;
after: ChannelExtrapolationMode;
export interface ElementKeyframe { };
propertyPath: AnimationPath; }
id: string;
time: number; export interface DiscreteAnimationChannel {
value: AnimationValue; kind: "discrete";
interpolation: AnimationInterpolation; keys: DiscreteAnimationKey[];
} }
export interface SelectedKeyframeRef { export type AnimationChannel =
trackId: string; | ScalarAnimationChannel
elementId: string; | DiscreteAnimationChannel;
propertyPath: AnimationPath;
keyframeId: string; export type ElementAnimationChannelMap = Record<
} string,
AnimationChannel | undefined
>;
export interface AnimationBindingComponent<TKey extends string = string> {
key: TKey;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
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<TKind extends AnimationBindingKind> =
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;
}

View File

@ -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;
}

View File

@ -1,5 +1,6 @@
export * from "./remove-effect-param-keyframe"; export * from "./remove-effect-param-keyframe";
export * from "./remove-keyframe"; export * from "./remove-keyframe";
export * from "./retime-keyframe"; export * from "./retime-keyframe";
export * from "./upsert-effect-param-keyframe"; export * from "./update-scalar-keyframe-curve";
export * from "./upsert-keyframe"; export * from "./upsert-effect-param-keyframe";
export * from "./upsert-keyframe";

View File

@ -1,8 +1,9 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { import {
getChannel, hasKeyframesForPath,
getChannelValueAtTime, getKeyframeById,
removeElementKeyframe, removeElementKeyframe,
resolveAnimationPathValueAtTime,
resolveAnimationTarget, resolveAnimationTarget,
} from "@/lib/animation"; } from "@/lib/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
}): AnimationValue | null { }): 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 }); const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) { if (!target) {
return null; return null;
@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({
return null; return null;
} }
return getChannelValueAtTime({ const keyframe = getKeyframeById({
channel, animations: element.animations,
time: keyframe.time, propertyPath,
keyframeId,
});
if (!keyframe) {
return null;
}
return resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath,
localTime: keyframe.time,
fallbackValue: baseValue, fallbackValue: baseValue,
}); });
} }
@ -72,8 +72,10 @@ function removeKeyframeAndPersist({
keyframeId, keyframeId,
}); });
const isChannelNowEmpty = const isChannelNowEmpty = !hasKeyframesForPath({
getChannel({ animations: nextAnimations, propertyPath }) === undefined; animations: nextAnimations,
propertyPath,
});
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase const baseElement = shouldPersistToBase

View File

@ -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);
}
}

View File

@ -1,8 +1,13 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils"; import { isVisualElement } from "@/lib/timeline/element-utils";
import type { AnimationInterpolation } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command { export class UpsertEffectParamKeyframeCommand extends Command {
@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly effectId: string; private readonly effectId: string;
private readonly paramKey: string; private readonly paramKey: string;
private readonly time: number; private readonly time: number;
private readonly value: number; private readonly value: number | string | boolean;
private readonly interpolation: "linear" | "hold" | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
constructor({ constructor({
@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
effectId: string; effectId: string;
paramKey: string; paramKey: string;
time: number; time: number;
value: number; value: number | string | boolean;
interpolation?: "linear" | "hold"; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}) { }) {
super(); super();
@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementPredicate: isVisualElement, elementPredicate: isVisualElement,
update: (element) => { update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration)); const boundedTime = Math.max(0, Math.min(this.time, element.duration));
const animations = upsertEffectParamKeyframe({ const propertyPath = buildEffectParamPath({
animations: element.animations,
effectId: this.effectId, effectId: this.effectId,
paramKey: this.paramKey, paramKey: this.paramKey,
});
const target = resolveAnimationTarget({
element,
path: propertyPath,
});
if (!target) {
return element;
}
const animations = upsertPathKeyframe({
animations: element.animations,
propertyPath,
time: boundedTime, time: boundedTime,
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
kind: target.kind,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue,
}); });
return { ...element, animations }; return { ...element, animations };
}, },

View File

@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command {
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
valueKind: target.valueKind, kind: target.kind,
defaultInterpolation: target.defaultInterpolation, defaultInterpolation: target.defaultInterpolation,
numericRange: target.numericRange, coerceValue: target.coerceValue,
}), }),
}; };
}, },

View File

@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
return; return;
} }
if (canRecoverSourceAudio({ element: sourceElement })) { if (canRecoverSourceAudio(sourceElement)) {
editor.timeline.updateTracks( editor.timeline.updateTracks(
updateSourceAudioEnabled({ updateSourceAudioEnabled({
tracks: this.savedState, tracks: this.savedState,
@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
.media .media
.getAssets() .getAssets()
.find((asset) => asset.id === sourceElement.mediaId); .find((asset) => asset.id === sourceElement.mediaId);
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { if (!canExtractSourceAudio(sourceElement, mediaAsset)) {
return; return;
} }
if (sourceElement.duration <= 0) { if (sourceElement.duration <= 0) {

View File

@ -23,11 +23,6 @@ const webEnvSchema = z.object({
MARBLE_WORKSPACE_KEY: z.string(), MARBLE_WORKSPACE_KEY: z.string(),
FREESOUND_CLIENT_ID: z.string(), FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: 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<typeof webEnvSchema>; export type WebEnv = z.infer<typeof webEnvSchema>;

View File

@ -1,235 +1,237 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
import { getMaskSnapGeometry } from "@/lib/masks/geometry"; import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap"; import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds"; import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
const bounds: ElementBounds = { const bounds: ElementBounds = {
cx: 200, cx: 200,
cy: 150, cy: 150,
width: 200, width: 200,
height: 100, height: 100,
rotation: 0, rotation: 0,
}; };
const canvasSize = { const canvasSize = {
width: 400, width: 400,
height: 300, height: 300,
}; };
const snapThreshold = { const snapThreshold = {
x: 8, x: 8,
y: 8, y: 8,
}; };
function buildSplitParams( function buildSplitParams(
overrides: Partial<SplitMaskParams> = {}, overrides: Partial<SplitMaskParams> = {},
): SplitMaskParams { ): SplitMaskParams {
return { return {
feather: 0, feather: 0,
inverted: false, inverted: false,
strokeColor: "#ffffff", strokeColor: "#ffffff",
strokeWidth: 0, strokeWidth: 0,
centerX: 0, strokeAlign: "center",
centerY: 0, centerX: 0,
rotation: 0, centerY: 0,
...overrides, rotation: 0,
}; ...overrides,
} };
}
function buildRectangleParams(
overrides: Partial<RectangleMaskParams> = {}, function buildRectangleParams(
): RectangleMaskParams { overrides: Partial<RectangleMaskParams> = {},
return { ): RectangleMaskParams {
feather: 0, return {
inverted: false, feather: 0,
strokeColor: "#ffffff", inverted: false,
strokeWidth: 0, strokeColor: "#ffffff",
centerX: 0, strokeWidth: 0,
centerY: 0, strokeAlign: "center",
width: 0.4, centerX: 0,
height: 0.2, centerY: 0,
rotation: 0, width: 0.4,
scale: 1, height: 0.2,
...overrides, rotation: 0,
}; scale: 1,
} ...overrides,
};
function sortSegment( }
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] { function sortSegment(
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ segment: [{ x: number; y: number }, { x: number; y: number }],
{ 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( describe("mask geometry", () => {
getMaskSnapGeometry({ test("resolves split mask center from centerX and centerY", () => {
params: buildSplitParams({ expect(
centerX: 0.25, getMaskSnapGeometry({
centerY: -0.5, params: buildSplitParams({
rotation: 45, centerX: 0.25,
}), centerY: -0.5,
bounds, rotation: 45,
}), }),
).toEqual({ bounds,
position: { x: 50, y: -50 }, }),
size: { width: 0, height: 0 }, ).toEqual({
rotation: 45, 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({ test("resolves box mask center and size from centerX and centerY", () => {
params: buildRectangleParams({ expect(
centerX: -0.25, getMaskSnapGeometry({
centerY: 0.5, params: buildRectangleParams({
width: 0.5, centerX: -0.25,
height: 0.6, centerY: 0.5,
rotation: 30, width: 0.5,
}), height: 0.6,
bounds, rotation: 30,
}), }),
).toEqual({ bounds,
position: { x: -50, y: 50 }, }),
size: { width: 100, height: 60 }, ).toEqual({
rotation: 30, 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(), test("returns a vertical split stroke segment for rotation 0", () => {
width: bounds.width, const segment = getSplitMaskStrokeSegment({
height: bounds.height, 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(segment).not.toBeNull();
} if (!segment) {
expect(sortSegment(segment)).toEqual([ throw new Error("Expected split stroke segment for rotation 0");
{ x: bounds.width / 2, y: 0 }, }
{ x: bounds.width / 2, y: bounds.height }, 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 }), test("returns a horizontal split stroke segment for rotation 90", () => {
width: bounds.width, const segment = getSplitMaskStrokeSegment({
height: bounds.height, 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(segment).not.toBeNull();
} if (!segment) {
expect(sortSegment(segment)).toEqual([ throw new Error("Expected split stroke segment for rotation 90");
{ x: 0, y: bounds.height / 2 }, }
{ x: bounds.width, y: bounds.height / 2 }, 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({ describe("mask snapping", () => {
handleId: "position", test("snaps split mask movement using the shared position pipeline", () => {
startParams: buildSplitParams({ const result = snapMaskInteraction({
centerX: 0.03, handleId: "position",
centerY: -0.04, startParams: buildSplitParams({
}), centerX: 0.03,
proposedParams: buildSplitParams({ centerY: -0.04,
centerX: 0.03, }),
centerY: -0.04, proposedParams: buildSplitParams({
}), centerX: 0.03,
bounds, centerY: -0.04,
canvasSize, }),
snapThreshold, bounds,
}); canvasSize,
snapThreshold,
expect(result.params.centerX).toBe(0); });
expect(result.params.centerY).toBe(0);
expect(result.activeLines).toEqual([ expect(result.params.centerX).toBe(0);
{ type: "vertical", position: 0 }, expect(result.params.centerY).toBe(0);
{ type: "horizontal", position: 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", test("snaps box mask movement against element center and edges", () => {
startParams: buildRectangleParams(), const result = snapMaskInteraction({
proposedParams: buildRectangleParams({ handleId: "position",
centerX: 0.29, startParams: buildRectangleParams(),
centerY: 0.03, proposedParams: buildRectangleParams({
}), centerX: 0.29,
bounds, centerY: 0.03,
canvasSize, }),
snapThreshold, bounds,
}); canvasSize,
snapThreshold,
expect(result.params.centerX).toBeCloseTo(0.3); });
expect(result.params.centerY).toBe(0);
expect(result.activeLines).toEqual([ expect(result.params.centerX).toBeCloseTo(0.3);
{ type: "vertical", position: 100 }, expect(result.params.centerY).toBe(0);
{ type: "horizontal", position: 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", test("snaps mask rotation through the shared rotation path", () => {
startParams: buildRectangleParams(), const result = snapMaskInteraction({
proposedParams: buildRectangleParams({ handleId: "rotation",
rotation: 88, startParams: buildRectangleParams(),
}), proposedParams: buildRectangleParams({
bounds, rotation: 88,
canvasSize, }),
snapThreshold, bounds,
}); canvasSize,
snapThreshold,
expect(result.params.rotation).toBe(90); });
expect(result.activeLines).toEqual([]);
}); expect(result.params.rotation).toBe(90);
expect(result.activeLines).toEqual([]);
test("snaps edge resize for box masks", () => { });
const result = snapMaskInteraction({
handleId: "right", test("snaps edge resize for box masks", () => {
startParams: buildRectangleParams(), const result = snapMaskInteraction({
proposedParams: buildRectangleParams({ handleId: "right",
width: 0.98, startParams: buildRectangleParams(),
}), proposedParams: buildRectangleParams({
bounds, width: 0.98,
canvasSize, }),
snapThreshold, bounds,
}); canvasSize,
snapThreshold,
expect(result.params.width).toBe(1); });
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
}); 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", test("snaps corner resize for box masks", () => {
startParams: buildRectangleParams(), const result = snapMaskInteraction({
proposedParams: buildRectangleParams({ handleId: "bottom-right",
width: 0.99, startParams: buildRectangleParams(),
height: 0.495, proposedParams: buildRectangleParams({
}), width: 0.99,
bounds, height: 0.495,
canvasSize, }),
snapThreshold, bounds,
}); canvasSize,
snapThreshold,
expect(result.params.width).toBe(1); });
expect(result.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); expect(result.params.width).toBe(1);
}); expect(result.params.height).toBe(0.5);
}); expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
});

View File

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { upsertElementKeyframe } from "@/lib/animation";
import type { UploadAudioElement, VideoElement } from "@/lib/timeline"; import type { UploadAudioElement, VideoElement } from "@/lib/timeline";
import { import {
buildSeparatedAudioElement, buildSeparatedAudioElement,
@ -25,32 +26,7 @@ describe("audio separation", () => {
volume: -6, volume: -6,
muted: true, muted: true,
retime: { rate: 1.25, maintainPitch: true }, retime: { rate: 1.25, maintainPitch: true },
animations: { animations: buildAnimations(),
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",
},
],
},
},
},
}); });
const separatedAudioElement = buildSeparatedAudioElement({ const separatedAudioElement = buildSeparatedAudioElement({
@ -71,11 +47,14 @@ describe("audio separation", () => {
muted: true, muted: true,
retime: { rate: 1.25, maintainPitch: true }, retime: { rate: 1.25, maintainPitch: true },
}); });
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([
"volume", "volume",
]); ]);
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
"volume:value",
]);
expect( expect(
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id, separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id,
).not.toBe("volume-keyframe"); ).not.toBe("volume-keyframe");
}); });
@ -143,3 +122,23 @@ function buildAudioElement(
...overrides, ...overrides,
} satisfies UploadAudioElement; } 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",
});
}

View File

@ -1,4 +1,4 @@
import { cloneAnimations, getChannel } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
import type { ElementAnimations } from "@/lib/animation/types"; import type { ElementAnimations } from "@/lib/animation/types";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
@ -42,11 +42,9 @@ export function canExtractSourceAudio({
); );
} }
export function canRecoverSourceAudio({ export function canRecoverSourceAudio(
element, element: TimelineElement,
}: { ): element is VideoElement {
element: TimelineElement;
}): element is VideoElement {
return element.type === "video" && isSourceAudioSeparated({ element }); return element.type === "video" && isSourceAudioSeparated({ element });
} }
@ -119,16 +117,27 @@ function cloneVolumeAnimations({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
}): ElementAnimations | undefined { }): ElementAnimations | undefined {
const volumeChannel = getChannel({ animations, propertyPath: "volume" }); const volumeBinding = animations?.bindings.volume;
if (!volumeChannel) { 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 undefined;
} }
return cloneAnimations({ return cloneAnimations({
animations: { animations: {
channels: { bindings: {
volume: volumeChannel, volume: volumeBinding,
}, },
channels: subsetChannels,
}, },
shouldRegenerateKeyframeIds: true, shouldRegenerateKeyframeIds: true,
}); });

View File

@ -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<Record<string, unknown>>;
const tracks = scenes[0].tracks as Array<Record<string, unknown>>;
const elements = tracks[0].elements as Array<Record<string, unknown>>;
const animations = elements[0].animations as Record<string, unknown>;
const bindings = animations.bindings as Record<string, Record<string, unknown>>;
const channels = animations.channels as Record<string, Record<string, unknown>>;
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");
});
});

View File

@ -1,92 +1,94 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
import { v5Project } from "./fixtures"; import { v5Project } from "./fixtures";
describe("V5 to V6 Migration", () => { describe("V5 to V6 Migration", () => {
test("converts number bookmarks to Bookmark objects", async () => { test("converts number bookmarks to Bookmark objects", async () => {
const result = transformProjectV5ToV6({ const result = transformProjectV5ToV6({
project: v5Project as Parameters< project: v5Project as Parameters<
typeof transformProjectV5ToV6 typeof transformProjectV5ToV6
>[0]["project"], >[0]["project"],
}); });
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
expect(result.project.version).toBe(6); expect(result.project.version).toBe(6);
const mainScene = ( const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }> result.project.scenes as Array<{ bookmarks: unknown[] }>
)[0]; )[0];
expect(mainScene.bookmarks).toEqual([ expect(mainScene.bookmarks).toEqual([
{ time: 2.0 }, { time: 2.0 },
{ time: 5.5 }, { time: 5.5 },
{ time: 12.0 }, { time: 12.0 },
]); ]);
const introScene = ( const introScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }> result.project.scenes as Array<{ bookmarks: unknown[] }>
)[1]; )[1];
expect(introScene.bookmarks).toEqual([]); expect(introScene.bookmarks).toEqual([]);
}); });
test("skips projects that are already v6", () => { test("skips projects that are already v6", () => {
const result = transformProjectV5ToV6({ const firstScene = (v5Project as { scenes: Array<Record<string, unknown>> })
project: { .scenes[0];
...v5Project, const result = transformProjectV5ToV6({
version: 6, project: {
scenes: [ ...v5Project,
{ version: 6,
...(v5Project as { scenes: unknown[] }).scenes[0], scenes: [
bookmarks: [{ time: 2 }, { time: 5 }], {
}, ...firstScene,
], bookmarks: [{ time: 2 }, { time: 5 }],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"], },
}); ],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
expect(result.skipped).toBe(true); });
expect(result.reason).toBe("already v6");
}); expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v6");
test("skips projects with no id", () => { });
const result = transformProjectV5ToV6({
project: { test("skips projects with no id", () => {
version: 5, const result = transformProjectV5ToV6({
scenes: [], project: {
} as Parameters<typeof transformProjectV5ToV6>[0]["project"], version: 5,
}); scenes: [],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
expect(result.skipped).toBe(true); });
expect(result.reason).toBe("no project id");
}); expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
test("preserves existing Bookmark objects with note, color, duration", () => { });
const projectWithRichBookmarks = {
...v5Project, test("preserves existing Bookmark objects with note, color, duration", () => {
version: 5, const projectWithRichBookmarks = {
scenes: [ ...v5Project,
{ version: 5,
...(v5Project as { scenes: Array<Record<string, unknown>> }) scenes: [
.scenes[0], {
bookmarks: [ ...(v5Project as { scenes: Array<Record<string, unknown>> })
{ time: 1, note: "Intro", color: "#ef4444" }, .scenes[0],
{ time: 5.5, duration: 2 }, bookmarks: [
], { time: 1, note: "Intro", color: "#ef4444" },
}, { time: 5.5, duration: 2 },
], ],
}; },
],
const result = transformProjectV5ToV6({ };
project: projectWithRichBookmarks as Parameters<
typeof transformProjectV5ToV6 const result = transformProjectV5ToV6({
>[0]["project"], project: projectWithRichBookmarks as Parameters<
}); typeof transformProjectV5ToV6
>[0]["project"],
expect(result.skipped).toBe(false); });
const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }> expect(result.skipped).toBe(false);
)[0]; const mainScene = (
expect(mainScene.bookmarks).toEqual([ result.project.scenes as Array<{ bookmarks: unknown[] }>
{ time: 1, note: "Intro", color: "#ef4444" }, )[0];
{ time: 5.5, duration: 2 }, expect(mainScene.bookmarks).toEqual([
]); { time: 1, note: "Intro", color: "#ef4444" },
}); { time: 5.5, duration: 2 },
}); ]);
});
});

View File

@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19"; import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20"; import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21"; import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 21; export const CURRENT_PROJECT_VERSION = 22;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -47,4 +48,5 @@ export const migrations = [
new V18toV19Migration(), new V18toV19Migration(),
new V19toV20Migration(), new V19toV20Migration(),
new V20toV21Migration(), new V20toV21Migration(),
new V21toV22Migration(),
]; ];

View File

@ -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<string, ProjectRecord>;
}
export function transformProjectV21ToV22({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
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<string, ProjectRecord> = {};
const nextChannels: Record<string, ProjectRecord> = {};
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<TValue>({
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}`;
}

View File

@ -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 });
}
}

View File

@ -70,12 +70,6 @@ services:
- MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder}
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY} - 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@ -17,12 +17,7 @@
"UPSTASH_REDIS_REST_TOKEN", "UPSTASH_REDIS_REST_TOKEN",
"MARBLE_WORKSPACE_KEY", "MARBLE_WORKSPACE_KEY",
"FREESOUND_CLIENT_ID", "FREESOUND_CLIENT_ID",
"FREESOUND_API_KEY", "FREESOUND_API_KEY"
"CLOUDFLARE_ACCOUNT_ID",
"R2_ACCESS_KEY_ID",
"R2_SECRET_ACCESS_KEY",
"R2_BUCKET_NAME",
"MODAL_TRANSCRIPTION_URL"
] ]
}, },
"check-types": { "check-types": {