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:
Maze Winther 2026-02-27 16:33:57 +01:00
parent 9b94f89def
commit 49426f19cd
55 changed files with 4139 additions and 312 deletions

View File

@ -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 ✓`
---

View File

@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"} />

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export const TIME_EPSILON_SECONDS = 1 / 1000;
export const MIN_TRANSFORM_SCALE = 0.01;

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from "./remove-keyframe";
export * from "./retime-keyframe";
export * from "./upsert-keyframe";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
source,
sourceWidth: width || renderer.width,
sourceHeight: height || renderer.height,
timelineTime: time,
});
}
}

View File

@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
source,
sourceWidth: width,
sourceHeight: height,
timelineTime: time,
});
}
}

View File

@ -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 &&

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}: {