Merge branch 'temp'
This commit is contained in:
commit
fcc09e80e2
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -292,12 +292,7 @@ export function useEditorActions() {
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
if (
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
!canToggleSourceAudio({
|
|
||||||
element: selectedElement.element,
|
|
||||||
mediaAsset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 },
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue