feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules.
This commit is contained in:
parent
9b94f89def
commit
49426f19cd
|
|
@ -37,6 +37,7 @@ Review every point below carefully to ensure files follow consistent code style
|
|||
- [ ] Each file has one single purpose/responsibility
|
||||
- Example: `timeline/index.tsx` should not define `validateElementTrackCompatibility` — that belongs in a lib file
|
||||
- Example: `lib/timeline-utils.ts` should not declare `TRACK_COLORS` — that belongs in `constants/`
|
||||
- [ ] File name accurately reflects what the file contains — a misleading name is a bug waiting to happen
|
||||
- [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder
|
||||
|
||||
## Comments
|
||||
|
|
@ -134,7 +135,7 @@ Do NOT review by reading the file top-to-bottom and noting what jumps out. Inste
|
|||
4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
|
||||
|
||||
Before outputting the table, list each checklist section and confirm you checked it:
|
||||
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
|
||||
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | File Names ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
### Accessibility (a11y)
|
||||
|
||||
- Always include a `title` element for icons unless there's text beside the icon.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
|
||||
import type { ElementUpdatePatch } from "@/types/timeline";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue,
|
||||
parse,
|
||||
valueAtPlayhead,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue = ({ value }: { value: number }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const propertyDraft = usePropertyDraft({
|
||||
displayValue,
|
||||
parse,
|
||||
onPreview: (value) => previewValue({ value }),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: valueAtPlayhead,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitValue = ({ value }: { value: number }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...propertyDraft,
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
toggleKeyframe,
|
||||
commitValue,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { KeyframeIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function KeyframeToggle({
|
||||
isActive,
|
||||
isDisabled = false,
|
||||
title,
|
||||
onToggle,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
title: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-pressed={isActive}
|
||||
disabled={isDisabled}
|
||||
title={title}
|
||||
onClick={onToggle}
|
||||
className="[&>svg]:size-3.5 mb-0.5"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(isActive && "text-primary fill-primary")}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -139,21 +139,28 @@ export function SectionFields({
|
|||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionField({
|
||||
label,
|
||||
beforeLabel,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
beforeLabel?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{beforeLabel}
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import type { ElementType, Transform } from "@/types/timeline";
|
||||
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { VisualElement } from "@/types/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
} from "../section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
|
|
@ -13,71 +19,125 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
import { KeyframeToggle } from "../keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
|
||||
type TransformElement = {
|
||||
id: string;
|
||||
transform: Transform;
|
||||
type: ElementType;
|
||||
};
|
||||
|
||||
function parseFloat_({ input }: { input: string }): number | null {
|
||||
function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function isPropertyAtDefault({
|
||||
hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
staticValue,
|
||||
defaultValue,
|
||||
}: {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number;
|
||||
staticValue: number;
|
||||
defaultValue: number;
|
||||
}): boolean {
|
||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
||||
return isNearlyEqual({
|
||||
leftValue: resolvedValue,
|
||||
rightValue: defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
return staticValue === defaultValue;
|
||||
}
|
||||
|
||||
export function TransformSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TransformElement;
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
|
||||
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
|
||||
|
||||
const previewTransform = (transform: Partial<Transform>) => {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { transform: { ...element.transform, ...transform } },
|
||||
const positionX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.position.x",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.x,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
x: value,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commit = () => editor.timeline.commitPreview();
|
||||
|
||||
const positionX = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.x).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, x: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const positionY = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.y).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, y: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
const positionY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.position.y",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.y,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
y: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const scale = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.scale * 100).toString(),
|
||||
const scale = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scale",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return Math.max(parsed, 1) / 100;
|
||||
},
|
||||
onPreview: (value) => previewTransform({ scale: value }),
|
||||
onCommit: commit,
|
||||
valueAtPlayhead: resolvedTransform.scale,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scale: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const scaleFieldProps = {
|
||||
className: "flex-1",
|
||||
|
|
@ -88,66 +148,148 @@ export function TransformSection({
|
|||
dragSensitivity: "slow" as const,
|
||||
onScrub: scale.scrubTo,
|
||||
onScrubEnd: scale.commitScrub,
|
||||
onReset: () =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
scale: DEFAULT_TRANSFORM.scale,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
|
||||
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scale,
|
||||
staticValue: element.transform.scale,
|
||||
defaultValue: DEFAULT_TRANSFORM.scale,
|
||||
}),
|
||||
};
|
||||
|
||||
const rotation = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.rotate).toString(),
|
||||
const rotation = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
onPreview: (value) => previewTransform({ rotate: value }),
|
||||
onCommit: commit,
|
||||
valueAtPlayhead: resolvedTransform.rotate,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const hasPositionKeyframe =
|
||||
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
|
||||
|
||||
const togglePositionKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
|
||||
const keyframesToRemove: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}> = [];
|
||||
if (positionX.keyframeIdAtTime) {
|
||||
keyframesToRemove.push({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.x" as const,
|
||||
keyframeId: positionX.keyframeIdAtTime,
|
||||
});
|
||||
}
|
||||
if (positionY.keyframeIdAtTime) {
|
||||
keyframesToRemove.push({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.y" as const,
|
||||
keyframeId: positionY.keyframeIdAtTime,
|
||||
});
|
||||
}
|
||||
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: keyframesToRemove,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.x",
|
||||
time: localTime,
|
||||
value: resolvedTransform.position.x,
|
||||
},
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.y",
|
||||
time: localTime,
|
||||
value: resolvedTransform.position.y,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:transform`}>
|
||||
<SectionHeader title="Transform" />
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField label="Scale">
|
||||
<div className="flex items-center gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<NumberField icon="W" {...scaleFieldProps} />
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Scale"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scale.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle scale keyframe"
|
||||
onToggle={scale.toggleKeyframe}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
</SectionField>
|
||||
<SectionField label="Position">
|
||||
<div className="flex items-center gap-2">
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<NumberField icon="W" {...scaleFieldProps} />
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Position"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={hasPositionKeyframe}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle position keyframe"
|
||||
onToggle={togglePositionKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon="X"
|
||||
className="flex-1"
|
||||
|
|
@ -158,27 +300,15 @@ export function TransformSection({
|
|||
onScrub={positionX.scrubTo}
|
||||
onScrubEnd={positionX.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
x: DEFAULT_TRANSFORM.position.x,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.x === DEFAULT_TRANSFORM.position.x
|
||||
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.position.x,
|
||||
staticValue: element.transform.position.x,
|
||||
defaultValue: DEFAULT_TRANSFORM.position.x,
|
||||
})}
|
||||
/>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
|
|
@ -190,63 +320,56 @@ export function TransformSection({
|
|||
onScrub={positionY.scrubTo}
|
||||
onScrubEnd={positionY.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
y: DEFAULT_TRANSFORM.position.y,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.y === DEFAULT_TRANSFORM.position.y
|
||||
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.position.y,
|
||||
staticValue: element.transform.position.y,
|
||||
defaultValue: DEFAULT_TRANSFORM.position.y,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionField>
|
||||
|
||||
<SectionField label="Rotation">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-none"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: DEFAULT_TRANSFORM.rotate,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
<SectionField
|
||||
label="Rotation"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={rotation.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle rotation keyframe"
|
||||
onToggle={rotation.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-none"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.rotate,
|
||||
staticValue: element.transform.rotate,
|
||||
defaultValue: DEFAULT_TRANSFORM.rotate,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import type { EditorCore } from "@/core";
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import {
|
||||
DEFAULT_BOOKMARK_COLOR,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import {
|
||||
|
|
@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
|
||||
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
|
||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||
const BOOKMARK_MARKER_HEIGHT_PX = 15;
|
||||
|
|
@ -139,10 +136,12 @@ function TimelineBookmark({
|
|||
const time = bookmark.time;
|
||||
const bookmarkDuration = bookmark.duration ?? 0;
|
||||
const durationWidth =
|
||||
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
|
||||
bookmarkDuration > 0
|
||||
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
|
||||
: 0;
|
||||
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
|
||||
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
|
||||
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
|
||||
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
|
||||
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
} from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
|
|
@ -40,10 +44,10 @@ export function SnapIndicator({
|
|||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
|
||||
top: topPosition,
|
||||
height: `${height}px`,
|
||||
width: "2px",
|
||||
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
|
||||
}}
|
||||
>
|
||||
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ import { useEditor } from "@/hooks/use-editor";
|
|||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import {
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasMediaId,
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -19,7 +22,7 @@ import {
|
|||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../../ui/context-menu";
|
||||
} from "@/components/ui/context-menu";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
|
|
@ -42,10 +45,123 @@ import {
|
|||
VolumeMute02Icon,
|
||||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
KeyframeIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||
|
||||
interface KeyframeIndicator {
|
||||
time: number;
|
||||
offsetPx: number;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function buildKeyframeIndicator({
|
||||
keyframe,
|
||||
trackId,
|
||||
elementId,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
isKeyframeSelected,
|
||||
}: {
|
||||
keyframe: ElementKeyframe;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
isKeyframeSelected: ({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}) => boolean;
|
||||
}): {
|
||||
time: number;
|
||||
offsetPx: number;
|
||||
keyframeRef: SelectedKeyframeRef;
|
||||
isSelected: boolean;
|
||||
} {
|
||||
const keyframeRef = {
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
keyframeId: keyframe.id,
|
||||
};
|
||||
const keyframeLeft = timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + keyframe.time,
|
||||
zoomLevel,
|
||||
});
|
||||
return {
|
||||
time: keyframe.time,
|
||||
offsetPx: keyframeLeft - elementLeft,
|
||||
keyframeRef,
|
||||
isSelected: isKeyframeSelected({ keyframe: keyframeRef }),
|
||||
};
|
||||
}
|
||||
|
||||
function getKeyframeIndicators({
|
||||
keyframes,
|
||||
trackId,
|
||||
elementId,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
elementWidth,
|
||||
isKeyframeSelected,
|
||||
}: {
|
||||
keyframes: ElementKeyframe[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
elementWidth: number;
|
||||
isKeyframeSelected: ({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}) => boolean;
|
||||
}): KeyframeIndicator[] {
|
||||
if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keyframesByTime = new Map<number, KeyframeIndicator>();
|
||||
for (const keyframe of keyframes) {
|
||||
const indicator = buildKeyframeIndicator({
|
||||
keyframe,
|
||||
trackId,
|
||||
elementId,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
isKeyframeSelected,
|
||||
});
|
||||
const existingIndicator = keyframesByTime.get(indicator.time);
|
||||
if (!existingIndicator) {
|
||||
keyframesByTime.set(indicator.time, {
|
||||
time: indicator.time,
|
||||
offsetPx: indicator.offsetPx,
|
||||
keyframes: [indicator.keyframeRef],
|
||||
isSelected: indicator.isSelected,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existingIndicator.keyframes.push(indicator.keyframeRef);
|
||||
existingIndicator.isSelected =
|
||||
existingIndicator.isSelected || indicator.isSelected;
|
||||
}
|
||||
|
||||
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
function getDisplayShortcut(action: TAction) {
|
||||
const { defaultShortcuts } = getActionDefinition(action);
|
||||
|
|
@ -86,6 +202,7 @@ export function TimelineElement({
|
|||
}: TimelineElementProps) {
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useElementSelection();
|
||||
const { isKeyframeSelected } = useKeyframeSelection();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
|
|
@ -123,10 +240,24 @@ export function TimelineElement({
|
|||
: element.startTime;
|
||||
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
|
||||
const displayedDuration = isResizing ? currentDuration : element.duration;
|
||||
const elementWidth =
|
||||
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const elementLeft = displayedStartTime * 50 * zoomLevel;
|
||||
|
||||
const elementWidth = timelineTimeToPixels({
|
||||
time: displayedDuration,
|
||||
zoomLevel,
|
||||
});
|
||||
const elementLeft = timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime,
|
||||
zoomLevel,
|
||||
});
|
||||
const keyframeIndicators = getKeyframeIndicators({
|
||||
keyframes: getElementKeyframes({ animations: element.animations }),
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
elementWidth,
|
||||
isKeyframeSelected,
|
||||
});
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
|
|
@ -161,6 +292,7 @@ export function TimelineElement({
|
|||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
<KeyframeIndicators indicators={keyframeIndicators} />
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
|
|
@ -197,7 +329,9 @@ export function TimelineElement({
|
|||
<>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Search01Icon} />}
|
||||
onClick={(event) => handleRevealInMedia({ event })}
|
||||
onClick={(event: React.MouseEvent) =>
|
||||
handleRevealInMedia({ event })
|
||||
}
|
||||
>
|
||||
Reveal media
|
||||
</ContextMenuItem>
|
||||
|
|
@ -271,7 +405,6 @@ function ElementInner({
|
|||
mediaAssets={mediaAssets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasAudio
|
||||
? isMuted
|
||||
: canElementBeHidden(element) && element.hidden) && (
|
||||
|
|
@ -335,6 +468,67 @@ function ResizeHandle({
|
|||
);
|
||||
}
|
||||
|
||||
function KeyframeIndicators({
|
||||
indicators,
|
||||
}: {
|
||||
indicators: KeyframeIndicator[];
|
||||
}) {
|
||||
const { toggleKeyframeSelection, selectKeyframeRange } =
|
||||
useKeyframeSelection();
|
||||
const orderedKeyframes = indicators.flatMap((indicator) => indicator.keyframes);
|
||||
|
||||
const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleKeyframeClick = ({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
selectKeyframeRange({
|
||||
orderedKeyframes,
|
||||
targetKeyframes: keyframes,
|
||||
isAdditive: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toggleKeyframeSelection({
|
||||
keyframes,
|
||||
isMultiKey: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
};
|
||||
|
||||
return indicators.map((indicator) => (
|
||||
<button
|
||||
key={indicator.time}
|
||||
type="button"
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
|
||||
style={{ left: indicator.offsetPx }}
|
||||
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
|
||||
onClick={(event) =>
|
||||
handleKeyframeClick({ event, keyframes: indicator.keyframes })
|
||||
}
|
||||
aria-label="Select keyframe"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(
|
||||
"size-3.5 text-black",
|
||||
indicator.isSelected ? "fill-primary" : "fill-white",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
function ElementContent({
|
||||
element,
|
||||
track,
|
||||
|
|
@ -549,10 +743,11 @@ function ActionMenuItem({
|
|||
...props
|
||||
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
|
||||
action: TAction;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={(event) => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
|
|
@ -43,9 +47,11 @@ export function TimelinePlayhead({
|
|||
400;
|
||||
const totalHeight = Math.max(0, timelineContainerHeight - 4);
|
||||
|
||||
const timelinePosition =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = timelinePosition;
|
||||
const centerPosition = timelineTimeToSnappedPixels({
|
||||
time: playheadPosition,
|
||||
zoomLevel,
|
||||
});
|
||||
const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
|
||||
|
||||
const handlePlayheadKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
|
|
@ -77,7 +83,7 @@ export function TimelinePlayhead({
|
|||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
|
||||
}}
|
||||
onKeyDown={handlePlayheadKeyDown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
||||
|
||||
interface TimelineTickProps {
|
||||
|
|
@ -16,7 +16,7 @@ export function TimelineTick({
|
|||
fps,
|
||||
showLabel,
|
||||
}: TimelineTickProps) {
|
||||
const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
|
||||
|
||||
if (showLabel) {
|
||||
const label = formatRulerLabel({ timeInSeconds: time, fps });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export const TIME_EPSILON_SECONDS = 1 / 1000;
|
||||
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/types/animation";
|
||||
|
||||
type ElementRef = { trackId: string; elementId: string };
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
|
|
@ -14,13 +17,47 @@ export class SelectionManager {
|
|||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||
return this.selectedKeyframes;
|
||||
}
|
||||
|
||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedKeyframes({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||
}): void {
|
||||
this.selectedKeyframes = keyframes;
|
||||
if (anchorKeyframe !== undefined) {
|
||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||
} else if (keyframes.length === 0) {
|
||||
this.keyframeSelectionAnchor = null;
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearKeyframeSelection(): void {
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import type {
|
|||
TimelineElement,
|
||||
ClipboardItem,
|
||||
} from "@/types/timeline";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/types/animation";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
|
|
@ -24,6 +29,9 @@ import {
|
|||
UpdateElementStartTimeCommand,
|
||||
MoveElementCommand,
|
||||
TracksSnapshotCommand,
|
||||
UpsertKeyframeCommand,
|
||||
RemoveKeyframeCommand,
|
||||
RetimeKeyframeCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand, PreviewTracker } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
|
|
@ -61,7 +69,11 @@ export class TimelineManager {
|
|||
trimEnd: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
|
||||
const command = new UpdateElementTrimCommand({
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
|
|
@ -80,11 +92,11 @@ export class TimelineManager {
|
|||
duration: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementDurationCommand(
|
||||
const command = new UpdateElementDurationCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
);
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
|
|
@ -99,7 +111,10 @@ export class TimelineManager {
|
|||
elements: { trackId: string; elementId: string }[];
|
||||
startTime: number;
|
||||
}): void {
|
||||
const command = new UpdateElementStartTimeCommand(elements, startTime);
|
||||
const command = new UpdateElementStartTimeCommand({
|
||||
elements,
|
||||
startTime,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -116,13 +131,13 @@ export class TimelineManager {
|
|||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
}): void {
|
||||
const command = new MoveElementCommand(
|
||||
const command = new MoveElementCommand({
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
);
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +160,11 @@ export class TimelineManager {
|
|||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): { trackId: string; elementId: string }[] {
|
||||
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
||||
const command = new SplitElementsCommand({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
return command.getRightSideElements();
|
||||
}
|
||||
|
|
@ -197,7 +216,7 @@ export class TimelineManager {
|
|||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new DeleteElementsCommand(elements);
|
||||
const command = new DeleteElementsCommand({ elements });
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -208,13 +227,17 @@ export class TimelineManager {
|
|||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<Record<string, unknown>>;
|
||||
updates: Partial<TimelineElement>;
|
||||
}>;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const commands = updates.map(
|
||||
({ trackId, elementId, updates: elementUpdates }) =>
|
||||
new UpdateElementCommand(trackId, elementId, elementUpdates),
|
||||
new UpdateElementCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: elementUpdates,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
|
|
@ -225,6 +248,99 @@ export class TimelineManager {
|
|||
}
|
||||
}
|
||||
|
||||
upsertKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}>;
|
||||
}): void {
|
||||
if (keyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = keyframes.map(
|
||||
({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation = "linear",
|
||||
keyframeId,
|
||||
}) =>
|
||||
new UpsertKeyframeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
removeKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}>;
|
||||
}): void {
|
||||
if (keyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = keyframes.map(
|
||||
({ trackId, elementId, propertyPath, keyframeId }) =>
|
||||
new RemoveKeyframeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
retimeKeyframe({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
time,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
}): void {
|
||||
const command = new RetimeKeyframeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
nextTime: time,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isPreviewActive(): boolean {
|
||||
return this.previewTracker.isActive();
|
||||
}
|
||||
|
|
@ -235,7 +351,7 @@ export class TimelineManager {
|
|||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<Record<string, unknown>>;
|
||||
updates: Partial<TimelineElement>;
|
||||
}>;
|
||||
}): void {
|
||||
const tracks = this.getTracks();
|
||||
|
|
|
|||
|
|
@ -592,9 +592,12 @@ export function useElementInteraction({
|
|||
});
|
||||
if (!alreadySelected) {
|
||||
selectElement({ trackId: track.id, elementId: element.id });
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.clearKeyframeSelection();
|
||||
},
|
||||
[isElementSelected, selectElement],
|
||||
[editor.selection, isElementSelected, selectElement],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SelectedKeyframeRef } from "@/types/animation";
|
||||
|
||||
function getSelectedKeyframeId({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): string {
|
||||
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
|
||||
}
|
||||
|
||||
function mergeUniqueKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}): SelectedKeyframeRef[] {
|
||||
const keyframesById = new Map<string, SelectedKeyframeRef>();
|
||||
for (const keyframe of keyframes) {
|
||||
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
|
||||
}
|
||||
return [...keyframesById.values()];
|
||||
}
|
||||
|
||||
export function useKeyframeSelection() {
|
||||
const editor = useEditor();
|
||||
const selectedKeyframes = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedKeyframes(),
|
||||
);
|
||||
const keyframeSelectionAnchor = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getKeyframeSelectionAnchor(),
|
||||
);
|
||||
|
||||
const isKeyframeSelected = useCallback(
|
||||
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
|
||||
const keyframeId = getSelectedKeyframeId({ keyframe });
|
||||
return selectedKeyframes.some(
|
||||
(selectedKeyframe) =>
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
|
||||
);
|
||||
},
|
||||
[selectedKeyframes],
|
||||
);
|
||||
|
||||
const setKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: uniqueKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addKeyframesToSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const mergedKeyframes = mergeUniqueKeyframes({
|
||||
keyframes: [...selectedKeyframes, ...keyframes],
|
||||
});
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: mergedKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const removeKeyframesFromSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const keyframeIdsToRemove = new Set(
|
||||
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const nextKeyframes = selectedKeyframes.filter(
|
||||
(selectedKeyframe) =>
|
||||
!keyframeIdsToRemove.has(
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
|
||||
),
|
||||
);
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: nextKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const clearKeyframeSelection = useCallback(() => {
|
||||
editor.selection.clearKeyframeSelection();
|
||||
}, [editor]);
|
||||
|
||||
const toggleKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
isMultiKey,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isMultiKey: boolean;
|
||||
}) => {
|
||||
const anchorKeyframe = keyframes[0];
|
||||
if (!isMultiKey) {
|
||||
setKeyframeSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (areAllKeyframesSelected) {
|
||||
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
addKeyframesToSelection({ keyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
setKeyframeSelection,
|
||||
isKeyframeSelected,
|
||||
removeKeyframesFromSelection,
|
||||
addKeyframesToSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const selectKeyframeRange = useCallback(
|
||||
({
|
||||
orderedKeyframes,
|
||||
targetKeyframes,
|
||||
isAdditive,
|
||||
}: {
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
targetKeyframes: SelectedKeyframeRef[];
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorKeyframe =
|
||||
keyframeSelectionAnchor ??
|
||||
selectedKeyframes[selectedKeyframes.length - 1] ??
|
||||
targetKeyframes[0];
|
||||
if (!anchorKeyframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKeyframeIds = new Set(
|
||||
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
|
||||
const anchorIndex = orderedKeyframes.findIndex(
|
||||
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
|
||||
);
|
||||
if (anchorIndex === -1) {
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({
|
||||
keyframes: targetKeyframes,
|
||||
anchorKeyframe,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndexes = orderedKeyframes
|
||||
.map((keyframe, index) => ({
|
||||
keyframeId: getSelectedKeyframeId({ keyframe }),
|
||||
index,
|
||||
}))
|
||||
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
|
||||
.map(({ index }) => index);
|
||||
if (targetIndexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
|
||||
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
|
||||
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
|
||||
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
keyframeSelectionAnchor,
|
||||
selectedKeyframes,
|
||||
addKeyframesToSelection,
|
||||
setKeyframeSelection,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedKeyframes,
|
||||
keyframeSelectionAnchor,
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
addKeyframesToSelection,
|
||||
removeKeyframesFromSelection,
|
||||
clearKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
|
|
@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
|
|||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
const timelinePosition =
|
||||
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const timelinePosition = timelineTimeToSnappedPixels({
|
||||
time: snapPoint?.time ?? 0,
|
||||
zoomLevel,
|
||||
});
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import {
|
||||
clampAnimationsToDuration,
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
getChannelValueAtTime,
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
splitAnimationsAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
describe("transform keyframe evaluation", () => {
|
||||
test("uses fallback value when channel is missing", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: undefined,
|
||||
time: 1,
|
||||
fallbackValue: 42,
|
||||
});
|
||||
expect(value).toBe(42);
|
||||
});
|
||||
|
||||
test("returns boundary value when time is within epsilon of first/last keyframe", () => {
|
||||
const channel = {
|
||||
valueKind: "number" as const,
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "linear" as const },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" as const },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: 0.0008,
|
||||
fallbackValue: 0,
|
||||
}),
|
||||
).toBe(10);
|
||||
expect(
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: 1.9992,
|
||||
fallbackValue: 0,
|
||||
}),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
test("interpolates linear channels", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 1,
|
||||
fallbackValue: 0,
|
||||
});
|
||||
expect(value).toBe(20);
|
||||
});
|
||||
|
||||
test("clamps local time to [0, duration]", () => {
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 2,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 12,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(4);
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 7,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("uses hold interpolation from the left keyframe", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "hold" },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 1,
|
||||
fallbackValue: 0,
|
||||
});
|
||||
expect(value).toBe(10);
|
||||
});
|
||||
|
||||
test("resolves transform by mixing animated and fallback properties", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 0, interpolation: "linear" },
|
||||
{ id: "b", time: 4, value: 80, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: {
|
||||
position: { x: 10, y: 20 },
|
||||
scale: 1,
|
||||
rotate: 15,
|
||||
},
|
||||
animations,
|
||||
localTime: 2,
|
||||
});
|
||||
expect(resolvedTransform).toEqual({
|
||||
position: { x: 40, y: 20 },
|
||||
scale: 2,
|
||||
rotate: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("transform keyframe mutation utilities", () => {
|
||||
test("splits channels and rebases right side times", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 2, interpolation: "linear" },
|
||||
{ id: "c", time: 6, value: 4, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations,
|
||||
splitTime: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
leftAnimations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2, 4]);
|
||||
expect(
|
||||
rightAnimations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps channels to updated element duration", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 0, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 20, interpolation: "linear" },
|
||||
{ id: "c", time: 5, value: 50, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const clampedAnimations = clampAnimationsToDuration({
|
||||
animations,
|
||||
duration: 2,
|
||||
});
|
||||
expect(
|
||||
clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("typed channel interpolation", () => {
|
||||
test("interpolates color channels from hex keyframes", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "color",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: "#000000", interpolation: "linear" },
|
||||
{ id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 0.5,
|
||||
fallbackValue: "#000000",
|
||||
});
|
||||
expect(typeof value).toBe("string");
|
||||
expect(value).toContain("rgba(");
|
||||
});
|
||||
|
||||
test("uses hold behavior for discrete channels", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "discrete",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: "normal", interpolation: "hold" },
|
||||
{ id: "b", time: 2, value: "multiply", interpolation: "hold" },
|
||||
],
|
||||
},
|
||||
time: 1.2,
|
||||
fallbackValue: "normal",
|
||||
});
|
||||
expect(value).toBe("normal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyframe query helpers", () => {
|
||||
test("getElementKeyframes returns flat list of all keyframes across channels", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const keyframes = getElementKeyframes({ animations });
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
|
||||
"opacity",
|
||||
"transform.position.x",
|
||||
]);
|
||||
});
|
||||
|
||||
test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
|
||||
expect(getElementKeyframes({ animations: undefined })).toEqual([]);
|
||||
expect(
|
||||
getElementKeyframes({
|
||||
animations: {
|
||||
channels: { opacity: { valueKind: "number", keyframes: [] } },
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("hasKeyframesForPath returns true only for paths with keyframes", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
|
||||
},
|
||||
"transform.position.y": {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "r-1", time: 1, value: 15, interpolation: "linear" },
|
||||
{ id: "r-2", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const found = getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
time: 1.0008,
|
||||
});
|
||||
expect(found?.id).toBe("r-1");
|
||||
expect(found?.value).toBe(15);
|
||||
expect(found?.propertyPath).toBe("transform.rotate");
|
||||
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
time: 1.01,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
export {
|
||||
getChannelValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
upsertElementKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
resolveVolumeAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
import type {
|
||||
AnimationChannel,
|
||||
AnimationValue,
|
||||
ColorAnimationChannel,
|
||||
DiscreteValue,
|
||||
DiscreteAnimationChannel,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): number {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
function isWithinTimePair({
|
||||
time,
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
time: number;
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= leftTime - TIME_EPSILON_SECONDS &&
|
||||
time <= rightTime + TIME_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function parseHexChannel({ hex }: { hex: string }): number | null {
|
||||
const value = Number.parseInt(hex, 16);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function parseHexColor({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): { red: number; green: number; blue: number; alpha: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawHex = trimmed.slice(1);
|
||||
if (rawHex.length === 3 || rawHex.length === 4) {
|
||||
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
|
||||
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
|
||||
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
|
||||
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
|
||||
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
|
||||
if (
|
||||
red === null ||
|
||||
green === null ||
|
||||
blue === null ||
|
||||
alpha === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
if (rawHex.length === 6 || rawHex.length === 8) {
|
||||
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
|
||||
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
|
||||
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
|
||||
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
|
||||
const alpha = parseHexChannel({ hex: alphaHex });
|
||||
if (
|
||||
red === null ||
|
||||
green === null ||
|
||||
blue === null ||
|
||||
alpha === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRgbaColor({
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha,
|
||||
}: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}): string {
|
||||
const roundedRed = Math.round(red);
|
||||
const roundedGreen = Math.round(green);
|
||||
const roundedBlue = Math.round(blue);
|
||||
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
|
||||
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
leftValue,
|
||||
rightValue,
|
||||
progress,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
progress: number;
|
||||
}): number {
|
||||
return leftValue + (rightValue - leftValue) * progress;
|
||||
}
|
||||
|
||||
function interpolateColor({
|
||||
leftColor,
|
||||
rightColor,
|
||||
progress,
|
||||
}: {
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
progress: number;
|
||||
}): string {
|
||||
const leftParsed = parseHexColor({ color: leftColor });
|
||||
const rightParsed = parseHexColor({ color: rightColor });
|
||||
if (!leftParsed || !rightParsed) {
|
||||
return progress >= 1 ? rightColor : leftColor;
|
||||
}
|
||||
|
||||
return formatRgbaColor({
|
||||
red: lerpNumber({
|
||||
leftValue: leftParsed.red,
|
||||
rightValue: rightParsed.red,
|
||||
progress,
|
||||
}),
|
||||
green: lerpNumber({
|
||||
leftValue: leftParsed.green,
|
||||
rightValue: rightParsed.green,
|
||||
progress,
|
||||
}),
|
||||
blue: lerpNumber({
|
||||
leftValue: leftParsed.blue,
|
||||
rightValue: rightParsed.blue,
|
||||
progress,
|
||||
}),
|
||||
alpha: lerpNumber({
|
||||
leftValue: leftParsed.alpha,
|
||||
rightValue: rightParsed.alpha,
|
||||
progress,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
channel,
|
||||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
return {
|
||||
...channel,
|
||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
}),
|
||||
),
|
||||
} as TChannel;
|
||||
}
|
||||
|
||||
function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
|
||||
keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue,
|
||||
}: {
|
||||
keyframes: TKeyframe[] | undefined;
|
||||
time: number;
|
||||
fallbackValue: TValue;
|
||||
getInterpolatedValue: ({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
}: {
|
||||
leftKeyframe: TKeyframe;
|
||||
rightKeyframe: TKeyframe;
|
||||
progress: number;
|
||||
}) => TValue;
|
||||
}): TValue {
|
||||
if (!keyframes || keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const firstKeyframe = keyframes[0];
|
||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
||||
if (!firstKeyframe || !lastKeyframe) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
||||
return firstKeyframe.value;
|
||||
}
|
||||
|
||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
||||
return lastKeyframe.value;
|
||||
}
|
||||
|
||||
for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
|
||||
const leftKeyframe = keyframes[keyframeIndex];
|
||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
||||
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({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: NumberAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return lerpNumber({
|
||||
leftValue: leftKeyframe.value,
|
||||
rightValue: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ColorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: string;
|
||||
}): string {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return interpolateColor({
|
||||
leftColor: leftKeyframe.value,
|
||||
rightColor: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscreteValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof fallbackValue !== "number") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getNumberChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof fallbackValue !== "string") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getDiscreteValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/types/animation";
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keyframes.map((keyframe) => ({
|
||||
propertyPath: propertyPath as AnimationPropertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
return Boolean(channel && channel.keyframes.length > 0);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.keyframes.length === 0) return null;
|
||||
const keyframe = channel.keyframes.find(
|
||||
(kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS,
|
||||
);
|
||||
if (!keyframe) return null;
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationKeyframe,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
ColorAnimationChannel,
|
||||
DiscreteAnimationChannel,
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getDefaultInterpolationForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
isAnimationPropertyPath,
|
||||
} from "./property-registry";
|
||||
|
||||
function isNearlySameTime({
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
|
||||
}
|
||||
|
||||
function toAnimation({
|
||||
channelEntries,
|
||||
}: {
|
||||
channelEntries: Array<[string, AnimationChannel]>;
|
||||
}): ElementAnimations | undefined {
|
||||
if (channelEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
channels: Object.fromEntries(channelEntries),
|
||||
};
|
||||
}
|
||||
|
||||
function toChannel({
|
||||
keyframes,
|
||||
valueKind,
|
||||
}: {
|
||||
keyframes: AnimationKeyframe[];
|
||||
valueKind: AnimationValueKind;
|
||||
}): AnimationChannel {
|
||||
return normalizeChannel({
|
||||
channel: {
|
||||
valueKind,
|
||||
keyframes,
|
||||
} as AnimationChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
}): AnimationChannel | undefined {
|
||||
return animations?.channels[propertyPath];
|
||||
}
|
||||
|
||||
function getInterpolationForChannel({
|
||||
channel,
|
||||
interpolation,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
interpolation: AnimationInterpolation | undefined;
|
||||
}): AnimationInterpolation {
|
||||
if (channel.valueKind === "discrete") {
|
||||
return "hold";
|
||||
}
|
||||
|
||||
if (interpolation === "linear" || interpolation === "hold") {
|
||||
return interpolation;
|
||||
}
|
||||
|
||||
return "linear";
|
||||
}
|
||||
|
||||
function buildKeyframe({
|
||||
channel,
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}): AnimationKeyframe {
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof value !== "number") {
|
||||
throw new Error("Number channel keyframes require numeric values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation === "hold" ? "hold" : "linear",
|
||||
};
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Color channel keyframes require string values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation === "hold" ? "hold" : "linear",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value !== "string" && typeof value !== "boolean") {
|
||||
throw new Error("Discrete channel keyframes require boolean or string values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: "hold",
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyChannel({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationChannel {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
|
||||
}
|
||||
|
||||
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentKeyframes = channel.keyframes;
|
||||
const nextKeyframes = [...currentKeyframes];
|
||||
const nextInterpolation = getInterpolationForChannel({
|
||||
channel,
|
||||
interpolation,
|
||||
});
|
||||
if (keyframeId) {
|
||||
const keyframeByIdIndex = nextKeyframes.findIndex(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
);
|
||||
if (keyframeByIdIndex >= 0) {
|
||||
nextKeyframes[keyframeByIdIndex] = buildKeyframe({
|
||||
channel,
|
||||
id: nextKeyframes[keyframeByIdIndex].id,
|
||||
time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
});
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
|
||||
);
|
||||
if (keyframeAtTimeIndex >= 0) {
|
||||
nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
|
||||
channel,
|
||||
id: nextKeyframes[keyframeAtTimeIndex].id,
|
||||
time: nextKeyframes[keyframeAtTimeIndex].time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
});
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
nextKeyframes.push(
|
||||
buildKeyframe({
|
||||
channel,
|
||||
id: keyframeId ?? generateUUID(),
|
||||
time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
}),
|
||||
);
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextKeyframes = channel.keyframes.filter(
|
||||
(keyframe) => keyframe.id !== keyframeId,
|
||||
);
|
||||
if (nextKeyframes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function retimeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
time,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyframeByIdIndex = channel.keyframes.findIndex(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
);
|
||||
if (keyframeByIdIndex < 0) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
const nextKeyframes = [...channel.keyframes];
|
||||
nextKeyframes[keyframeByIdIndex] = {
|
||||
...nextKeyframes[keyframeByIdIndex],
|
||||
time,
|
||||
};
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
channel: AnimationChannel | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const currentChannels = animations?.channels ?? {};
|
||||
|
||||
const nextChannelEntries = Object.entries(currentChannels)
|
||||
.filter(([path]) => path !== propertyPath)
|
||||
.filter(([, ch]) => ch && ch.keyframes.length > 0)
|
||||
.map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
|
||||
|
||||
if (channel && channel.keyframes.length > 0) {
|
||||
nextChannelEntries.push([propertyPath, channel]);
|
||||
}
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: nextChannelEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneAnimations({
|
||||
animations,
|
||||
shouldRegenerateKeyframeIds = false,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
shouldRegenerateKeyframeIds?: boolean;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clonedEntries = Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clonedKeyframes = channel.keyframes.map((keyframe) => ({
|
||||
...keyframe,
|
||||
id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
|
||||
}));
|
||||
|
||||
return [
|
||||
[
|
||||
propertyPath,
|
||||
toChannel({
|
||||
keyframes: clonedKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
}),
|
||||
] as [string, AnimationChannel],
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: clonedEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function clampAnimationsToDuration({
|
||||
animations,
|
||||
duration,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
duration: number;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clampedEntries = Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextKeyframes = channel.keyframes.filter(
|
||||
(keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
|
||||
);
|
||||
if (nextKeyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
propertyPath,
|
||||
toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
}),
|
||||
] as [string, AnimationChannel],
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: clampedEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function splitAnimationsAtTime({
|
||||
animations,
|
||||
splitTime,
|
||||
shouldIncludeSplitBoundary = true,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
splitTime: number;
|
||||
shouldIncludeSplitBoundary?: boolean;
|
||||
}): {
|
||||
leftAnimations: ElementAnimations | undefined;
|
||||
rightAnimations: ElementAnimations | undefined;
|
||||
} {
|
||||
if (!animations) {
|
||||
return { leftAnimations: undefined, rightAnimations: undefined };
|
||||
}
|
||||
|
||||
const leftChannels: Array<[string, AnimationChannel]> = [];
|
||||
const rightChannels: Array<[string, AnimationChannel]> = [];
|
||||
|
||||
for (const [propertyPath, channel] of Object.entries(animations.channels)) {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({ channel });
|
||||
let leftKeyframes = normalizedChannel.keyframes.filter(
|
||||
(keyframe) => keyframe.time <= splitTime,
|
||||
);
|
||||
let rightKeyframes = normalizedChannel.keyframes
|
||||
.filter((keyframe) => keyframe.time >= splitTime)
|
||||
.map((keyframe) => ({
|
||||
...keyframe,
|
||||
time: keyframe.time - splitTime,
|
||||
}));
|
||||
|
||||
const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
|
||||
);
|
||||
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
|
||||
);
|
||||
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
|
||||
const boundaryValue = getChannelValueAtTime({
|
||||
channel: normalizedChannel,
|
||||
time: splitTime,
|
||||
fallbackValue: normalizedChannel.keyframes[0].value,
|
||||
});
|
||||
const knownPropertyPath = isAnimationPropertyPath({ propertyPath })
|
||||
? (propertyPath as AnimationPropertyPath)
|
||||
: null;
|
||||
const boundaryInterpolation = knownPropertyPath
|
||||
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
|
||||
: normalizedChannel.valueKind === "discrete"
|
||||
? "hold"
|
||||
: "linear";
|
||||
|
||||
if (!hasBoundaryOnLeft) {
|
||||
leftKeyframes = [
|
||||
...leftKeyframes,
|
||||
buildKeyframe({
|
||||
channel: normalizedChannel,
|
||||
id: generateUUID(),
|
||||
time: splitTime,
|
||||
value: boundaryValue,
|
||||
interpolation: boundaryInterpolation,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (!hasBoundaryOnRight) {
|
||||
rightKeyframes = [
|
||||
buildKeyframe({
|
||||
channel: normalizedChannel,
|
||||
id: generateUUID(),
|
||||
time: 0,
|
||||
value: boundaryValue,
|
||||
interpolation: boundaryInterpolation,
|
||||
}),
|
||||
...rightKeyframes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const leftChannel = leftKeyframes.length
|
||||
? toChannel({
|
||||
keyframes: leftKeyframes,
|
||||
valueKind: normalizedChannel.valueKind,
|
||||
})
|
||||
: undefined;
|
||||
const rightChannel = rightKeyframes.length
|
||||
? toChannel({
|
||||
keyframes: rightKeyframes,
|
||||
valueKind: normalizedChannel.valueKind,
|
||||
})
|
||||
: undefined;
|
||||
if (leftChannel) {
|
||||
leftChannels.push([propertyPath, leftChannel]);
|
||||
}
|
||||
if (rightChannel) {
|
||||
rightChannels.push([propertyPath, rightChannel]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
leftAnimations: toAnimation({ channelEntries: leftChannels }),
|
||||
rightAnimations: toAnimation({ channelEntries: rightChannels }),
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const targetChannel =
|
||||
channel && channel.valueKind === propertyDefinition.valueKind
|
||||
? channel
|
||||
: createEmptyChannel({ propertyPath });
|
||||
const updatedChannel = upsertKeyframe({
|
||||
channel: targetChannel,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation: interpolation ?? defaultInterpolation,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
return (
|
||||
setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
}) ?? { channels: {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const updatedChannel = removeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
});
|
||||
return setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function retimeElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
}): ElementAnimations | undefined {
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const updatedChannel = retimeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
time,
|
||||
});
|
||||
return setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
DiscreteValue,
|
||||
} from "@/types/animation";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface NumericRange {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface AnimationPropertyDefinition {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericRange;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => number | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: number;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.x : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.position.y": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.y : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scale : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, scale: value } }
|
||||
: element,
|
||||
},
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, rotate: value } }
|
||||
: element,
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element) ? { ...element, opacity: value } : element,
|
||||
},
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1 },
|
||||
supportsElement: ({ element }) => element.type === "audio",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "audio" ? element.volume : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "audio" ? { ...element, volume: value } : element,
|
||||
},
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): boolean {
|
||||
return propertyPath in ANIMATION_PROPERTY_REGISTRY;
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
||||
if (coercedValue === null || typeof coercedValue !== "number") {
|
||||
return element;
|
||||
}
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
function clampNumericRange({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericRange | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, value));
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clampNumericRange({
|
||||
value,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
});
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "boolean") {
|
||||
return value as DiscreteValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import type { ElementAnimations } from "@/types/animation";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import { getNumberChannelValueAtTime } from "./interpolation";
|
||||
import { getNumberChannelForPath } from "./number-channel";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position.x",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position.y",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scale: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scale",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scale,
|
||||
}),
|
||||
rotate: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVolumeAtTime({
|
||||
baseVolume,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseVolume: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "volume",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseVolume,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, VideoElement } from "@/types/timeline";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
|
||||
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
|
||||
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
|
||||
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
|
||||
type MockEditor = {
|
||||
timeline: {
|
||||
getTracks: () => TimelineTrack[];
|
||||
updateTracks: (tracks: TimelineTrack[]) => void;
|
||||
};
|
||||
selection: {
|
||||
getSelectedElements: () => { trackId: string; elementId: string }[];
|
||||
setSelectedElements: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const originalGetInstance = EditorCore.getInstance;
|
||||
|
||||
function mockEditorCore({ editor }: { editor: MockEditor }): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: () => EditorCore;
|
||||
}
|
||||
).getInstance = () => editor as unknown as EditorCore;
|
||||
}
|
||||
|
||||
function restoreEditorCore(): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: typeof EditorCore.getInstance;
|
||||
}
|
||||
).getInstance = originalGetInstance;
|
||||
}
|
||||
|
||||
function buildVideoElement(): VideoElement {
|
||||
return {
|
||||
id: "element-1",
|
||||
name: "Clip",
|
||||
type: "video",
|
||||
mediaId: "media-1",
|
||||
duration: 8,
|
||||
startTime: 1,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: DEFAULT_TRANSFORM,
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
|
||||
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
|
||||
return [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Main",
|
||||
type: "video",
|
||||
elements: [element],
|
||||
isMain: true,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreEditorCore();
|
||||
});
|
||||
|
||||
describe("keyframe-aware timeline commands", () => {
|
||||
test("duration updates clamp keyframes beyond the new duration", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementDurationCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
duration: 3,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
|
||||
expect(
|
||||
updatedElement?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
test("trim updates clamp keyframes when duration is changed", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementTrimCommand({
|
||||
elementId: "element-1",
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
startTime: 1,
|
||||
duration: 2,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.duration).toBe(2);
|
||||
expect(
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
test("split rebases right-side keyframes and keeps continuity at split time", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new SplitElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
splitTime: 5,
|
||||
}).execute();
|
||||
|
||||
const leftElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id === "element-1",
|
||||
) as VideoElement;
|
||||
const rightElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id !== "element-1",
|
||||
) as VideoElement;
|
||||
|
||||
expect(
|
||||
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 4]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBeCloseTo(5 / 3, 4);
|
||||
});
|
||||
|
||||
test("duplicate creates independent keyframe ids for copied element", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new DuplicateElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
}).execute();
|
||||
|
||||
const originalElement = updatedTracks.find(
|
||||
(track) => track.id === "track-1",
|
||||
)?.elements[0] as VideoElement;
|
||||
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
|
||||
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
|
||||
|
||||
expect(duplicatedElement).toBeDefined();
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 6]);
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
).not.toBe(
|
||||
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic keyframe commands", () => {
|
||||
test("upsert adds or updates keyframe at target time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
time: 2,
|
||||
value: 2.5,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
|
||||
expect(atTwo?.value).toBe(2.5);
|
||||
});
|
||||
|
||||
test("remove deletes keyframe by id", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
|
||||
expect(updatedElement.transform.scale).toBe(1);
|
||||
});
|
||||
|
||||
test("remove persists value to base property when channel becomes empty", () => {
|
||||
const element: VideoElement = {
|
||||
...buildVideoElement(),
|
||||
transform: {
|
||||
...DEFAULT_TRANSFORM,
|
||||
scale: 1,
|
||||
},
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "only-scale",
|
||||
time: 2,
|
||||
value: 1.43,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "only-scale",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.transform.scale).toBe(1.43);
|
||||
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upsert supports non-transform paths like opacity", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "opacity",
|
||||
time: 1,
|
||||
value: 0.35,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const opacityChannel = updatedElement.animations?.channels.opacity;
|
||||
expect(opacityChannel?.valueKind).toBe("number");
|
||||
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
|
||||
});
|
||||
|
||||
test("retime moves keyframe to new time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
nextTime: 4,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
|
||||
(existingKeyframe) => existingKeyframe.id === "kf-b",
|
||||
);
|
||||
expect(keyframe?.time).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -176,6 +177,10 @@ function buildPastedElements({
|
|||
...item.element,
|
||||
id: newElementId,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: item.element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
} as TimelineElement);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ import { isMainTrack } from "@/lib/timeline";
|
|||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
|
||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
||||
constructor({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
|
|
@ -17,7 +23,7 @@ export class DeleteElementsCommand extends Command {
|
|||
const updatedTracks = this.savedState
|
||||
.map((track) => {
|
||||
const hasElementsToDelete = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToDelete) {
|
||||
|
|
@ -29,7 +35,9 @@ export class DeleteElementsCommand extends Command {
|
|||
elements: track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(el) => el.trackId === track.id && el.elementId === element.id,
|
||||
(elementEntry) =>
|
||||
elementEntry.trackId === track.id &&
|
||||
elementEntry.elementId === element.id,
|
||||
),
|
||||
),
|
||||
} as typeof track;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
|
|
@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
|
|||
|
||||
for (const track of this.savedState) {
|
||||
const elementsToDuplicate = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToDuplicate.length === 0) {
|
||||
|
|
@ -114,5 +115,14 @@ function buildDuplicateElement({
|
|||
id: string;
|
||||
startTime: number;
|
||||
}): TimelineElement {
|
||||
return { ...element, id, name: `${element.name} (copy)`, startTime };
|
||||
return {
|
||||
...element,
|
||||
id,
|
||||
name: `${element.name} (copy)`,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
|
|||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./remove-keyframe";
|
||||
export * from "./retime-keyframe";
|
||||
export * from "./upsert-keyframe";
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getChannel,
|
||||
getChannelValueAtTime,
|
||||
getElementBaseValueForProperty,
|
||||
removeElementKeyframe,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
function sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!channel || !keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
|
||||
if (baseValue === null || typeof baseValue !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampled = getChannelValueAtTime({
|
||||
channel,
|
||||
time: keyframe.time,
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
return typeof sampled === "number" ? sampled : null;
|
||||
}
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): TimelineElement {
|
||||
const valueBefore = sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const nextAnimations = removeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const isChannelNowEmpty =
|
||||
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
? withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value: valueBefore,
|
||||
})
|
||||
: element;
|
||||
|
||||
return { ...baseElement, animations: nextAnimations };
|
||||
}
|
||||
|
||||
export class RemoveKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) =>
|
||||
removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
nextTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
this.nextTime = nextTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
time: boundedTime,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/types/animation";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly time: number;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.time = time;
|
||||
this.value = value;
|
||||
this.interpolation = interpolation;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: upsertElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,15 +14,31 @@ import {
|
|||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly sourceTrackId: string;
|
||||
private readonly targetTrackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly newStartTime: number;
|
||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||
|
||||
constructor(
|
||||
private sourceTrackId: string,
|
||||
private targetTrackId: string,
|
||||
private elementId: string,
|
||||
private newStartTime: number,
|
||||
private createTrack?: { type: TrackType; index: number },
|
||||
) {
|
||||
constructor({
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
}) {
|
||||
super();
|
||||
this.sourceTrackId = sourceTrackId;
|
||||
this.targetTrackId = targetTrackId;
|
||||
this.elementId = elementId;
|
||||
this.newStartTime = newStartTime;
|
||||
this.createTrack = createTrack;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
|
|
@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
|
|||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const sourceTrack = this.savedState.find(
|
||||
(t) => t.id === this.sourceTrackId,
|
||||
(track) => track.id === this.sourceTrackId,
|
||||
);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(el) => el.id === this.elementId,
|
||||
(trackElement) => trackElement.id === this.elementId,
|
||||
);
|
||||
|
||||
if (!sourceTrack || !element) {
|
||||
|
|
@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
|
|||
return;
|
||||
}
|
||||
|
||||
let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
|
||||
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
|
||||
let tracksToUpdate = this.savedState;
|
||||
if (!targetTrack && this.createTrack) {
|
||||
const newTrack = buildEmptyTrack({
|
||||
|
|
@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
|
|||
excludeElementId: this.elementId,
|
||||
});
|
||||
|
||||
// keyframe times remain clip-local, so moving only changes element startTime.
|
||||
const movedElement: TimelineElement = {
|
||||
...element,
|
||||
startTime: adjustedStartTime,
|
||||
|
|
@ -85,8 +102,8 @@ export class MoveElementCommand extends Command {
|
|||
if (isSameTrack && track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((el) =>
|
||||
el.id === this.elementId ? movedElement : el,
|
||||
elements: track.elements.map((trackElement) =>
|
||||
trackElement.id === this.elementId ? movedElement : trackElement,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
|
|||
if (track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter((el) => el.id !== this.elementId),
|
||||
elements: track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,29 @@ import { Command } from "@/lib/commands/base-command";
|
|||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private previousSelection: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private splitTime: number,
|
||||
private retainSide: "both" | "left" | "right" = "both",
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.splitTime = splitTime;
|
||||
this.retainSide = retainSide;
|
||||
}
|
||||
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
|
|
@ -28,7 +39,7 @@ export class SplitElementsCommand extends Command {
|
|||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToSplit = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToSplit.length === 0) {
|
||||
|
|
@ -39,7 +50,7 @@ export class SplitElementsCommand extends Command {
|
|||
...track,
|
||||
elements: track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(el) => el.elementId === element.id,
|
||||
(elementEntry) => elementEntry.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!shouldSplit) {
|
||||
|
|
@ -59,6 +70,11 @@ export class SplitElementsCommand extends Command {
|
|||
const relativeTime = this.splitTime - element.startTime;
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
shouldIncludeSplitBoundary: true,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
|
|
@ -67,6 +83,7 @@ export class SplitElementsCommand extends Command {
|
|||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -85,6 +102,7 @@ export class SplitElementsCommand extends Command {
|
|||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -102,6 +120,7 @@ export class SplitElementsCommand extends Command {
|
|||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
{
|
||||
...element,
|
||||
|
|
@ -110,6 +129,7 @@ export class SplitElementsCommand extends Command {
|
|||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,28 +1,48 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementDurationCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly duration: number;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private duration: number,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
duration: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, duration: this.duration } : el,
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
if (track.id !== this.trackId) return track;
|
||||
const newElements = track.elements.map((element) =>
|
||||
element.id === this.elementId
|
||||
? {
|
||||
...element,
|
||||
duration: this.duration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: this.duration,
|
||||
}),
|
||||
}
|
||||
: element,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
|||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly startTime: number;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private startTime: number,
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
startTime,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
startTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
|
|
@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
|
|||
const currentTracks = this.savedState;
|
||||
const updatedTracks = currentTracks.map((track) => {
|
||||
const hasElementsToUpdate = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToUpdate) {
|
||||
|
|
@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
|
|||
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = this.elements.some(
|
||||
(el) => el.elementId === element.id && el.trackId === track.id,
|
||||
(elementEntry) =>
|
||||
elementEntry.elementId === element.id &&
|
||||
elementEntry.trackId === track.id,
|
||||
);
|
||||
if (!shouldUpdate) {
|
||||
return element;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,35 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elementId: string;
|
||||
private readonly trimStart: number;
|
||||
private readonly trimEnd: number;
|
||||
private readonly startTime: number | undefined;
|
||||
private readonly duration: number | undefined;
|
||||
|
||||
constructor(
|
||||
private elementId: string,
|
||||
private trimStart: number,
|
||||
private trimEnd: number,
|
||||
private startTime?: number,
|
||||
private duration?: number,
|
||||
) {
|
||||
constructor({
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
}) {
|
||||
super();
|
||||
this.elementId = elementId;
|
||||
this.trimStart = trimStart;
|
||||
this.trimEnd = trimEnd;
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
|
|
@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
|
|||
return element;
|
||||
}
|
||||
|
||||
const nextDuration = this.duration ?? element.duration;
|
||||
return {
|
||||
...element,
|
||||
trimStart: this.trimStart,
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: this.startTime ?? element.startTime,
|
||||
duration: this.duration ?? element.duration,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: nextDuration,
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,38 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
|
||||
export class UpdateElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly updates: Partial<TimelineElement>;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private updates: Partial<Record<string, unknown>>,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
updates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, ...this.updates } : el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) => ({ ...element, ...this.updates }),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
|
||||
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
|
|
@ -62,10 +63,12 @@ export function getElementBounds({
|
|||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
|
@ -73,6 +76,11 @@ export function getElementBounds({
|
|||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
|
|
@ -80,21 +88,31 @@ export function getElementBounds({
|
|||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform: element.transform,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 200,
|
||||
transform: element.transform,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
|
|
@ -139,30 +157,30 @@ export function getElementBounds({
|
|||
measuredHeight = visualRect.height;
|
||||
const localCenterX = visualRect.left + visualRect.width / 2;
|
||||
const localCenterY = visualRect.top + visualRect.height / 2;
|
||||
const scaledCenterX = localCenterX * element.transform.scale;
|
||||
const scaledCenterY = localCenterY * element.transform.scale;
|
||||
const rotationRad = (element.transform.rotate * Math.PI) / 180;
|
||||
const scaledCenterX = localCenterX * transform.scale;
|
||||
const scaledCenterY = localCenterY * transform.scale;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
|
||||
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
|
||||
return {
|
||||
cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX,
|
||||
cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY,
|
||||
width: measuredWidth * element.transform.scale,
|
||||
height: measuredHeight * element.transform.scale,
|
||||
rotation: element.transform.rotate,
|
||||
cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
|
||||
cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
|
||||
width: measuredWidth * transform.scale,
|
||||
height: measuredHeight * transform.scale,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
const width = measuredWidth * element.transform.scale;
|
||||
const height = measuredHeight * element.transform.scale;
|
||||
const width = measuredWidth * transform.scale;
|
||||
const height = measuredHeight * transform.scale;
|
||||
return {
|
||||
cx: canvasWidth / 2 + element.transform.position.x,
|
||||
cy: canvasHeight / 2 + element.transform.position.y,
|
||||
cx: canvasWidth / 2 + transform.position.x,
|
||||
cy: canvasHeight / 2 + transform.position.y,
|
||||
width,
|
||||
height,
|
||||
rotation: element.transform.rotate,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +224,11 @@ export function getVisibleElementsWithBounds({
|
|||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
|
|
@ -214,6 +237,7 @@ export function getVisibleElementsWithBounds({
|
|||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type {
|
|||
AudioElement,
|
||||
VideoElement,
|
||||
ImageElement,
|
||||
StickerElement,
|
||||
VisualElement,
|
||||
UploadAudioElement,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaType } from "@/types/assets";
|
||||
|
|
@ -31,7 +31,7 @@ export function canElementHaveAudio(
|
|||
|
||||
export function isVisualElement(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement | StickerElement {
|
||||
): element is VisualElement {
|
||||
return (
|
||||
element.type === "video" ||
|
||||
element.type === "image" ||
|
||||
|
|
@ -42,7 +42,7 @@ export function isVisualElement(
|
|||
|
||||
export function canElementBeHidden(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement | StickerElement {
|
||||
): element is VisualElement {
|
||||
return element.type !== "audio";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export * from "./track-utils";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./pixel-utils";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
|
||||
|
||||
function getDevicePixelRatio({
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
if (
|
||||
typeof devicePixelRatio === "number" &&
|
||||
Number.isFinite(devicePixelRatio) &&
|
||||
devicePixelRatio > 0
|
||||
) {
|
||||
return devicePixelRatio;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
|
||||
return window.devicePixelRatio;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getTimelinePixelsPerSecond({
|
||||
zoomLevel,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
}
|
||||
|
||||
export function timelineTimeToPixels({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
return time * getTimelinePixelsPerSecond({ zoomLevel });
|
||||
}
|
||||
|
||||
export function snapPixelToDeviceGrid({
|
||||
pixel,
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
pixel: number;
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
|
||||
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
|
||||
}
|
||||
|
||||
export function timelineTimeToSnappedPixels({
|
||||
time,
|
||||
zoomLevel,
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
|
||||
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
|
||||
}
|
||||
|
||||
export function getCenteredLineLeft({
|
||||
centerPixel,
|
||||
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
}: {
|
||||
centerPixel: number;
|
||||
lineWidthPx?: number;
|
||||
}): number {
|
||||
return centerPixel - lineWidthPx / 2;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import type { Bookmark, TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark";
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
|
@ -26,6 +27,7 @@ export function findSnapPoints({
|
|||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
enableBookmarkSnapping = true,
|
||||
enableKeyframeSnapping = true,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
|
|
@ -35,13 +37,15 @@ export function findSnapPoints({
|
|||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
enableBookmarkSnapping?: boolean;
|
||||
enableKeyframeSnapping?: boolean;
|
||||
}): SnapPoint[] {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
if (enableElementSnapping) {
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
|
||||
if (enableElementSnapping) {
|
||||
snapPoints.push(
|
||||
{
|
||||
time: element.startTime,
|
||||
|
|
@ -57,6 +61,19 @@ export function findSnapPoints({
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (enableKeyframeSnapping) {
|
||||
for (const keyframe of getElementKeyframes({
|
||||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export function updateElementInTracks({
|
||||
tracks,
|
||||
trackId,
|
||||
elementId,
|
||||
update,
|
||||
elementPredicate,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
update: (element: TimelineElement) => TimelineElement;
|
||||
elementPredicate?: (element: TimelineElement) => boolean;
|
||||
}): TimelineTrack[] {
|
||||
return tracks.map((track) => {
|
||||
if (track.id !== trackId) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const nextElements = track.elements.map((element) => {
|
||||
if (element.id !== elementId) {
|
||||
return element;
|
||||
}
|
||||
if (elementPredicate && !elementPredicate(element)) {
|
||||
return element;
|
||||
}
|
||||
return update(element);
|
||||
});
|
||||
|
||||
return { ...track, elements: nextElements } as TimelineTrack;
|
||||
});
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
|
|||
source,
|
||||
sourceWidth: width || renderer.width,
|
||||
sourceHeight: height || renderer.height,
|
||||
timelineTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
|
|||
source,
|
||||
sourceWidth: width,
|
||||
sourceHeight: height,
|
||||
timelineTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
getTextBackgroundRect,
|
||||
measureTextBlock,
|
||||
} from "@/lib/text/layout";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
|
|
@ -86,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
|||
|
||||
renderer.context.save();
|
||||
|
||||
const x = this.params.transform.position.x + this.params.canvasCenter.x;
|
||||
const y = this.params.transform.position.y + this.params.canvasCenter.y;
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.startTime,
|
||||
elementDuration: this.params.duration,
|
||||
});
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: this.params.transform,
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
const opacity = resolveOpacityAtTime({
|
||||
baseOpacity: this.params.opacity,
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
const x = transform.position.x + this.params.canvasCenter.x;
|
||||
const y = transform.position.y + this.params.canvasCenter.y;
|
||||
|
||||
renderer.context.translate(x, y);
|
||||
renderer.context.scale(
|
||||
this.params.transform.scale,
|
||||
this.params.transform.scale,
|
||||
);
|
||||
if (this.params.transform.rotate) {
|
||||
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
|
||||
renderer.context.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
|
||||
|
|
@ -138,7 +155,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
|||
? this.params.blendMode
|
||||
: "source-over"
|
||||
) as GlobalCompositeOperation;
|
||||
renderer.context.globalAlpha = this.params.opacity;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (
|
||||
this.params.background.color &&
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
|
|||
return;
|
||||
}
|
||||
|
||||
const videoTime = this.getLocalTime(time);
|
||||
const videoTime = this.getSourceLocalTime(time);
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
|
|
@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
|
|||
source: frame.canvas,
|
||||
sourceWidth: frame.canvas.width,
|
||||
sourceHeight: frame.canvas.height,
|
||||
timelineTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@ import type { CanvasRenderer } from "../canvas-renderer";
|
|||
import { BaseNode } from "./base-node";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
|
||||
const VISUAL_EPSILON = 1 / 1000;
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
|
||||
export interface VisualNodeParams {
|
||||
duration: number;
|
||||
|
|
@ -11,6 +16,7 @@ export interface VisualNodeParams {
|
|||
trimStart: number;
|
||||
trimEnd: number;
|
||||
transform: Transform;
|
||||
animations?: ElementAnimations;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
}
|
||||
|
|
@ -18,14 +24,22 @@ export interface VisualNodeParams {
|
|||
export abstract class VisualNode<
|
||||
Params extends VisualNodeParams = VisualNodeParams,
|
||||
> extends BaseNode<Params> {
|
||||
protected getLocalTime(time: number): number {
|
||||
protected getSourceLocalTime(time: number): number {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
protected getAnimationLocalTime(time: number): number {
|
||||
return getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.timeOffset,
|
||||
elementDuration: this.params.duration,
|
||||
});
|
||||
}
|
||||
|
||||
protected isInRange(time: number): boolean {
|
||||
const localTime = this.getLocalTime(time);
|
||||
const localTime = this.getSourceLocalTime(time);
|
||||
return (
|
||||
localTime >= this.params.trimStart - VISUAL_EPSILON &&
|
||||
localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
|
||||
localTime < this.params.trimStart + this.params.duration
|
||||
);
|
||||
}
|
||||
|
|
@ -35,15 +49,27 @@ export abstract class VisualNode<
|
|||
source,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
timelineTime,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
source: CanvasImageSource;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
timelineTime: number;
|
||||
}): void {
|
||||
renderer.context.save();
|
||||
|
||||
const { transform, opacity } = this.params;
|
||||
const animationLocalTime = this.getAnimationLocalTime(timelineTime);
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: this.params.transform,
|
||||
animations: this.params.animations,
|
||||
localTime: animationLocalTime,
|
||||
});
|
||||
const opacity = resolveOpacityAtTime({
|
||||
baseOpacity: this.params.opacity,
|
||||
animations: this.params.animations,
|
||||
localTime: animationLocalTime,
|
||||
});
|
||||
const containScale = Math.min(
|
||||
renderer.width / sourceWidth,
|
||||
renderer.height / sourceHeight,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export function buildScene(params: BuildSceneParams) {
|
|||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
}),
|
||||
|
|
@ -82,6 +83,7 @@ export function buildScene(params: BuildSceneParams) {
|
|||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
...(params.isPreview && {
|
||||
|
|
@ -112,6 +114,7 @@ export function buildScene(params: BuildSceneParams) {
|
|||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position.x",
|
||||
"transform.position.y",
|
||||
"transform.scale",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
|
||||
export type AnimationValueKind = "number" | "color" | "discrete";
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean;
|
||||
|
||||
export type NumberKeyframeInterpolation = "linear" | "hold";
|
||||
export type ColorKeyframeInterpolation = "linear" | "hold";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| NumberKeyframeInterpolation
|
||||
| ColorKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
interface BaseAnimationKeyframe<
|
||||
TValue extends AnimationValue,
|
||||
TInterpolation extends AnimationInterpolation,
|
||||
> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
value: TValue;
|
||||
interpolation: TInterpolation;
|
||||
}
|
||||
|
||||
export interface NumberKeyframe
|
||||
extends BaseAnimationKeyframe<number, NumberKeyframeInterpolation> {}
|
||||
|
||||
export interface ColorKeyframe
|
||||
extends BaseAnimationKeyframe<string, ColorKeyframeInterpolation> {}
|
||||
|
||||
export interface DiscreteKeyframe
|
||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
||||
|
||||
export type AnimationKeyframe = NumberKeyframe | ColorKeyframe | DiscreteKeyframe;
|
||||
|
||||
interface BaseAnimationChannel<
|
||||
TValueKind extends AnimationValueKind,
|
||||
TKeyframe extends AnimationKeyframe,
|
||||
> {
|
||||
valueKind: TValueKind;
|
||||
keyframes: TKeyframe[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationChannel
|
||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
||||
|
||||
export interface ColorAnimationChannel
|
||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
||||
|
||||
export interface DiscreteAnimationChannel
|
||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
||||
|
||||
export type AnimationChannel =
|
||||
| NumberAnimationChannel
|
||||
| ColorAnimationChannel
|
||||
| DiscreteAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { BlendMode, Transform } from "./rendering";
|
||||
import type { ElementAnimations } from "./animation";
|
||||
|
||||
export interface Bookmark {
|
||||
time: number;
|
||||
|
|
@ -80,6 +81,7 @@ interface BaseTimelineElement {
|
|||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
animations?: ElementAnimations;
|
||||
}
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
|
|
@ -136,6 +138,17 @@ export interface StickerElement extends BaseTimelineElement {
|
|||
blendMode?: BlendMode;
|
||||
}
|
||||
|
||||
export type VisualElement =
|
||||
| VideoElement
|
||||
| ImageElement
|
||||
| TextElement
|
||||
| StickerElement;
|
||||
|
||||
export type ElementUpdatePatch =
|
||||
| { transform: Transform }
|
||||
| { opacity: number }
|
||||
| { volume: number };
|
||||
|
||||
export type TimelineElement =
|
||||
| AudioElement
|
||||
| VideoElement
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ export function clamp({
|
|||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function isNearlyEqual({
|
||||
leftValue,
|
||||
rightValue,
|
||||
epsilon = 0.0001,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
epsilon?: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftValue - rightValue) <= epsilon;
|
||||
}
|
||||
|
||||
export function evaluateMathExpression({
|
||||
input,
|
||||
}: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue