diff options
Diffstat (limited to 'apps/web/src/lib')
52 files changed, 4275 insertions, 222 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index 83144a25..82319216 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -105,11 +105,20 @@ export const ACTIONS = { category: "editing", defaultShortcuts: ["n"], }, + "toggle-ripple-editing": { + description: "Toggle ripple editing", + category: "editing", + }, "select-all": { description: "Select all elements", category: "selection", defaultShortcuts: ["ctrl+a"], }, + "deselect-all": { + description: "Deselect all elements", + category: "selection", + defaultShortcuts: ["escape"], + }, "duplicate-selected": { description: "Duplicate selected element", category: "selection", @@ -141,7 +150,7 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -export function getActionDefinition(action: TAction): TActionDefinition { +export function getActionDefinition({ action }: { action: TAction }): TActionDefinition { return ACTIONS[action]; } 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/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts new file mode 100644 index 00000000..2a340f94 --- /dev/null +++ b/apps/web/src/lib/animation/color-channel.ts @@ -0,0 +1,19 @@ +import type { + AnimationPropertyPath, + ColorAnimationChannel, + ElementAnimations, +} from "@/types/animation"; + +export function getColorChannelForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): ColorAnimationChannel | undefined { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.valueKind !== "color") { + return undefined; + } + return channel; +} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts new file mode 100644 index 00000000..07efe4d8 --- /dev/null +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -0,0 +1,117 @@ +import type { Effect, EffectParamValues } from "@/types/effects"; +import type { + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; +import { + getChannel, + removeKeyframe, + setChannel, + upsertKeyframe, +} from "./keyframes"; +import { getChannelValueAtTime } from "./interpolation"; + +const EFFECT_PARAM_PATH_PREFIX = "effects."; +const EFFECT_PARAM_PATH_SUFFIX = ".params."; + +function buildEffectParamPath({ + effectId, + paramKey, +}: { + effectId: string; + paramKey: string; +}): string { + return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; +} + +export function resolveEffectParamsAtTime({ + effect, + animations, + localTime, +}: { + effect: Effect; + animations: ElementAnimations | undefined; + localTime: number; +}): EffectParamValues { + const resolved: EffectParamValues = {}; + + for (const [paramKey, staticValue] of Object.entries(effect.params)) { + const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + if (channel && channel.keyframes.length > 0) { + resolved[paramKey] = getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: staticValue, + }) as number | string | boolean; + } else { + resolved[paramKey] = staticValue; + } + } + + return resolved; +} + +const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = { + valueKind: "number", + keyframes: [], +}; + +export function upsertEffectParamKeyframe({ + animations, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; +}): ElementAnimations | undefined { + const path = buildEffectParamPath({ effectId, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + const targetChannel = + channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL; + const updatedChannel = upsertKeyframe({ + channel: targetChannel, + time, + value, + interpolation: interpolation ?? "linear", + keyframeId, + }); + + return ( + setChannel({ + animations, + propertyPath: path, + channel: updatedChannel, + }) ?? { channels: {} } + ); +} + +export function removeEffectParamKeyframe({ + animations, + effectId, + paramKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + effectId: string; + paramKey: string; + keyframeId: string; +}): ElementAnimations | undefined { + const path = buildEffectParamPath({ effectId, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + const updatedChannel = removeKeyframe({ channel, keyframeId }); + return setChannel({ + animations, + propertyPath: path, + channel: updatedChannel, + }); +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts new file mode 100644 index 00000000..c4396bb7 --- /dev/null +++ b/apps/web/src/lib/animation/index.ts @@ -0,0 +1,41 @@ +export { + getChannelValueAtTime, + getNumberChannelValueAtTime, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveColorAtTime, + resolveNumberAtTime, + 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..7a6fc6cf --- /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, + }); + }, + }); +} + +export 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..3e3613f2 --- /dev/null +++ b/apps/web/src/lib/animation/keyframes.ts @@ -0,0 +1,611 @@ +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..361ae3ba --- /dev/null +++ b/apps/web/src/lib/animation/property-registry.ts @@ -0,0 +1,352 @@ +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 { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, + DEFAULT_TEXT_BACKGROUND, +} from "@/constants/text-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 }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => 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 as number }, + }, + } + : 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 as number }, + }, + } + : 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 as number }, + } + : 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 as number }, + } + : 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 as number } + : 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 as number } + : element, + }, + color: { + valueKind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + valueKind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }, + "background.paddingY": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }, + "background.offsetX": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }, + "background.offsetY": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }, + "background.cornerRadius": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : 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) { + 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..147be1b3 --- /dev/null +++ b/apps/web/src/lib/animation/resolve.ts @@ -0,0 +1,148 @@ +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { Transform } from "@/types/timeline"; +import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation"; +import { getColorChannelForPath } from "./color-channel"; +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 resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): number { + return getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ animations, propertyPath }), + time: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): string { + return getColorValueAtTime({ + channel: getColorChannelForPath({ animations, propertyPath }), + time: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +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 91eb6397..7fb46c36 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,13 +1,23 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; -import { isMainTrack } from "@/lib/timeline"; +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 }[]) { + constructor({ + elements, + rippleEnabled = false, + }: { + elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; + }) { super(); + this.elements = elements; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -16,23 +26,44 @@ export class DeleteElementsCommand extends Command { const updatedTracks = this.savedState .map((track) => { - const hasElementsToDelete = this.elements.some( - (el) => el.trackId === track.id, + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, ); + const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; if (!hasElementsToDelete) { return track; } - return { - ...track, - elements: track.elements.filter( - (element) => - !this.elements.some( - (el) => el.trackId === track.id && el.elementId === element.id, - ), - ), - } as typeof 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 })); + + 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( + (a, b) => b.startTime - a.startTime, + ); + for (const { startTime, duration } of sortedByStartDesc) { + elements = rippleShiftElements({ + elements, + afterTime: startTime, + shiftAmount: duration, + }); + } + } + + return { ...track, elements } as typeof track; }) .filter((track) => track.elements.length > 0 || isMainTrack(track)); 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/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts new file mode 100644 index 00000000..5299749d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -0,0 +1,74 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import { buildDefaultEffectInstance } from "@/lib/effects"; + +function addEffectToElement({ + element, + effectType, +}: { + element: VisualElement; + effectType: string; +}): VisualElement { + const instance = buildDefaultEffectInstance({ effectType }); + const currentEffects = element.effects ?? []; + return { ...element, effects: [...currentEffects, instance] }; +} + +export class AddClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private effectId: string | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectType: string; + + constructor({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectType = effectType; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const updated = addEffectToElement({ + element: element as VisualElement, + effectType: this.effectType, + }); + const effects = updated.effects ?? []; + this.effectId = effects[effects.length - 1]?.id ?? null; + return updated; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getEffectId(): string | null { + return this.effectId; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts new file mode 100644 index 00000000..133ce661 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -0,0 +1,5 @@ +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts new file mode 100644 index 00000000..b475474f --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -0,0 +1,65 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function removeEffectFromElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const filtered = currentEffects.filter((effect) => effect.id !== effectId); + return { ...element, effects: filtered }; +} + +export class RemoveClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return removeEffectFromElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts new file mode 100644 index 00000000..81c66503 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -0,0 +1,73 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function reorderEffectsOnElement({ + element, + fromIndex, + toIndex, +}: { + element: VisualElement; + fromIndex: number; + toIndex: number; +}): VisualElement { + const effects = [...(element.effects ?? [])]; + const [moved] = effects.splice(fromIndex, 1); + effects.splice(toIndex, 0, moved); + return { ...element, effects }; +} + +export class ReorderClipEffectsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly fromIndex: number; + private readonly toIndex: number; + + constructor({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return reorderEffectsOnElement({ + element: element as VisualElement, + fromIndex: this.fromIndex, + toIndex: this.toIndex, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts new file mode 100644 index 00000000..70bf955d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -0,0 +1,67 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +export function toggleEffectOnElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => + effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, + ); + return { ...element, effects: updated }; +} + +export class ToggleClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return toggleEffectOnElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts new file mode 100644 index 00000000..d9bf0aad --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -0,0 +1,86 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { EffectParamValues } from "@/types/effects"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function updateEffectParamsOnElement({ + element, + effectId, + params, +}: { + element: VisualElement; + effectId: string; + params: Partial<EffectParamValues>; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => { + if (effect.id !== effectId) { + return effect; + } + + const nextParams = { ...effect.params }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + + return { ...effect, params: nextParams }; + }); + return { ...element, effects: updated }; +} + +export class UpdateClipEffectParamsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly params: Partial<EffectParamValues>; + + constructor({ + trackId, + elementId, + effectId, + params, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial<EffectParamValues>; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.params = params; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 3f10baa4..413d1adf 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -9,3 +9,5 @@ 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"; +export * from "./effects"; 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..c03b14e9 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,5 @@ +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts new file mode 100644 index 00000000..d4922532 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -0,0 +1,68 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/types/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + 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: isVisualElement, + update: (element) => { + const animations = removeEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + 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/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-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts new file mode 100644 index 00000000..f5236279 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -0,0 +1,84 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/types/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly time: number; + private readonly value: number; + private readonly interpolation: "linear" | "hold" | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + 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: isVisualElement, + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const animations = upsertEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + 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..51e18c21 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -11,18 +11,39 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; +import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; 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; + private readonly rippleEnabled: boolean; - 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, + rippleEnabled = false, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + rippleEnabled?: boolean; + }) { super(); + this.sourceTrackId = sourceTrackId; + this.targetTrackId = targetTrackId; + this.elementId = elementId; + this.newStartTime = newStartTime; + this.createTrack = createTrack; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -30,18 +51,17 @@ 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) { - console.error("Source track or element not found"); - return; + throw new Error("Source track or element not found"); } - 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({ @@ -53,8 +73,7 @@ export class MoveElementCommand extends Command { targetTrack = newTrack; } if (!targetTrack) { - console.error("Target track not found"); - return; + throw new Error("Target track not found"); } const validation = validateElementTrackCompatibility({ @@ -63,8 +82,7 @@ export class MoveElementCommand extends Command { }); if (!validation.isValid) { - console.error(validation.errorMessage); - return; + throw new Error(validation.errorMessage); } const adjustedStartTime = enforceMainTrackStart({ @@ -74,6 +92,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,32 +100,39 @@ 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, ), - }; + } as typeof track; } if (track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.filter((el) => el.id !== this.elementId), - }; + const remainingElements = track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ); + const shiftedElements = this.rippleEnabled + ? rippleShiftElements({ + elements: remainingElements, + afterTime: element.startTime, + shiftAmount: element.duration, + }) + : remainingElements; + return { ...track, elements: shiftedElements } as typeof track; } if (track.id === this.targetTrackId) { return { ...track, elements: [...track.elements, movedElement], - }; + } as typeof track; } return track; - }) as TimelineTrack[]; + }); 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 abe133c9..37ccbb12 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -2,18 +2,34 @@ import { Command } from "@/lib/commands/base-command"; 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", - ) { + 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 }[] { @@ -28,92 +44,113 @@ export class SplitElementsCommand extends Command { const updatedTracks = this.savedState.map((track) => { const elementsToSplit = this.elements.filter( - (el) => el.trackId === track.id, + (target) => target.trackId === track.id, ); if (elementsToSplit.length === 0) { return track; } - return { - ...track, - elements: track.elements.flatMap((element) => { - const shouldSplit = elementsToSplit.some( - (el) => el.elementId === element.id, - ); + let leftVisibleDurationForRipple: number | null = null; - if (!shouldSplit) { - return [element]; - } - - const effectiveStart = element.startTime; - const effectiveEnd = element.startTime + element.duration; + let elements = track.elements.flatMap((element) => { + const shouldSplit = elementsToSplit.some( + (target) => target.elementId === element.id, + ); - if ( - this.splitTime <= effectiveStart || - this.splitTime >= effectiveEnd - ) { - return [element]; - } + if (!shouldSplit) { + return [element]; + } - const relativeTime = this.splitTime - element.startTime; - const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const effectiveStart = element.startTime; + const effectiveEnd = element.startTime + element.duration; - if (this.retainSide === "left") { - return [ - { - ...element, - duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, - name: `${element.name} (left)`, - }, - ]; - } + if ( + this.splitTime <= effectiveStart || + this.splitTime >= effectiveEnd + ) { + return [element]; + } - if (this.retainSide === "right") { - const newId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: newId, - }); - return [ - { - ...element, - id: newId, - startTime: this.splitTime, - duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, - name: `${element.name} (right)`, - }, - ]; - } - - // "both" - split into two pieces - const secondElementId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: secondElementId, - }); + 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 [ { ...element, duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, + ]; + } + + if (this.retainSide === "right") { + if (this.rippleEnabled && elementsToSplit.length === 1) { + leftVisibleDurationForRipple = leftVisibleDuration; + } + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + return [ { ...element, - id: secondElementId, + id: newId, startTime: this.splitTime, duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; - }), - } as typeof track; + } + + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + + return [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightVisibleDuration, + name: `${element.name} (left)`, + animations: leftAnimations, + }, + { + ...element, + id: secondElementId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftVisibleDuration, + name: `${element.name} (right)`, + animations: rightAnimations, + }, + ]; + }); + + if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { + elements = rippleShiftElements({ + elements, + afterTime: this.splitTime, + shiftAmount: leftVisibleDurationForRipple, + }); + } + + return { ...track, elements } as typeof track; }); editor.timeline.updateTracks(updatedTracks); 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..7548638a 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,40 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; +import { clampAnimationsToDuration } from "@/lib/animation"; +import { rippleShiftElements } from "@/lib/timeline"; 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; + private readonly rippleEnabled: boolean; - constructor( - private elementId: string, - private trimStart: number, - private trimEnd: number, - private startTime?: number, - private duration?: number, - ) { + constructor({ + elementId, + trimStart, + trimEnd, + startTime, + duration, + rippleEnabled = false, + }: { + elementId: string; + trimStart: number; + trimEnd: number; + startTime?: number; + duration?: number; + rippleEnabled?: boolean; + }) { super(); + this.elementId = elementId; + this.trimStart = trimStart; + this.trimEnd = trimEnd; + this.startTime = startTime; + this.duration = duration; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -20,20 +42,52 @@ export class UpdateElementTrimCommand extends Command { this.savedState = editor.timeline.getTracks(); const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - if (element.id !== this.elementId) { - return element; - } + const targetElement = track.elements.find( + (element) => element.id === this.elementId, + ); + if (!targetElement) return track; + const nextDuration = this.duration ?? targetElement.duration; + const nextStartTime = this.startTime ?? targetElement.startTime; + + const oldEndTime = targetElement.startTime + targetElement.duration; + const newEndTime = nextStartTime + nextDuration; + const shiftAmount = oldEndTime - newEndTime; + + const updatedElement = { + ...targetElement, + trimStart: this.trimStart, + trimEnd: this.trimEnd, + startTime: nextStartTime, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: targetElement.animations, + duration: nextDuration, + }), + }; + + if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { + const shiftedOthers = rippleShiftElements({ + elements: track.elements.filter((element) => element.id !== this.elementId), + afterTime: oldEndTime, + shiftAmount, + }); return { - ...element, - trimStart: this.trimStart, - trimEnd: this.trimEnd, - startTime: this.startTime ?? element.startTime, - duration: this.duration ?? element.duration, - }; - }); - return { ...track, elements: newElements } as typeof track; + ...track, + elements: track.elements.map((element) => + element.id === this.elementId + ? updatedElement + : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element) + ), + } as typeof track; + } + + return { + ...track, + elements: track.elements.map((element) => + element.id === this.elementId ? updatedElement : element + ), + } as typeof track; }); editor.timeline.updateTracks(updatedTracks); 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/export.ts b/apps/web/src/lib/export.ts index 67621e16..ae68144b 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -16,3 +16,23 @@ export function getExportFileExtension({ }): string { return `.${format}`; } + +export function downloadBuffer({ + buffer, + filename, + mimeType, +}: { + buffer: ArrayBuffer; + filename: string; + mimeType: string; +}): void { + const blob = new Blob([buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + downloadLink.href = url; + downloadLink.download = filename; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 9e58ec52..08ffbfa8 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -4,9 +4,15 @@ import { isMainTrack } from "@/lib/timeline"; import { DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_BACKGROUND, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveTransformAtTime, + resolveNumberAtTime, +} from "@/lib/animation"; export interface ElementBounds { cx: number; @@ -62,17 +68,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({ @@ -80,21 +93,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; @@ -129,40 +152,71 @@ export function getElementBounds({ fallbackFontSize: scaledFontSize, }); const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; + const resolvedBackground = { + ...element.background, + paddingX: resolveNumberAtTime({ + baseValue: + element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: + element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: + element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: + element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }), + }; const visualRect = getTextVisualRect({ textAlign: element.textAlign, block, - background: element.background, + background: resolvedBackground, fontSizeRatio, }); measuredWidth = visualRect.width; measuredHeight = visualRect.height; const localCenterX = visualRect.left + visualRect.width / 2; const localCenterY = visualRect.top + visualRect.height / 2; - const scaledCenterX = localCenterX * element.transform.scale; - const scaledCenterY = localCenterY * element.transform.scale; - const rotationRad = (element.transform.rotate * Math.PI) / 180; + const scaledCenterX = localCenterX * transform.scale; + const scaledCenterY = localCenterY * transform.scale; + const rotationRad = (transform.rotate * Math.PI) / 180; const cos = Math.cos(rotationRad); const sin = Math.sin(rotationRad); const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin; const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos; return { - cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX, - cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY, - width: measuredWidth * element.transform.scale, - height: measuredHeight * element.transform.scale, - rotation: element.transform.rotate, + cx: canvasWidth / 2 + transform.position.x + rotatedCenterX, + cy: canvasHeight / 2 + transform.position.y + rotatedCenterY, + width: measuredWidth * transform.scale, + height: measuredHeight * transform.scale, + rotation: transform.rotate, }; } - const width = measuredWidth * element.transform.scale; - const height = measuredHeight * element.transform.scale; + const width = measuredWidth * transform.scale; + const height = measuredHeight * transform.scale; return { - cx: canvasWidth / 2 + element.transform.position.x, - cy: canvasHeight / 2 + element.transform.position.y, + cx: canvasWidth / 2 + transform.position.x, + cy: canvasHeight / 2 + transform.position.y, width, height, - rotation: element.transform.rotate, + rotation: transform.rotate, }; } @@ -206,6 +260,11 @@ export function getVisibleElementsWithBounds({ }); for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); const mediaAsset = element.type === "video" || element.type === "image" ? mediaMap.get(element.mediaId) @@ -214,6 +273,7 @@ export function getVisibleElementsWithBounds({ element, canvasSize, mediaAsset, + localTime, }); if (bounds) { result.push({ diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 8c38e9aa..3eadcec2 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -1,5 +1,5 @@ import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants"; -import type { TextElement } from "@/types/timeline"; +import type { TextBackground, TextElement } from "@/types/timeline"; type TextRect = { left: number; @@ -74,8 +74,12 @@ function getTextRect({ textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; }): TextRect { - const left = - textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2; + const textAlignToLeft: Record<typeof textAlign, number> = { + left: 0, + right: -block.maxWidth, + center: -block.maxWidth / 2, + }; + const left = textAlignToLeft[textAlign]; return { left, @@ -88,9 +92,13 @@ function getTextRect({ function isTextBackgroundVisible({ background, }: { - background: TextElement["background"]; + background: TextBackground; }): boolean { - return Boolean(background.color) && background.color !== "transparent"; + return ( + background.enabled && + Boolean(background.color) && + background.color !== "transparent" + ); } export function getTextBackgroundRect({ @@ -101,7 +109,7 @@ export function getTextBackgroundRect({ }: { textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; - background: TextElement["background"]; + background: TextBackground; fontSizeRatio?: number; }): TextRect | null { if (!isTextBackgroundVisible({ background })) { @@ -132,7 +140,7 @@ export function getTextVisualRect({ }: { textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; - background: TextElement["background"]; + background: TextBackground; fontSizeRatio?: number; }): TextRect { const textRect = getTextRect({ textAlign, block }); 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 c51667bf..11564376 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -6,22 +6,26 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { + CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, CreateStickerElement, CreateUploadAudioElement, CreateLibraryAudioElement, + TextBackground, TextElement, TimelineElement, TimelineTrack, 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 +35,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,8 +46,8 @@ export function isVisualElement( export function canElementBeHidden( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { - return element.type !== "audio"; +): element is VisualElement { + return isVisualElement(element); } export function hasMediaId( @@ -124,13 +128,30 @@ export function wouldElementOverlap({ endTime: number; excludeElementId?: string; }): boolean { - return elements.some((el) => { - if (excludeElementId && el.id === excludeElementId) return false; - const elEnd = el.startTime + el.duration; - return startTime < elEnd && endTime > el.startTime; + return elements.some((element) => { + if (excludeElementId && element.id === excludeElementId) return false; + const elementEnd = element.startTime + element.duration; + return startTime < elementEnd && endTime > element.startTime; }); } +function buildTextBackground( + raw: Partial<TextBackground> | undefined, +): TextBackground { + const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color; + const enabled = + typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent"; + return { + enabled, + color, + cornerRadius: raw?.cornerRadius, + paddingX: raw?.paddingX, + paddingY: raw?.paddingY, + offsetX: raw?.offsetX, + offsetY: raw?.offsetY, + }; +} + export function buildTextElement({ raw, startTime, @@ -154,14 +175,7 @@ export function buildTextElement({ : DEFAULT_TEXT_ELEMENT.fontSize, fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, - 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, - }, + background: buildTextBackground(t.background), textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, @@ -174,6 +188,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, @@ -218,6 +254,7 @@ export function buildVideoElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, @@ -274,6 +311,7 @@ export function buildUploadAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; @@ -336,6 +374,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 f3ace94f..887ff7ab 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,9 +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/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts new file mode 100644 index 00000000..5f37bf29 --- /dev/null +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -0,0 +1,17 @@ +import type { TimelineElement } from "@/types/timeline"; + +export function rippleShiftElements({ + elements, + afterTime, + shiftAmount, +}: { + elements: TimelineElement[]; + afterTime: number; + shiftAmount: number; +}): TimelineElement[] { + return elements.map((element) => + element.startTime >= afterTime + ? { ...element, startTime: element.startTime - shiftAmount } + : element, + ); +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index a29737d0..c7fac090 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,10 +1,11 @@ import type { Bookmark, TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; +import { getElementKeyframes } from "@/lib/animation"; export interface SnapPoint { time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark"; + type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; elementId?: string; trackId?: string; } @@ -26,6 +27,7 @@ export function findSnapPoints({ enableElementSnapping = true, enablePlayheadSnapping = true, enableBookmarkSnapping = true, + enableKeyframeSnapping = true, }: { tracks: Array<TimelineTrack>; playheadTime: number; @@ -35,13 +37,15 @@ export function findSnapPoints({ enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { snapPoints.push( { time: element.startTime, @@ -57,6 +61,19 @@ export function findSnapPoints({ }, ); } + + if (enableKeyframeSnapping) { + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } } } 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"; } |
