summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
commit5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch)
treeb33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/lib
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/index.ts39
-rw-r--r--apps/web/src/lib/animation/interpolation.ts370
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts67
-rw-r--r--apps/web/src/lib/animation/keyframes.ts593
-rw-r--r--apps/web/src/lib/animation/number-channel.ts20
-rw-r--r--apps/web/src/lib/animation/property-registry.ts230
-rw-r--r--apps/web/src/lib/animation/resolve.ts111
-rw-r--r--apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts433
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts3
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts141
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts76
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts89
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts51
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts43
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts40
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts21
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts38
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts34
-rw-r--r--apps/web/src/lib/effects/definitions/blur.frag.glsl25
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts50
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts13
-rw-r--r--apps/web/src/lib/effects/effect.vert.glsl7
-rw-r--r--apps/web/src/lib/effects/index.ts34
-rw-r--r--apps/web/src/lib/effects/registry.ts31
-rw-r--r--apps/web/src/lib/media/audio.ts68
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts93
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts14
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts28
-rw-r--r--apps/web/src/lib/text/layout.ts167
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts85
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts43
-rw-r--r--apps/web/src/lib/timeline/index.ts2
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts79
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts172
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts33
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts38
42 files changed, 3614 insertions, 160 deletions
diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
new file mode 100644
index 00000000..629bd0d6
--- /dev/null
+++ b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
new file mode 100644
index 00000000..c41c1a7e
--- /dev/null
+++ b/apps/web/src/lib/animation/index.ts
@@ -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";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
new file mode 100644
index 00000000..d3f68010
--- /dev/null
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -0,0 +1,370 @@
+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];
+
+ if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
+ return rightKeyframe.value;
+ }
+
+ const isBetweenPair = isWithinTimePair({
+ time,
+ leftTime: leftKeyframe.time,
+ rightTime: rightKeyframe.time,
+ });
+ if (!isBetweenPair) {
+ continue;
+ }
+
+ const span = rightKeyframe.time - leftKeyframe.time;
+ if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ return rightKeyframe.value;
+ }
+
+ const progress = clamp01({
+ value: (time - leftKeyframe.time) / span,
+ });
+
+ return getInterpolatedValue({
+ leftKeyframe,
+ rightKeyframe,
+ progress,
+ });
+ }
+
+ return lastKeyframe.value;
+}
+
+export function getNumberChannelValueAtTime({
+ 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,
+ });
+}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
new file mode 100644
index 00000000..6626e3e8
--- /dev/null
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -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(
+ (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ );
+ if (!keyframe) return null;
+ return {
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ };
+}
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
new file mode 100644
index 00000000..56f156a4
--- /dev/null
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -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,
+ });
+}
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
new file mode 100644
index 00000000..67166156
--- /dev/null
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -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;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
new file mode 100644
index 00000000..44f5d638
--- /dev/null
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -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;
+}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
new file mode 100644
index 00000000..58f292b1
--- /dev/null
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -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,
+ });
+}
diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
new file mode 100644
index 00000000..d2356a39
--- /dev/null
+++ b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 49554e0e..800cbb90 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -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);
}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 9e6ed8cd..7fb46c36 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -5,12 +5,19 @@ import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly rippleEnabled: boolean;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private rippleEnabled = false,
- ) {
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
- if (!hasElementsToDelete) {
- return track;
- }
+ if (!hasElementsToDelete) {
+ return track;
+ }
- const deletedElementInfos = elementsToDeleteOnTrack
- .map((target) =>
- track.elements.find((element) => element.id === target.elementId),
- )
- .filter((element): element is NonNullable<typeof element> => element !== undefined)
- .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+ const deletedElementInfos = elementsToDeleteOnTrack
+ .map((target) =>
+ track.elements.find((element) => element.id === target.elementId),
+ )
+ .filter((element): element is NonNullable<typeof element> => element !== undefined)
+ .map((element) => ({ startTime: element.startTime, duration: element.duration }));
- let elements = track.elements.filter(
- (element) =>
- !this.elements.some(
- (target) =>
- target.trackId === track.id && target.elementId === element.id,
- ),
- );
+ let elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index cf1bd94c..97b475f4 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -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,
+ }),
+ };
}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 3f10baa4..69d94c24 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -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";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index e1551471..68fc9841 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
return true;
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
new file mode 100644
index 00000000..f221e33a
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,3 @@
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
new file mode 100644
index 00000000..f12f9829
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -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);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
new file mode 100644
index 00000000..5328419f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -0,0 +1,76 @@
+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));
+ if (!Number.isFinite(boundedTime)) return element;
+ 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);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
new file mode 100644
index 00000000..370f6865
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -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);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 2fe06694..6aadcf16 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -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,
@@ -81,12 +98,12 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
- let updatedTracks = tracksToUpdate.map((track) => {
+ let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
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,
+ ),
};
}
@@ -105,8 +124,8 @@ export class MoveElementCommand extends Command {
};
}
- return track;
- }) as TimelineTrack[];
+ return track;
+ });
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 05a23fff..37ccbb12 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -3,19 +3,33 @@ import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
+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";
+ private readonly rippleEnabled: boolean;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private splitTime: number,
- private retainSide: "both" | "left" | "right" = "both",
- private rippleEnabled = false,
- ) {
+ constructor({
+ elements,
+ splitTime,
+ retainSide = "both",
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ splitTime: number;
+ retainSide?: "both" | "left" | "right";
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.splitTime = splitTime;
+ this.retainSide = retainSide;
+ this.rippleEnabled = rippleEnabled;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -61,6 +75,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 [
@@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
];
}
@@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
}
- const secondElementId = generateUUID();
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
{
...element,
@@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
});
- if (
- this.rippleEnabled &&
- leftVisibleDurationForRipple !== null
- ) {
+ if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
elements = rippleShiftElements({
elements,
afterTime: this.splitTime,
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
index 6b3a860b..3366548d 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -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);
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
index 51dcb63d..e25bc0e3 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -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;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
index 99c63287..6afcb9d0 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -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;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
index 4ee54726..643f8e4b 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -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 }) as TimelineElement,
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
new file mode 100644
index 00000000..2c015725
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -0,0 +1,25 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_sigma;
+uniform vec2 u_direction;
+
+varying vec2 v_texCoord;
+
+void main() {
+ vec2 texelSize = 1.0 / u_resolution;
+
+ vec4 color = vec4(0.0);
+ float totalWeight = 0.0;
+
+ // step=1 texel — scaling step size instead causes discrete ghosting artifacts
+ for (int i = -30; i <= 30; i++) {
+ float fi = float(i);
+ float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ totalWeight += weight;
+ }
+
+ gl_FragColor = color / totalWeight;
+}
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
new file mode 100644
index 00000000..3a8f691a
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -0,0 +1,50 @@
+import type { EffectDefinition } from "@/types/effects";
+import blurFragmentShader from "./blur.frag.glsl";
+
+export const blurEffectDefinition: EffectDefinition = {
+ type: "blur",
+ name: "Blur",
+ keywords: ["blur", "soft", "defocus"],
+ params: [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 15,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ ],
+ renderer: {
+ type: "webgl",
+ passes: [
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [1, 0],
+ };
+ },
+ },
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [0, 1],
+ };
+ },
+ },
+ ],
+ },
+};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
new file mode 100644
index 00000000..1742b1d7
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -0,0 +1,13 @@
+import { hasEffect, registerEffect } from "../registry";
+import { blurEffectDefinition } from "./blur";
+
+const defaultEffects = [blurEffectDefinition];
+
+export function registerDefaultEffects(): void {
+ for (const definition of defaultEffects) {
+ if (hasEffect({ effectType: definition.type })) {
+ continue;
+ }
+ registerEffect({ definition });
+ }
+}
diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl
new file mode 100644
index 00000000..5febc835
--- /dev/null
+++ b/apps/web/src/lib/effects/effect.vert.glsl
@@ -0,0 +1,7 @@
+attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
new file mode 100644
index 00000000..14d06d84
--- /dev/null
+++ b/apps/web/src/lib/effects/index.ts
@@ -0,0 +1,34 @@
+import { generateUUID } from "@/utils/id";
+import { getEffect } from "./registry";
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+
+export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { registerDefaultEffects } from "./definitions";
+
+export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
+ "video",
+ "image",
+ "text",
+ "sticker",
+];
+
+export function buildDefaultEffectInstance({
+ effectType,
+}: {
+ effectType: string;
+}): Effect {
+ const definition = getEffect({ effectType });
+
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+
+ return {
+ id: generateUUID(),
+ type: effectType,
+ params,
+ enabled: true,
+ };
+}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
new file mode 100644
index 00000000..568b8924
--- /dev/null
+++ b/apps/web/src/lib/effects/registry.ts
@@ -0,0 +1,31 @@
+import type { EffectDefinition } from "@/types/effects";
+
+const effectDefinitions = new Map<string, EffectDefinition>();
+
+export function registerEffect({
+ definition,
+}: {
+ definition: EffectDefinition;
+}): void {
+ effectDefinitions.set(definition.type, definition);
+}
+
+export function hasEffect({ effectType }: { effectType: string }): boolean {
+ return effectDefinitions.has(effectType);
+}
+
+export function getEffect({
+ effectType,
+}: {
+ effectType: string;
+}): EffectDefinition {
+ const definition = effectDefinitions.get(effectType);
+ if (!definition) {
+ throw new Error(`Unknown effect type: ${effectType}`);
+ }
+ return definition;
+}
+
+export function getAllEffects(): EffectDefinition[] {
+ return Array.from(effectDefinitions.values());
+}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
index 0b71cbaa..6f789301 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
+
+const MAX_AUDIO_CHANNELS = 2;
+const EXPORT_SAMPLE_RATE = 44100;
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
> & { buffer: AudioBuffer };
-export function createAudioContext(): AudioContext {
+export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
- return new AudioContextConstructor();
+ return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
}
export interface DecodedAudio {
@@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({
mediaAsset: MediaAsset;
audioContext: AudioContext;
}): Promise<AudioBuffer | null> {
+ const input = new Input({
+ source: new BlobSource(mediaAsset.file),
+ formats: ALL_FORMATS,
+ });
+
try {
- const arrayBuffer = await mediaAsset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) return null;
+
+ const sink = new AudioBufferSink(audioTrack);
+ const targetSampleRate = audioContext.sampleRate;
+
+ const chunks: AudioBuffer[] = [];
+ let totalSamples = 0;
+
+ for await (const { buffer } of sink.buffers(0)) {
+ chunks.push(buffer);
+ totalSamples += buffer.length;
+ }
+
+ if (chunks.length === 0) return null;
+
+ const nativeSampleRate = chunks[0].sampleRate;
+ const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
+
+ const nativeChannels = Array.from(
+ { length: numChannels },
+ () => new Float32Array(totalSamples),
+ );
+ let offset = 0;
+ for (const chunk of chunks) {
+ for (let channel = 0; channel < numChannels; channel++) {
+ const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
+ nativeChannels[channel].set(sourceData, offset);
+ }
+ offset += chunk.length;
+ }
+
+ // use OfflineAudioContext for high-quality resampling to target rate
+ const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
+ const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
+
+ const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
+ for (let ch = 0; ch < numChannels; ch++) {
+ nativeBuffer.copyToChannel(nativeChannels[ch], ch);
+ }
+
+ const sourceNode = offlineContext.createBufferSource();
+ sourceNode.buffer = nativeBuffer;
+ sourceNode.connect(offlineContext.destination);
+ sourceNode.start(0);
+
+ return await offlineContext.startRendering();
} catch (error) {
console.warn("Failed to decode video audio:", error);
return null;
+ } finally {
+ input.dispose();
}
}
@@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
- sampleRate = 44100,
+ sampleRate = EXPORT_SAMPLE_RATE,
audioContext,
}: {
tracks: TimelineTrack[];
@@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({
sampleRate?: number;
audioContext?: AudioContext;
}): Promise<AudioBuffer | null> {
- const context = audioContext ?? createAudioContext();
+ const context = audioContext ?? createAudioContext({ sampleRate });
const audioElements = await collectAudioElements({
tracks,
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index ec26c14d..5b1f301d 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -2,9 +2,12 @@ import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline";
import {
+ DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
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;
@@ -60,17 +63,24 @@ 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 (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
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({
@@ -78,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;
@@ -121,37 +141,46 @@ export function getElementBounds({
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
-
- let top = Number.POSITIVE_INFINITY;
- let bottom = Number.NEGATIVE_INFINITY;
- let maxWidth = 0;
-
- for (let i = 0; i < lineMetrics.length; i++) {
- const metrics = lineMetrics[i];
- const y = i * lineHeightPx;
- top = Math.min(
- top,
- y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
- );
- bottom = Math.max(
- bottom,
- y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
- );
- maxWidth = Math.max(maxWidth, metrics.width);
- }
-
- measuredWidth = maxWidth;
- measuredHeight = bottom - top;
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+ const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: element.background,
+ fontSizeRatio,
+ });
+ measuredWidth = visualRect.width;
+ measuredHeight = visualRect.height;
+ const localCenterX = visualRect.left + visualRect.width / 2;
+ const localCenterY = visualRect.top + visualRect.height / 2;
+ 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 + 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,
};
}
@@ -195,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)
@@ -203,6 +237,7 @@ export function getVisibleElementsWithBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
});
if (bounds) {
result.push({
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
index 08c16735..dcc87282 100644
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -74,3 +74,17 @@ export function getDisplayScale({
y: canvasRect.height / canvasSize.height,
};
}
+
+export function screenPixelsToLogicalThreshold({
+ canvas,
+ screenPixels,
+}: {
+ canvas: HTMLCanvasElement;
+ screenPixels: number;
+}): { x: number; y: number } {
+ const canvasRect = canvas.getBoundingClientRect();
+ return {
+ x: screenPixels * (canvas.width / canvasRect.width),
+ y: screenPixels * (canvas.height / canvasRect.height),
+ };
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index bd53f0ca..36efc3ce 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -3,10 +3,10 @@ export interface SnapLine {
position: number;
}
-const SNAP_THRESHOLD = 10;
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
export interface SnapResult {
snappedPosition: { x: number; y: number };
@@ -17,10 +17,12 @@ export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
+ snapThreshold,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
@@ -41,11 +43,13 @@ export function snapPosition({
function getClosestAxisSnap({
candidates,
+ threshold,
}: {
candidates: AxisSnapCandidate[];
+ threshold: number;
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= SNAP_THRESHOLD,
+ (candidate) => candidate.distance <= threshold,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
@@ -95,8 +99,14 @@ export function snapPosition({
});
}
- const closestX = getClosestAxisSnap({ candidates: xCandidates });
- const closestY = getClosestAxisSnap({ candidates: yCandidates });
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
@@ -124,12 +134,14 @@ export function snapScale({
baseWidth,
baseHeight,
canvasSize,
+ snapThreshold,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
@@ -162,7 +174,7 @@ export function snapScale({
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= SNAP_THRESHOLD) {
+ if (distanceLeft <= snapThreshold.x) {
const scale = (2 * (position.x - target.position)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -173,7 +185,7 @@ export function snapScale({
}
}
const distanceRight = Math.abs(rightEdge - target.position);
- if (distanceRight <= SNAP_THRESHOLD) {
+ if (distanceRight <= snapThreshold.x) {
const scale = (2 * (target.position - position.x)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -199,7 +211,7 @@ export function snapScale({
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= SNAP_THRESHOLD) {
+ if (distanceTop <= snapThreshold.y) {
const scale = (2 * (position.y - target.position)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
@@ -210,7 +222,7 @@ export function snapScale({
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
- if (distanceBottom <= SNAP_THRESHOLD) {
+ if (distanceBottom <= snapThreshold.y) {
const scale = (2 * (target.position - position.y)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
new file mode 100644
index 00000000..8c38e9aa
--- /dev/null
+++ b/apps/web/src/lib/text/layout.ts
@@ -0,0 +1,167 @@
+import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
+import type { TextElement } from "@/types/timeline";
+
+type TextRect = {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+};
+
+export interface TextBlockMeasurement {
+ visualCenterOffset: number;
+ height: number;
+ maxWidth: number;
+}
+
+export function getMetricAscent({
+ metrics,
+ fallbackFontSize,
+}: {
+ metrics: TextMetrics;
+ fallbackFontSize: number;
+}): number {
+ return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
+}
+
+export function getMetricDescent({
+ metrics,
+ fallbackFontSize,
+}: {
+ metrics: TextMetrics;
+ fallbackFontSize: number;
+}): number {
+ return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
+}
+
+export function measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize,
+}: {
+ lineMetrics: TextMetrics[];
+ lineHeightPx: number;
+ fallbackFontSize: number;
+}): TextBlockMeasurement {
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let index = 0; index < lineMetrics.length; index++) {
+ const metrics = lineMetrics[index];
+ const lineY = index * lineHeightPx;
+ top = Math.min(
+ top,
+ lineY - getMetricAscent({ metrics, fallbackFontSize }),
+ );
+ bottom = Math.max(
+ bottom,
+ lineY + getMetricDescent({ metrics, fallbackFontSize }),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ const height = bottom - top;
+ const visualCenterOffset = (top + bottom) / 2;
+
+ return { visualCenterOffset, height, maxWidth };
+}
+
+function getTextRect({
+ textAlign,
+ block,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+}): TextRect {
+ const left =
+ textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
+
+ return {
+ left,
+ top: -block.height / 2,
+ width: block.maxWidth,
+ height: block.height,
+ };
+}
+
+function isTextBackgroundVisible({
+ background,
+}: {
+ background: TextElement["background"];
+}): boolean {
+ return Boolean(background.color) && background.color !== "transparent";
+}
+
+export function getTextBackgroundRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio = 1,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+ background: TextElement["background"];
+ fontSizeRatio?: number;
+}): TextRect | null {
+ if (!isTextBackgroundVisible({ background })) {
+ return null;
+ }
+
+ const textRect = getTextRect({ textAlign, block });
+ const paddingX =
+ (background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio;
+ const paddingY =
+ (background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio;
+ const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX;
+ const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY;
+
+ return {
+ left: textRect.left - paddingX + offsetX,
+ top: textRect.top - paddingY + offsetY,
+ width: textRect.width + paddingX * 2,
+ height: textRect.height + paddingY * 2,
+ };
+}
+
+export function getTextVisualRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio = 1,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+ background: TextElement["background"];
+ fontSizeRatio?: number;
+}): TextRect {
+ const textRect = getTextRect({ textAlign, block });
+ const backgroundRect = getTextBackgroundRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio,
+ });
+
+ if (!backgroundRect) {
+ return textRect;
+ }
+
+ const left = Math.min(textRect.left, backgroundRect.left);
+ const top = Math.min(textRect.top, backgroundRect.top);
+ const right = Math.max(
+ textRect.left + textRect.width,
+ backgroundRect.left + backgroundRect.width,
+ );
+ const bottom = Math.max(
+ textRect.top + textRect.height,
+ backgroundRect.top + backgroundRect.height,
+ );
+
+ return {
+ left,
+ top,
+ width: right - left,
+ height: bottom - top,
+ };
+}
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index 9182892f..b502974d 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -1,9 +1,42 @@
-import type { TimelineTrack, ElementType } from "@/types/timeline";
-import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/types/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
function getTrackAtY({
mouseY,
tracks,
@@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
- const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -100,6 +134,8 @@ function findInsertIndex({
};
}
+const EMPTY_TARGET_ELEMENT = null;
+
export function computeDropTarget({
elementType,
mouseX,
@@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
+ targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
- return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
- const trackHeight = TRACK_HEIGHTS[track.type];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
- y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index fa74b14e..8c02c097 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
+ CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@@ -18,10 +19,12 @@ import type {
AudioElement,
VideoElement,
ImageElement,
- StickerElement,
+ VisualElement,
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@@ -31,7 +34,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 +45,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
-): element is VideoElement | ImageElement | TextElement | StickerElement {
+): element is VisualElement {
return element.type !== "audio";
}
@@ -154,7 +157,14 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
- backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
+ background: {
+ color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
+ cornerRadius: t.background?.cornerRadius,
+ paddingX: t.background?.paddingX,
+ paddingY: t.background?.paddingY,
+ offsetX: t.background?.offsetX,
+ offsetY: t.background?.offsetY,
+ },
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -167,6 +177,28 @@ export function buildTextElement({
};
}
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
export function buildStickerElement({
stickerId,
name,
@@ -211,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -267,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -329,6 +363,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
index b5e74a45..887ff7ab 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -1,10 +1,12 @@
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 "./ripple-utils";
+export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
new file mode 100644
index 00000000..4c0c243c
--- /dev/null
+++ b/apps/web/src/lib/timeline/pixel-utils.ts
@@ -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;
+}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
new file mode 100644
index 00000000..c7fac090
--- /dev/null
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -0,0 +1,172 @@
+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" | "keyframe";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+const DEFAULT_SNAP_THRESHOLD_PX = 10;
+
+export function findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
+}: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+
+ 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,
+ });
+ }
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({ time: playheadTime, type: "playhead" });
+ }
+
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({ time: bookmark.time, type: "bookmark" });
+ }
+ }
+
+ return snapPoints;
+}
+
+export function snapToNearestPoint({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
+}: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ snapThreshold?: number;
+}): SnapResult {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
+
+export function snapElementEdge({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ bookmarks = [],
+}: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
+}): SnapResult {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts
new file mode 100644
index 00000000..6118b327
--- /dev/null
+++ b/apps/web/src/lib/timeline/track-element-update.ts
@@ -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;
+ });
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index 415d0ace..94ff21ba 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -6,11 +6,11 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
+ EffectTrack,
TimelineElement,
} from "@/types/timeline";
import {
- TRACK_COLORS,
- TRACK_HEIGHTS,
+ TRACK_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,21 +23,20 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
-): track is VideoTrack | TextTrack | StickerTrack {
+): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
- return TRACK_COLORS[type];
+ return TRACK_CONFIG[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
- const colors = TRACK_COLORS[type];
- return `${colors.background}`.trim();
+ return TRACK_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
- return TRACK_HEIGHTS[type];
+ return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
@@ -77,17 +76,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
- const trackName =
- name ??
- (type === "video"
- ? "Video track"
- : type === "text"
- ? "Text track"
- : type === "audio"
- ? "Audio track"
- : type === "sticker"
- ? "Sticker track"
- : "Track");
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
@@ -124,6 +113,14 @@ export function buildEmptyTrack({
elements: [],
muted: false,
};
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
default:
throw new Error(`Unsupported track type: ${type}`);
}
@@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({
return tracks.length;
}
+ if (trackType === "effect") {
+ return 0;
+ }
+
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
@@ -216,6 +217,7 @@ export function canElementGoOnTrack({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}