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
@@ -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();
});
});
+39
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";
+365
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,
});
}
@@ -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,
};
}
+593
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,
});
}
@@ -0,0 +1,20 @@
import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
export function getNumberChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): NumberAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "number") {
return undefined;
}
return channel;
}
@@ -0,0 +1,230 @@
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
DiscreteValue,
} from "@/types/animation";
import type { TimelineElement } from "@/types/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import { isVisualElement } from "@/lib/timeline/element-utils";
interface NumericRange {
min?: number;
max?: number;
}
interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => number | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: number;
}) => TimelineElement;
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position.x": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value },
},
}
: element,
},
"transform.position.y": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value },
},
}
: element,
},
"transform.scale": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scale : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, scale: value } }
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, rotate: value } }
: element,
},
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element) ? { ...element, opacity: value } : element,
},
volume: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
supportsElement: ({ element }) => element.type === "audio",
getValue: ({ element }) =>
element.type === "audio" ? element.volume : null,
setValue: ({ element, value }) =>
element.type === "audio" ? { ...element, volume: value } : element,
},
};
export function isAnimationPropertyPath({
propertyPath,
}: {
propertyPath: string;
}): boolean {
return propertyPath in ANIMATION_PROPERTY_REGISTRY;
}
export function getAnimationPropertyDefinition({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationPropertyDefinition {
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
}
export function supportsAnimationProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.supportsElement({ element });
}
export function getElementBaseValueForProperty({
element,
propertyPath,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return null;
}
return definition.getValue({ element });
}
export function withElementBaseValueForProperty({
element,
propertyPath,
value,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): TimelineElement {
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
if (coercedValue === null || typeof coercedValue !== "number") {
return element;
}
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
}
export function getDefaultInterpolationForProperty({
propertyPath,
}: {
propertyPath: AnimationPropertyPath;
}): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.defaultInterpolation;
}
function clampNumericRange({
value,
numericRange,
}: {
value: number;
numericRange: NumericRange | undefined;
}): number {
if (!numericRange) {
return value;
}
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, value));
}
export function coerceAnimationValueForProperty({
propertyPath,
value,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return clampNumericRange({
value,
numericRange: propertyDefinition.numericRange,
});
}
if (propertyDefinition.valueKind === "color") {
return typeof value === "string" ? value : null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
return null;
}
+111
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,
});
}
@@ -0,0 +1,433 @@
import { afterEach, describe, expect, test } from "bun:test";
import { EditorCore } from "@/core";
import type { TimelineTrack, VideoElement } from "@/types/timeline";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
type MockEditor = {
timeline: {
getTracks: () => TimelineTrack[];
updateTracks: (tracks: TimelineTrack[]) => void;
};
selection: {
getSelectedElements: () => { trackId: string; elementId: string }[];
setSelectedElements: ({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}) => void;
};
};
const originalGetInstance = EditorCore.getInstance;
function mockEditorCore({ editor }: { editor: MockEditor }): void {
(
EditorCore as unknown as {
getInstance: () => EditorCore;
}
).getInstance = () => editor as unknown as EditorCore;
}
function restoreEditorCore(): void {
(
EditorCore as unknown as {
getInstance: typeof EditorCore.getInstance;
}
).getInstance = originalGetInstance;
}
function buildVideoElement(): VideoElement {
return {
id: "element-1",
name: "Clip",
type: "video",
mediaId: "media-1",
duration: 8,
startTime: 1,
trimStart: 0,
trimEnd: 0,
transform: DEFAULT_TRANSFORM,
opacity: 1,
animations: {
channels: {
"transform.scale": {
valueKind: "number",
keyframes: [
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
],
},
},
},
};
}
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
return [
{
id: "track-1",
name: "Main",
type: "video",
elements: [element],
isMain: true,
muted: false,
hidden: false,
},
];
}
afterEach(() => {
restoreEditorCore();
});
describe("keyframe-aware timeline commands", () => {
test("duration updates clamp keyframes beyond the new duration", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpdateElementDurationCommand({
trackId: "track-1",
elementId: "element-1",
duration: 3,
}).execute();
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
expect(
updatedElement?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3]);
});
test("trim updates clamp keyframes when duration is changed", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpdateElementTrimCommand({
elementId: "element-1",
trimStart: 0,
trimEnd: 0,
startTime: 1,
duration: 2,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
expect(updatedElement.duration).toBe(2);
expect(
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0]);
});
test("split rebases right-side keyframes and keeps continuity at split time", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new SplitElementsCommand({
elements: [{ trackId: "track-1", elementId: "element-1" }],
splitTime: 5,
}).execute();
const leftElement = updatedTracks[0].elements.find(
(element) => element.id === "element-1",
) as VideoElement;
const rightElement = updatedTracks[0].elements.find(
(element) => element.id !== "element-1",
) as VideoElement;
expect(
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3, 4]);
expect(
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 2]);
expect(
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
).toBeCloseTo(5 / 3, 4);
});
test("duplicate creates independent keyframe ids for copied element", () => {
const tracks = buildTracks({ element: buildVideoElement() });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
setSelectedElements: () => {},
},
},
});
new DuplicateElementsCommand({
elements: [{ trackId: "track-1", elementId: "element-1" }],
}).execute();
const originalElement = updatedTracks.find(
(track) => track.id === "track-1",
)?.elements[0] as VideoElement;
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
expect(duplicatedElement).toBeDefined();
expect(
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
(keyframe) => keyframe.time,
),
).toEqual([0, 3, 6]);
expect(
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
).not.toBe(
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
);
});
});
describe("generic keyframe commands", () => {
test("upsert adds or updates keyframe at target time", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpsertKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
time: 2,
value: 2.5,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframes =
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
expect(atTwo?.value).toBe(2.5);
});
test("remove deletes keyframe by id", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RemoveKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "kf-b",
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframes =
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
expect(keyframes).toHaveLength(2);
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
expect(updatedElement.transform.scale).toBe(1);
});
test("remove persists value to base property when channel becomes empty", () => {
const element: VideoElement = {
...buildVideoElement(),
transform: {
...DEFAULT_TRANSFORM,
scale: 1,
},
animations: {
channels: {
"transform.scale": {
valueKind: "number",
keyframes: [
{
id: "only-scale",
time: 2,
value: 1.43,
interpolation: "linear",
},
],
},
},
},
};
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RemoveKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "only-scale",
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
expect(updatedElement.transform.scale).toBe(1.43);
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
});
test("upsert supports non-transform paths like opacity", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new UpsertKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "opacity",
time: 1,
value: 0.35,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const opacityChannel = updatedElement.animations?.channels.opacity;
expect(opacityChannel?.valueKind).toBe("number");
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
});
test("retime moves keyframe to new time", () => {
const element = buildVideoElement();
const tracks = buildTracks({ element });
let updatedTracks: TimelineTrack[] = tracks;
mockEditorCore({
editor: {
timeline: {
getTracks: () => tracks,
updateTracks: (nextTracks) => {
updatedTracks = nextTracks;
},
},
selection: {
getSelectedElements: () => [],
setSelectedElements: () => {},
},
},
});
new RetimeKeyframeCommand({
trackId: "track-1",
elementId: "element-1",
propertyPath: "transform.scale",
keyframeId: "kf-b",
nextTime: 4,
}).execute();
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
(existingKeyframe) => existingKeyframe.id === "kf-b",
);
expect(keyframe?.time).toBe(4);
});
});
@@ -13,6 +13,7 @@ import {
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -176,6 +177,10 @@ function buildPastedElements({
...item.element,
id: newElementId,
startTime,
animations: cloneAnimations({
animations: item.element.animations,
shouldRegenerateKeyframeIds: true,
}),
} as TimelineElement);
}
@@ -5,9 +5,15 @@ import { isMainTrack } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
constructor(private elements: { trackId: string; elementId: string }[]) {
constructor({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}) {
super();
this.elements = elements;
}
execute(): void {
@@ -17,7 +23,7 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
const hasElementsToDelete = this.elements.some(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToDelete) {
@@ -29,7 +35,9 @@ export class DeleteElementsCommand extends Command {
elements: track.elements.filter(
(element) =>
!this.elements.some(
(el) => el.trackId === track.id && el.elementId === element.id,
(elementEntry) =>
elementEntry.trackId === track.id &&
elementEntry.elementId === element.id,
),
),
} as typeof track;
@@ -6,6 +6,7 @@ import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
@@ -114,5 +115,14 @@ function buildDuplicateElement({
id: string;
startTime: number;
}): TimelineElement {
return { ...element, id, name: `${element.name} (copy)`, startTime };
return {
...element,
id,
name: `${element.name} (copy)`,
startTime,
animations: cloneAnimations({
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}
@@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
@@ -0,0 +1,3 @@
export * from "./remove-keyframe";
export * from "./retime-keyframe";
export * from "./upsert-keyframe";
@@ -0,0 +1,141 @@
import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
getElementBaseValueForProperty,
removeElementKeyframe,
supportsAnimationProperty,
withElementBaseValueForProperty,
} from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
import type { AnimationPropertyPath } from "@/types/animation";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
function sampleValueBeforeRemoval({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}): number | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
});
const keyframe = channel?.keyframes.find(
(candidate) => candidate.id === keyframeId,
);
if (!channel || !keyframe) {
return null;
}
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
if (baseValue === null || typeof baseValue !== "number") {
return null;
}
const sampled = getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
element,
propertyPath,
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}): TimelineElement {
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
keyframeId,
});
const nextAnimations = removeElementKeyframe({
animations: element.animations,
propertyPath,
keyframeId,
});
const isChannelNowEmpty =
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
? withElementBaseValueForProperty({
element,
propertyPath,
value: valueBefore,
})
: element;
return { ...baseElement, animations: nextAnimations };
}
export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly keyframeId: string;
constructor({
trackId,
elementId,
propertyPath,
keyframeId,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) =>
removeKeyframeAndPersist({
element,
propertyPath: this.propertyPath,
keyframeId: this.keyframeId,
}),
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
@@ -0,0 +1,75 @@
import { EditorCore } from "@/core";
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
import type { AnimationPropertyPath } from "@/types/animation";
import type { TimelineTrack } from "@/types/timeline";
export class RetimeKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly keyframeId: string;
private readonly nextTime: number;
constructor({
trackId,
elementId,
propertyPath,
keyframeId,
nextTime,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
keyframeId: string;
nextTime: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.keyframeId = keyframeId;
this.nextTime = nextTime;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
return {
...element,
animations: retimeElementKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
keyframeId: this.keyframeId,
time: boundedTime,
}),
};
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
@@ -0,0 +1,89 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/types/animation";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
constructor({
trackId,
elementId,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.propertyPath = propertyPath;
this.time = time;
this.value = value;
this.interpolation = interpolation;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
animations: upsertElementKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
}),
};
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
@@ -14,15 +14,31 @@ import {
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly sourceTrackId: string;
private readonly targetTrackId: string;
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
constructor(
private sourceTrackId: string,
private targetTrackId: string,
private elementId: string,
private newStartTime: number,
private createTrack?: { type: TrackType; index: number },
) {
constructor({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}) {
super();
this.sourceTrackId = sourceTrackId;
this.targetTrackId = targetTrackId;
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
}
execute(): void {
@@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
(t) => t.id === this.sourceTrackId,
(track) => track.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
(el) => el.id === this.elementId,
(trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
return;
}
let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
@@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
excludeElementId: this.elementId,
});
// keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
@@ -85,8 +102,8 @@ export class MoveElementCommand extends Command {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.map((el) =>
el.id === this.elementId ? movedElement : el,
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
};
}
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter((el) => el.id !== this.elementId),
elements: track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
),
};
}
@@ -2,18 +2,29 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { splitAnimationsAtTime } from "@/lib/animation";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
constructor(
private elements: { trackId: string; elementId: string }[],
private splitTime: number,
private retainSide: "both" | "left" | "right" = "both",
) {
constructor({
elements,
splitTime,
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}) {
super();
this.elements = elements;
this.splitTime = splitTime;
this.retainSide = retainSide;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -28,7 +39,7 @@ export class SplitElementsCommand extends Command {
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToSplit.length === 0) {
@@ -39,7 +50,7 @@ export class SplitElementsCommand extends Command {
...track,
elements: track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
(el) => el.elementId === element.id,
(elementEntry) => elementEntry.elementId === element.id,
);
if (!shouldSplit) {
@@ -59,6 +70,11 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
shouldIncludeSplitBoundary: true,
});
if (this.retainSide === "left") {
return [
@@ -67,6 +83,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
animations: leftAnimations,
},
];
}
@@ -85,6 +102,7 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
},
];
}
@@ -102,6 +120,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
animations: leftAnimations,
},
{
...element,
@@ -110,6 +129,7 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
},
];
}),
@@ -1,28 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly duration: number;
constructor(
private trackId: string,
private elementId: string,
private duration: number,
) {
constructor({
trackId,
elementId,
duration,
}: {
trackId: string;
elementId: string;
duration: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.duration = duration;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, duration: this.duration } : el,
const updatedTracks = this.savedState.map((track) => {
if (track.id !== this.trackId) return track;
const newElements = track.elements.map((element) =>
element.id === this.elementId
? {
...element,
duration: this.duration,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: this.duration,
}),
}
: element,
);
return { ...t, elements: newElements } as typeof t;
return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
private readonly startTime: number;
constructor(
private elements: { trackId: string; elementId: string }[],
private startTime: number,
) {
constructor({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}) {
super();
this.elements = elements;
this.startTime = startTime;
}
execute(): void {
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
(el) => el.trackId === track.id,
(elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToUpdate) {
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
(el) => el.elementId === element.id && el.trackId === track.id,
(elementEntry) =>
elementEntry.elementId === element.id &&
elementEntry.trackId === track.id,
);
if (!shouldUpdate) {
return element;
@@ -1,18 +1,35 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elementId: string;
private readonly trimStart: number;
private readonly trimEnd: number;
private readonly startTime: number | undefined;
private readonly duration: number | undefined;
constructor(
private elementId: string,
private trimStart: number,
private trimEnd: number,
private startTime?: number,
private duration?: number,
) {
constructor({
elementId,
trimStart,
trimEnd,
startTime,
duration,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
startTime?: number;
duration?: number;
}) {
super();
this.elementId = elementId;
this.trimStart = trimStart;
this.trimEnd = trimEnd;
this.startTime = startTime;
this.duration = duration;
}
execute(): void {
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
return element;
}
const nextDuration = this.duration ?? element.duration;
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
duration: this.duration ?? element.duration,
duration: nextDuration,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: nextDuration,
}),
};
});
return { ...track, elements: newElements } as typeof track;
@@ -1,28 +1,38 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
export class UpdateElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly updates: Partial<TimelineElement>;
constructor(
private trackId: string,
private elementId: string,
private updates: Partial<Record<string, unknown>>,
) {
constructor({
trackId,
elementId,
updates,
}: {
trackId: string;
elementId: string;
updates: Partial<TimelineElement>;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.updates = updates;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((t) => {
if (t.id !== this.trackId) return t;
const newElements = t.elements.map((el) =>
el.id === this.elementId ? { ...el, ...this.updates } : el,
);
return { ...t, elements: newElements } as typeof t;
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
update: (element) => ({ ...element, ...this.updates }),
});
editor.timeline.updateTracks(updatedTracks);
+39 -15
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({
+3 -3
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";
}
+2
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,
+79
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;
}
+22 -5
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,
});
}
}
}
}
@@ -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;
});
}