mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -0,0 +1,313 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import {
|
||||
clampAnimationsToDuration,
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
getChannelValueAtTime,
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
splitAnimationsAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
describe("transform keyframe evaluation", () => {
|
||||
test("uses fallback value when channel is missing", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: undefined,
|
||||
time: 1,
|
||||
fallbackValue: 42,
|
||||
});
|
||||
expect(value).toBe(42);
|
||||
});
|
||||
|
||||
test("returns boundary value when time is within epsilon of first/last keyframe", () => {
|
||||
const channel = {
|
||||
valueKind: "number" as const,
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "linear" as const },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" as const },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: 0.0008,
|
||||
fallbackValue: 0,
|
||||
}),
|
||||
).toBe(10);
|
||||
expect(
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: 1.9992,
|
||||
fallbackValue: 0,
|
||||
}),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
test("interpolates linear channels", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 1,
|
||||
fallbackValue: 0,
|
||||
});
|
||||
expect(value).toBe(20);
|
||||
});
|
||||
|
||||
test("clamps local time to [0, duration]", () => {
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 2,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 12,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(4);
|
||||
expect(
|
||||
getElementLocalTime({
|
||||
timelineTime: 7,
|
||||
elementStartTime: 5,
|
||||
elementDuration: 4,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("uses hold interpolation from the left keyframe", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 10, interpolation: "hold" },
|
||||
{ id: "b", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 1,
|
||||
fallbackValue: 0,
|
||||
});
|
||||
expect(value).toBe(10);
|
||||
});
|
||||
|
||||
test("resolves transform by mixing animated and fallback properties", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 0, interpolation: "linear" },
|
||||
{ id: "b", time: 4, value: 80, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: {
|
||||
position: { x: 10, y: 20 },
|
||||
scale: 1,
|
||||
rotate: 15,
|
||||
},
|
||||
animations,
|
||||
localTime: 2,
|
||||
});
|
||||
expect(resolvedTransform).toEqual({
|
||||
position: { x: 40, y: 20 },
|
||||
scale: 2,
|
||||
rotate: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("transform keyframe mutation utilities", () => {
|
||||
test("splits channels and rebases right side times", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 2, interpolation: "linear" },
|
||||
{ id: "c", time: 6, value: 4, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations,
|
||||
splitTime: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
leftAnimations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2, 4]);
|
||||
expect(
|
||||
rightAnimations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps channels to updated element duration", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: 0, interpolation: "linear" },
|
||||
{ id: "b", time: 2, value: 20, interpolation: "linear" },
|
||||
{ id: "c", time: 5, value: 50, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const clampedAnimations = clampAnimationsToDuration({
|
||||
animations,
|
||||
duration: 2,
|
||||
});
|
||||
expect(
|
||||
clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("typed channel interpolation", () => {
|
||||
test("interpolates color channels from hex keyframes", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "color",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: "#000000", interpolation: "linear" },
|
||||
{ id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
time: 0.5,
|
||||
fallbackValue: "#000000",
|
||||
});
|
||||
expect(typeof value).toBe("string");
|
||||
expect(value).toContain("rgba(");
|
||||
});
|
||||
|
||||
test("uses hold behavior for discrete channels", () => {
|
||||
const value = getChannelValueAtTime({
|
||||
channel: {
|
||||
valueKind: "discrete",
|
||||
keyframes: [
|
||||
{ id: "a", time: 0, value: "normal", interpolation: "hold" },
|
||||
{ id: "b", time: 2, value: "multiply", interpolation: "hold" },
|
||||
],
|
||||
},
|
||||
time: 1.2,
|
||||
fallbackValue: "normal",
|
||||
});
|
||||
expect(value).toBe("normal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyframe query helpers", () => {
|
||||
test("getElementKeyframes returns flat list of all keyframes across channels", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const keyframes = getElementKeyframes({ animations });
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
|
||||
"opacity",
|
||||
"transform.position.x",
|
||||
]);
|
||||
});
|
||||
|
||||
test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
|
||||
expect(getElementKeyframes({ animations: undefined })).toEqual([]);
|
||||
expect(
|
||||
getElementKeyframes({
|
||||
animations: {
|
||||
channels: { opacity: { valueKind: "number", keyframes: [] } },
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("hasKeyframesForPath returns true only for paths with keyframes", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
|
||||
},
|
||||
"transform.position.y": {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
|
||||
const animations: ElementAnimations = {
|
||||
channels: {
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "r-1", time: 1, value: 15, interpolation: "linear" },
|
||||
{ id: "r-2", time: 2, value: 30, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const found = getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
time: 1.0008,
|
||||
});
|
||||
expect(found?.id).toBe("r-1");
|
||||
expect(found?.value).toBe(15);
|
||||
expect(found?.propertyPath).toBe("transform.rotate");
|
||||
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
time: 1.01,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
getChannelValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
upsertElementKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
resolveVolumeAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
@@ -0,0 +1,365 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationValue,
|
||||
ColorAnimationChannel,
|
||||
DiscreteValue,
|
||||
DiscreteAnimationChannel,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): number {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
function isWithinTimePair({
|
||||
time,
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
time: number;
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= leftTime - TIME_EPSILON_SECONDS &&
|
||||
time <= rightTime + TIME_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function parseHexChannel({ hex }: { hex: string }): number | null {
|
||||
const value = Number.parseInt(hex, 16);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function parseHexColor({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): { red: number; green: number; blue: number; alpha: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawHex = trimmed.slice(1);
|
||||
if (rawHex.length === 3 || rawHex.length === 4) {
|
||||
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
|
||||
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
|
||||
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
|
||||
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
|
||||
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
|
||||
if (
|
||||
red === null ||
|
||||
green === null ||
|
||||
blue === null ||
|
||||
alpha === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
if (rawHex.length === 6 || rawHex.length === 8) {
|
||||
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
|
||||
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
|
||||
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
|
||||
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
|
||||
const alpha = parseHexChannel({ hex: alphaHex });
|
||||
if (
|
||||
red === null ||
|
||||
green === null ||
|
||||
blue === null ||
|
||||
alpha === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRgbaColor({
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha,
|
||||
}: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}): string {
|
||||
const roundedRed = Math.round(red);
|
||||
const roundedGreen = Math.round(green);
|
||||
const roundedBlue = Math.round(blue);
|
||||
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
|
||||
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
leftValue,
|
||||
rightValue,
|
||||
progress,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
progress: number;
|
||||
}): number {
|
||||
return leftValue + (rightValue - leftValue) * progress;
|
||||
}
|
||||
|
||||
function interpolateColor({
|
||||
leftColor,
|
||||
rightColor,
|
||||
progress,
|
||||
}: {
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
progress: number;
|
||||
}): string {
|
||||
const leftParsed = parseHexColor({ color: leftColor });
|
||||
const rightParsed = parseHexColor({ color: rightColor });
|
||||
if (!leftParsed || !rightParsed) {
|
||||
return progress >= 1 ? rightColor : leftColor;
|
||||
}
|
||||
|
||||
return formatRgbaColor({
|
||||
red: lerpNumber({
|
||||
leftValue: leftParsed.red,
|
||||
rightValue: rightParsed.red,
|
||||
progress,
|
||||
}),
|
||||
green: lerpNumber({
|
||||
leftValue: leftParsed.green,
|
||||
rightValue: rightParsed.green,
|
||||
progress,
|
||||
}),
|
||||
blue: lerpNumber({
|
||||
leftValue: leftParsed.blue,
|
||||
rightValue: rightParsed.blue,
|
||||
progress,
|
||||
}),
|
||||
alpha: lerpNumber({
|
||||
leftValue: leftParsed.alpha,
|
||||
rightValue: rightParsed.alpha,
|
||||
progress,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
channel,
|
||||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
return {
|
||||
...channel,
|
||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
}),
|
||||
),
|
||||
} as TChannel;
|
||||
}
|
||||
|
||||
function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
|
||||
keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue,
|
||||
}: {
|
||||
keyframes: TKeyframe[] | undefined;
|
||||
time: number;
|
||||
fallbackValue: TValue;
|
||||
getInterpolatedValue: ({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
}: {
|
||||
leftKeyframe: TKeyframe;
|
||||
rightKeyframe: TKeyframe;
|
||||
progress: number;
|
||||
}) => TValue;
|
||||
}): TValue {
|
||||
if (!keyframes || keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const firstKeyframe = keyframes[0];
|
||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
||||
if (!firstKeyframe || !lastKeyframe) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
||||
return firstKeyframe.value;
|
||||
}
|
||||
|
||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
||||
return lastKeyframe.value;
|
||||
}
|
||||
|
||||
for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
|
||||
const leftKeyframe = keyframes[keyframeIndex];
|
||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
||||
const isBetweenPair = isWithinTimePair({
|
||||
time,
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
});
|
||||
if (!isBetweenPair) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = rightKeyframe.time - leftKeyframe.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKeyframe.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
value: (time - leftKeyframe.time) / span,
|
||||
});
|
||||
|
||||
return getInterpolatedValue({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKeyframe.value;
|
||||
}
|
||||
|
||||
export function getNumberChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: NumberAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return lerpNumber({
|
||||
leftValue: leftKeyframe.value,
|
||||
rightValue: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ColorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: string;
|
||||
}): string {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return interpolateColor({
|
||||
leftColor: leftKeyframe.value,
|
||||
rightColor: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscreteValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof fallbackValue !== "number") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getNumberChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof fallbackValue !== "string") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getDiscreteValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/types/animation";
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keyframes.map((keyframe) => ({
|
||||
propertyPath: propertyPath as AnimationPropertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
return Boolean(channel && channel.keyframes.length > 0);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.keyframes.length === 0) return null;
|
||||
const keyframe = channel.keyframes.find(
|
||||
(kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS,
|
||||
);
|
||||
if (!keyframe) return null;
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationKeyframe,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
ColorAnimationChannel,
|
||||
DiscreteAnimationChannel,
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getDefaultInterpolationForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
isAnimationPropertyPath,
|
||||
} from "./property-registry";
|
||||
|
||||
function isNearlySameTime({
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
|
||||
}
|
||||
|
||||
function toAnimation({
|
||||
channelEntries,
|
||||
}: {
|
||||
channelEntries: Array<[string, AnimationChannel]>;
|
||||
}): ElementAnimations | undefined {
|
||||
if (channelEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
channels: Object.fromEntries(channelEntries),
|
||||
};
|
||||
}
|
||||
|
||||
function toChannel({
|
||||
keyframes,
|
||||
valueKind,
|
||||
}: {
|
||||
keyframes: AnimationKeyframe[];
|
||||
valueKind: AnimationValueKind;
|
||||
}): AnimationChannel {
|
||||
return normalizeChannel({
|
||||
channel: {
|
||||
valueKind,
|
||||
keyframes,
|
||||
} as AnimationChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
}): AnimationChannel | undefined {
|
||||
return animations?.channels[propertyPath];
|
||||
}
|
||||
|
||||
function getInterpolationForChannel({
|
||||
channel,
|
||||
interpolation,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
interpolation: AnimationInterpolation | undefined;
|
||||
}): AnimationInterpolation {
|
||||
if (channel.valueKind === "discrete") {
|
||||
return "hold";
|
||||
}
|
||||
|
||||
if (interpolation === "linear" || interpolation === "hold") {
|
||||
return interpolation;
|
||||
}
|
||||
|
||||
return "linear";
|
||||
}
|
||||
|
||||
function buildKeyframe({
|
||||
channel,
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}): AnimationKeyframe {
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof value !== "number") {
|
||||
throw new Error("Number channel keyframes require numeric values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation === "hold" ? "hold" : "linear",
|
||||
};
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Color channel keyframes require string values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation === "hold" ? "hold" : "linear",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value !== "string" && typeof value !== "boolean") {
|
||||
throw new Error("Discrete channel keyframes require boolean or string values");
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
interpolation: "hold",
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyChannel({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationChannel {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
|
||||
}
|
||||
|
||||
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentKeyframes = channel.keyframes;
|
||||
const nextKeyframes = [...currentKeyframes];
|
||||
const nextInterpolation = getInterpolationForChannel({
|
||||
channel,
|
||||
interpolation,
|
||||
});
|
||||
if (keyframeId) {
|
||||
const keyframeByIdIndex = nextKeyframes.findIndex(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
);
|
||||
if (keyframeByIdIndex >= 0) {
|
||||
nextKeyframes[keyframeByIdIndex] = buildKeyframe({
|
||||
channel,
|
||||
id: nextKeyframes[keyframeByIdIndex].id,
|
||||
time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
});
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
|
||||
);
|
||||
if (keyframeAtTimeIndex >= 0) {
|
||||
nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
|
||||
channel,
|
||||
id: nextKeyframes[keyframeAtTimeIndex].id,
|
||||
time: nextKeyframes[keyframeAtTimeIndex].time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
});
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
nextKeyframes.push(
|
||||
buildKeyframe({
|
||||
channel,
|
||||
id: keyframeId ?? generateUUID(),
|
||||
time,
|
||||
value,
|
||||
interpolation: nextInterpolation,
|
||||
}),
|
||||
);
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextKeyframes = channel.keyframes.filter(
|
||||
(keyframe) => keyframe.id !== keyframeId,
|
||||
);
|
||||
if (nextKeyframes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function retimeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
time,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
}): AnimationChannel | undefined {
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyframeByIdIndex = channel.keyframes.findIndex(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
);
|
||||
if (keyframeByIdIndex < 0) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
const nextKeyframes = [...channel.keyframes];
|
||||
nextKeyframes[keyframeByIdIndex] = {
|
||||
...nextKeyframes[keyframeByIdIndex],
|
||||
time,
|
||||
};
|
||||
|
||||
return toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
channel: AnimationChannel | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const currentChannels = animations?.channels ?? {};
|
||||
|
||||
const nextChannelEntries = Object.entries(currentChannels)
|
||||
.filter(([path]) => path !== propertyPath)
|
||||
.filter(([, ch]) => ch && ch.keyframes.length > 0)
|
||||
.map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
|
||||
|
||||
if (channel && channel.keyframes.length > 0) {
|
||||
nextChannelEntries.push([propertyPath, channel]);
|
||||
}
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: nextChannelEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneAnimations({
|
||||
animations,
|
||||
shouldRegenerateKeyframeIds = false,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
shouldRegenerateKeyframeIds?: boolean;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clonedEntries = Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clonedKeyframes = channel.keyframes.map((keyframe) => ({
|
||||
...keyframe,
|
||||
id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
|
||||
}));
|
||||
|
||||
return [
|
||||
[
|
||||
propertyPath,
|
||||
toChannel({
|
||||
keyframes: clonedKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
}),
|
||||
] as [string, AnimationChannel],
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: clonedEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function clampAnimationsToDuration({
|
||||
animations,
|
||||
duration,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
duration: number;
|
||||
}): ElementAnimations | undefined {
|
||||
if (!animations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clampedEntries = Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextKeyframes = channel.keyframes.filter(
|
||||
(keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
|
||||
);
|
||||
if (nextKeyframes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
propertyPath,
|
||||
toChannel({
|
||||
keyframes: nextKeyframes,
|
||||
valueKind: channel.valueKind,
|
||||
}),
|
||||
] as [string, AnimationChannel],
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
return toAnimation({
|
||||
channelEntries: clampedEntries,
|
||||
});
|
||||
}
|
||||
|
||||
export function splitAnimationsAtTime({
|
||||
animations,
|
||||
splitTime,
|
||||
shouldIncludeSplitBoundary = true,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
splitTime: number;
|
||||
shouldIncludeSplitBoundary?: boolean;
|
||||
}): {
|
||||
leftAnimations: ElementAnimations | undefined;
|
||||
rightAnimations: ElementAnimations | undefined;
|
||||
} {
|
||||
if (!animations) {
|
||||
return { leftAnimations: undefined, rightAnimations: undefined };
|
||||
}
|
||||
|
||||
const leftChannels: Array<[string, AnimationChannel]> = [];
|
||||
const rightChannels: Array<[string, AnimationChannel]> = [];
|
||||
|
||||
for (const [propertyPath, channel] of Object.entries(animations.channels)) {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({ channel });
|
||||
let leftKeyframes = normalizedChannel.keyframes.filter(
|
||||
(keyframe) => keyframe.time <= splitTime,
|
||||
);
|
||||
let rightKeyframes = normalizedChannel.keyframes
|
||||
.filter((keyframe) => keyframe.time >= splitTime)
|
||||
.map((keyframe) => ({
|
||||
...keyframe,
|
||||
time: keyframe.time - splitTime,
|
||||
}));
|
||||
|
||||
const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
|
||||
);
|
||||
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
|
||||
);
|
||||
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
|
||||
const boundaryValue = getChannelValueAtTime({
|
||||
channel: normalizedChannel,
|
||||
time: splitTime,
|
||||
fallbackValue: normalizedChannel.keyframes[0].value,
|
||||
});
|
||||
const knownPropertyPath = isAnimationPropertyPath({ propertyPath })
|
||||
? (propertyPath as AnimationPropertyPath)
|
||||
: null;
|
||||
const boundaryInterpolation = knownPropertyPath
|
||||
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
|
||||
: normalizedChannel.valueKind === "discrete"
|
||||
? "hold"
|
||||
: "linear";
|
||||
|
||||
if (!hasBoundaryOnLeft) {
|
||||
leftKeyframes = [
|
||||
...leftKeyframes,
|
||||
buildKeyframe({
|
||||
channel: normalizedChannel,
|
||||
id: generateUUID(),
|
||||
time: splitTime,
|
||||
value: boundaryValue,
|
||||
interpolation: boundaryInterpolation,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (!hasBoundaryOnRight) {
|
||||
rightKeyframes = [
|
||||
buildKeyframe({
|
||||
channel: normalizedChannel,
|
||||
id: generateUUID(),
|
||||
time: 0,
|
||||
value: boundaryValue,
|
||||
interpolation: boundaryInterpolation,
|
||||
}),
|
||||
...rightKeyframes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const leftChannel = leftKeyframes.length
|
||||
? toChannel({
|
||||
keyframes: leftKeyframes,
|
||||
valueKind: normalizedChannel.valueKind,
|
||||
})
|
||||
: undefined;
|
||||
const rightChannel = rightKeyframes.length
|
||||
? toChannel({
|
||||
keyframes: rightKeyframes,
|
||||
valueKind: normalizedChannel.valueKind,
|
||||
})
|
||||
: undefined;
|
||||
if (leftChannel) {
|
||||
leftChannels.push([propertyPath, leftChannel]);
|
||||
}
|
||||
if (rightChannel) {
|
||||
rightChannels.push([propertyPath, rightChannel]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
leftAnimations: toAnimation({ channelEntries: leftChannels }),
|
||||
rightAnimations: toAnimation({ channelEntries: rightChannels }),
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const targetChannel =
|
||||
channel && channel.valueKind === propertyDefinition.valueKind
|
||||
? channel
|
||||
: createEmptyChannel({ propertyPath });
|
||||
const updatedChannel = upsertKeyframe({
|
||||
channel: targetChannel,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation: interpolation ?? defaultInterpolation,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
return (
|
||||
setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
}) ?? { channels: {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const updatedChannel = removeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
});
|
||||
return setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
});
|
||||
}
|
||||
|
||||
export function retimeElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
time: number;
|
||||
}): ElementAnimations | undefined {
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const updatedChannel = retimeKeyframe({
|
||||
channel,
|
||||
keyframeId,
|
||||
time,
|
||||
});
|
||||
return setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: updatedChannel,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
|
||||
export function getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): NumberAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
DiscreteValue,
|
||||
} from "@/types/animation";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface NumericRange {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface AnimationPropertyDefinition {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericRange;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => number | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: number;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position.x": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.x : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.position.y": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.y : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scale : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, scale: value } }
|
||||
: element,
|
||||
},
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, rotate: value } }
|
||||
: element,
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element) ? { ...element, opacity: value } : element,
|
||||
},
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1 },
|
||||
supportsElement: ({ element }) => element.type === "audio",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "audio" ? element.volume : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "audio" ? { ...element, volume: value } : element,
|
||||
},
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): boolean {
|
||||
return propertyPath in ANIMATION_PROPERTY_REGISTRY;
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
||||
if (coercedValue === null || typeof coercedValue !== "number") {
|
||||
return element;
|
||||
}
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
function clampNumericRange({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericRange | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, value));
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clampNumericRange({
|
||||
value,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
});
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "boolean") {
|
||||
return value as DiscreteValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import { getNumberChannelValueAtTime } from "./interpolation";
|
||||
import { getNumberChannelForPath } from "./number-channel";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position.x",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position.y",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scale: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scale",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scale,
|
||||
}),
|
||||
rotate: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVolumeAtTime({
|
||||
baseVolume,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseVolume: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "volume",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseVolume,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, VideoElement } from "@/types/timeline";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
|
||||
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
|
||||
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
|
||||
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
|
||||
type MockEditor = {
|
||||
timeline: {
|
||||
getTracks: () => TimelineTrack[];
|
||||
updateTracks: (tracks: TimelineTrack[]) => void;
|
||||
};
|
||||
selection: {
|
||||
getSelectedElements: () => { trackId: string; elementId: string }[];
|
||||
setSelectedElements: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const originalGetInstance = EditorCore.getInstance;
|
||||
|
||||
function mockEditorCore({ editor }: { editor: MockEditor }): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: () => EditorCore;
|
||||
}
|
||||
).getInstance = () => editor as unknown as EditorCore;
|
||||
}
|
||||
|
||||
function restoreEditorCore(): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: typeof EditorCore.getInstance;
|
||||
}
|
||||
).getInstance = originalGetInstance;
|
||||
}
|
||||
|
||||
function buildVideoElement(): VideoElement {
|
||||
return {
|
||||
id: "element-1",
|
||||
name: "Clip",
|
||||
type: "video",
|
||||
mediaId: "media-1",
|
||||
duration: 8,
|
||||
startTime: 1,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: DEFAULT_TRANSFORM,
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
|
||||
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
|
||||
return [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Main",
|
||||
type: "video",
|
||||
elements: [element],
|
||||
isMain: true,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreEditorCore();
|
||||
});
|
||||
|
||||
describe("keyframe-aware timeline commands", () => {
|
||||
test("duration updates clamp keyframes beyond the new duration", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementDurationCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
duration: 3,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
|
||||
expect(
|
||||
updatedElement?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
test("trim updates clamp keyframes when duration is changed", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementTrimCommand({
|
||||
elementId: "element-1",
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
startTime: 1,
|
||||
duration: 2,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.duration).toBe(2);
|
||||
expect(
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
test("split rebases right-side keyframes and keeps continuity at split time", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new SplitElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
splitTime: 5,
|
||||
}).execute();
|
||||
|
||||
const leftElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id === "element-1",
|
||||
) as VideoElement;
|
||||
const rightElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id !== "element-1",
|
||||
) as VideoElement;
|
||||
|
||||
expect(
|
||||
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 4]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBeCloseTo(5 / 3, 4);
|
||||
});
|
||||
|
||||
test("duplicate creates independent keyframe ids for copied element", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new DuplicateElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
}).execute();
|
||||
|
||||
const originalElement = updatedTracks.find(
|
||||
(track) => track.id === "track-1",
|
||||
)?.elements[0] as VideoElement;
|
||||
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
|
||||
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
|
||||
|
||||
expect(duplicatedElement).toBeDefined();
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 6]);
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
).not.toBe(
|
||||
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic keyframe commands", () => {
|
||||
test("upsert adds or updates keyframe at target time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
time: 2,
|
||||
value: 2.5,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
|
||||
expect(atTwo?.value).toBe(2.5);
|
||||
});
|
||||
|
||||
test("remove deletes keyframe by id", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
|
||||
expect(updatedElement.transform.scale).toBe(1);
|
||||
});
|
||||
|
||||
test("remove persists value to base property when channel becomes empty", () => {
|
||||
const element: VideoElement = {
|
||||
...buildVideoElement(),
|
||||
transform: {
|
||||
...DEFAULT_TRANSFORM,
|
||||
scale: 1,
|
||||
},
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "only-scale",
|
||||
time: 2,
|
||||
value: 1.43,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "only-scale",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.transform.scale).toBe(1.43);
|
||||
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upsert supports non-transform paths like opacity", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "opacity",
|
||||
time: 1,
|
||||
value: 0.35,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const opacityChannel = updatedElement.animations?.channels.opacity;
|
||||
expect(opacityChannel?.valueKind).toBe("number");
|
||||
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
|
||||
});
|
||||
|
||||
test("retime moves keyframe to new time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
nextTime: 4,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
|
||||
(existingKeyframe) => existingKeyframe.id === "kf-b",
|
||||
);
|
||||
expect(keyframe?.time).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -176,6 +177,10 @@ function buildPastedElements({
|
||||
...item.element,
|
||||
id: newElementId,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: item.element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
} as TimelineElement);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,15 @@ import { isMainTrack } from "@/lib/timeline";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
|
||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
||||
constructor({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -17,7 +23,7 @@ export class DeleteElementsCommand extends Command {
|
||||
const updatedTracks = this.savedState
|
||||
.map((track) => {
|
||||
const hasElementsToDelete = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToDelete) {
|
||||
@@ -29,7 +35,9 @@ export class DeleteElementsCommand extends Command {
|
||||
elements: track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(el) => el.trackId === track.id && el.elementId === element.id,
|
||||
(elementEntry) =>
|
||||
elementEntry.trackId === track.id &&
|
||||
elementEntry.elementId === element.id,
|
||||
),
|
||||
),
|
||||
} as typeof track;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
|
||||
for (const track of this.savedState) {
|
||||
const elementsToDuplicate = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToDuplicate.length === 0) {
|
||||
@@ -114,5 +115,14 @@ function buildDuplicateElement({
|
||||
id: string;
|
||||
startTime: number;
|
||||
}): TimelineElement {
|
||||
return { ...element, id, name: `${element.name} (copy)`, startTime };
|
||||
return {
|
||||
...element,
|
||||
id,
|
||||
name: `${element.name} (copy)`,
|
||||
startTime,
|
||||
animations: cloneAnimations({
|
||||
animations: element.animations,
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./remove-keyframe";
|
||||
export * from "./retime-keyframe";
|
||||
export * from "./upsert-keyframe";
|
||||
@@ -0,0 +1,141 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getChannel,
|
||||
getChannelValueAtTime,
|
||||
getElementBaseValueForProperty,
|
||||
removeElementKeyframe,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
function sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!channel || !keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
|
||||
if (baseValue === null || typeof baseValue !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampled = getChannelValueAtTime({
|
||||
channel,
|
||||
time: keyframe.time,
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
return typeof sampled === "number" ? sampled : null;
|
||||
}
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}): TimelineElement {
|
||||
const valueBefore = sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const nextAnimations = removeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const isChannelNowEmpty =
|
||||
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
? withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value: valueBefore,
|
||||
})
|
||||
: element;
|
||||
|
||||
return { ...baseElement, animations: nextAnimations };
|
||||
}
|
||||
|
||||
export class RemoveKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) =>
|
||||
removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
nextTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
this.nextTime = nextTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
time: boundedTime,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/types/animation";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly time: number;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.time = time;
|
||||
this.value = value;
|
||||
this.interpolation = interpolation;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: upsertElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,31 @@ import {
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly sourceTrackId: string;
|
||||
private readonly targetTrackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly newStartTime: number;
|
||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||
|
||||
constructor(
|
||||
private sourceTrackId: string,
|
||||
private targetTrackId: string,
|
||||
private elementId: string,
|
||||
private newStartTime: number,
|
||||
private createTrack?: { type: TrackType; index: number },
|
||||
) {
|
||||
constructor({
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
}) {
|
||||
super();
|
||||
this.sourceTrackId = sourceTrackId;
|
||||
this.targetTrackId = targetTrackId;
|
||||
this.elementId = elementId;
|
||||
this.newStartTime = newStartTime;
|
||||
this.createTrack = createTrack;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const sourceTrack = this.savedState.find(
|
||||
(t) => t.id === this.sourceTrackId,
|
||||
(track) => track.id === this.sourceTrackId,
|
||||
);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(el) => el.id === this.elementId,
|
||||
(trackElement) => trackElement.id === this.elementId,
|
||||
);
|
||||
|
||||
if (!sourceTrack || !element) {
|
||||
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
|
||||
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
|
||||
let tracksToUpdate = this.savedState;
|
||||
if (!targetTrack && this.createTrack) {
|
||||
const newTrack = buildEmptyTrack({
|
||||
@@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
|
||||
excludeElementId: this.elementId,
|
||||
});
|
||||
|
||||
// keyframe times remain clip-local, so moving only changes element startTime.
|
||||
const movedElement: TimelineElement = {
|
||||
...element,
|
||||
startTime: adjustedStartTime,
|
||||
@@ -85,8 +102,8 @@ export class MoveElementCommand extends Command {
|
||||
if (isSameTrack && track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((el) =>
|
||||
el.id === this.elementId ? movedElement : el,
|
||||
elements: track.elements.map((trackElement) =>
|
||||
trackElement.id === this.elementId ? movedElement : trackElement,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
|
||||
if (track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter((el) => el.id !== this.elementId),
|
||||
elements: track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,29 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private previousSelection: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private splitTime: number,
|
||||
private retainSide: "both" | "left" | "right" = "both",
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.splitTime = splitTime;
|
||||
this.retainSide = retainSide;
|
||||
}
|
||||
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
@@ -28,7 +39,7 @@ export class SplitElementsCommand extends Command {
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToSplit = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToSplit.length === 0) {
|
||||
@@ -39,7 +50,7 @@ export class SplitElementsCommand extends Command {
|
||||
...track,
|
||||
elements: track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(el) => el.elementId === element.id,
|
||||
(elementEntry) => elementEntry.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!shouldSplit) {
|
||||
@@ -59,6 +70,11 @@ export class SplitElementsCommand extends Command {
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
shouldIncludeSplitBoundary: true,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
@@ -67,6 +83,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -85,6 +102,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -102,6 +120,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
{
|
||||
...element,
|
||||
@@ -110,6 +129,7 @@ export class SplitElementsCommand extends Command {
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}),
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementDurationCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly duration: number;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private duration: number,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
duration: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, duration: this.duration } : el,
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
if (track.id !== this.trackId) return track;
|
||||
const newElements = track.elements.map((element) =>
|
||||
element.id === this.elementId
|
||||
? {
|
||||
...element,
|
||||
duration: this.duration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: this.duration,
|
||||
}),
|
||||
}
|
||||
: element,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly startTime: number;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private startTime: number,
|
||||
) {
|
||||
constructor({
|
||||
elements,
|
||||
startTime,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
startTime: number;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
|
||||
const currentTracks = this.savedState;
|
||||
const updatedTracks = currentTracks.map((track) => {
|
||||
const hasElementsToUpdate = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
(elementEntry) => elementEntry.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToUpdate) {
|
||||
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
|
||||
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = this.elements.some(
|
||||
(el) => el.elementId === element.id && el.trackId === track.id,
|
||||
(elementEntry) =>
|
||||
elementEntry.elementId === element.id &&
|
||||
elementEntry.trackId === track.id,
|
||||
);
|
||||
if (!shouldUpdate) {
|
||||
return element;
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elementId: string;
|
||||
private readonly trimStart: number;
|
||||
private readonly trimEnd: number;
|
||||
private readonly startTime: number | undefined;
|
||||
private readonly duration: number | undefined;
|
||||
|
||||
constructor(
|
||||
private elementId: string,
|
||||
private trimStart: number,
|
||||
private trimEnd: number,
|
||||
private startTime?: number,
|
||||
private duration?: number,
|
||||
) {
|
||||
constructor({
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
}) {
|
||||
super();
|
||||
this.elementId = elementId;
|
||||
this.trimStart = trimStart;
|
||||
this.trimEnd = trimEnd;
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextDuration = this.duration ?? element.duration;
|
||||
return {
|
||||
...element,
|
||||
trimStart: this.trimStart,
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: this.startTime ?? element.startTime,
|
||||
duration: this.duration ?? element.duration,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: nextDuration,
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
|
||||
export class UpdateElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly updates: Partial<TimelineElement>;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private updates: Partial<Record<string, unknown>>,
|
||||
) {
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
updates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, ...this.updates } : el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) => ({ ...element, ...this.updates }),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
|
||||
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
@@ -62,10 +63,12 @@ export function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
@@ -73,6 +76,11 @@ export function getElementBounds({
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
@@ -80,21 +88,31 @@ export function getElementBounds({
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform: element.transform,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 200,
|
||||
transform: element.transform,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
@@ -139,30 +157,30 @@ export function getElementBounds({
|
||||
measuredHeight = visualRect.height;
|
||||
const localCenterX = visualRect.left + visualRect.width / 2;
|
||||
const localCenterY = visualRect.top + visualRect.height / 2;
|
||||
const scaledCenterX = localCenterX * element.transform.scale;
|
||||
const scaledCenterY = localCenterY * element.transform.scale;
|
||||
const rotationRad = (element.transform.rotate * Math.PI) / 180;
|
||||
const scaledCenterX = localCenterX * transform.scale;
|
||||
const scaledCenterY = localCenterY * transform.scale;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
|
||||
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
|
||||
return {
|
||||
cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX,
|
||||
cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY,
|
||||
width: measuredWidth * element.transform.scale,
|
||||
height: measuredHeight * element.transform.scale,
|
||||
rotation: element.transform.rotate,
|
||||
cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
|
||||
cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
|
||||
width: measuredWidth * transform.scale,
|
||||
height: measuredHeight * transform.scale,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
const width = measuredWidth * element.transform.scale;
|
||||
const height = measuredHeight * element.transform.scale;
|
||||
const width = measuredWidth * transform.scale;
|
||||
const height = measuredHeight * transform.scale;
|
||||
return {
|
||||
cx: canvasWidth / 2 + element.transform.position.x,
|
||||
cy: canvasHeight / 2 + element.transform.position.y,
|
||||
cx: canvasWidth / 2 + transform.position.x,
|
||||
cy: canvasHeight / 2 + transform.position.y,
|
||||
width,
|
||||
height,
|
||||
rotation: element.transform.rotate,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +224,11 @@ export function getVisibleElementsWithBounds({
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
@@ -214,6 +237,7 @@ export function getVisibleElementsWithBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
AudioElement,
|
||||
VideoElement,
|
||||
ImageElement,
|
||||
StickerElement,
|
||||
VisualElement,
|
||||
UploadAudioElement,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaType } from "@/types/assets";
|
||||
@@ -31,7 +31,7 @@ export function canElementHaveAudio(
|
||||
|
||||
export function isVisualElement(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement | StickerElement {
|
||||
): element is VisualElement {
|
||||
return (
|
||||
element.type === "video" ||
|
||||
element.type === "image" ||
|
||||
@@ -42,7 +42,7 @@ export function isVisualElement(
|
||||
|
||||
export function canElementBeHidden(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement | StickerElement {
|
||||
): element is VisualElement {
|
||||
return element.type !== "audio";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export * from "./track-utils";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./pixel-utils";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
|
||||
|
||||
function getDevicePixelRatio({
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
if (
|
||||
typeof devicePixelRatio === "number" &&
|
||||
Number.isFinite(devicePixelRatio) &&
|
||||
devicePixelRatio > 0
|
||||
) {
|
||||
return devicePixelRatio;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
|
||||
return window.devicePixelRatio;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getTimelinePixelsPerSecond({
|
||||
zoomLevel,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
}
|
||||
|
||||
export function timelineTimeToPixels({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
return time * getTimelinePixelsPerSecond({ zoomLevel });
|
||||
}
|
||||
|
||||
export function snapPixelToDeviceGrid({
|
||||
pixel,
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
pixel: number;
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
|
||||
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
|
||||
}
|
||||
|
||||
export function timelineTimeToSnappedPixels({
|
||||
time,
|
||||
zoomLevel,
|
||||
devicePixelRatio,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
devicePixelRatio?: number;
|
||||
}): number {
|
||||
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
|
||||
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
|
||||
}
|
||||
|
||||
export function getCenteredLineLeft({
|
||||
centerPixel,
|
||||
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
}: {
|
||||
centerPixel: number;
|
||||
lineWidthPx?: number;
|
||||
}): number {
|
||||
return centerPixel - lineWidthPx / 2;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Bookmark, TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark";
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
@@ -26,6 +27,7 @@ export function findSnapPoints({
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
enableBookmarkSnapping = true,
|
||||
enableKeyframeSnapping = true,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
@@ -35,13 +37,15 @@ export function findSnapPoints({
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
enableBookmarkSnapping?: boolean;
|
||||
enableKeyframeSnapping?: boolean;
|
||||
}): SnapPoint[] {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
if (enableElementSnapping) {
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
|
||||
if (enableElementSnapping) {
|
||||
snapPoints.push(
|
||||
{
|
||||
time: element.startTime,
|
||||
@@ -57,6 +61,19 @@ export function findSnapPoints({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (enableKeyframeSnapping) {
|
||||
for (const keyframe of getElementKeyframes({
|
||||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export function updateElementInTracks({
|
||||
tracks,
|
||||
trackId,
|
||||
elementId,
|
||||
update,
|
||||
elementPredicate,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
update: (element: TimelineElement) => TimelineElement;
|
||||
elementPredicate?: (element: TimelineElement) => boolean;
|
||||
}): TimelineTrack[] {
|
||||
return tracks.map((track) => {
|
||||
if (track.id !== trackId) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const nextElements = track.elements.map((element) => {
|
||||
if (element.id !== elementId) {
|
||||
return element;
|
||||
}
|
||||
if (elementPredicate && !elementPredicate(element)) {
|
||||
return element;
|
||||
}
|
||||
return update(element);
|
||||
});
|
||||
|
||||
return { ...track, elements: nextElements } as TimelineTrack;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user