feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
+23 -4
View File
@@ -1,4 +1,4 @@
import type { ShortcutKey } from "@/types/keybinding";
import type { ShortcutKey } from "@/lib/actions/keybinding";
export type TActionCategory =
| "playback"
@@ -7,7 +7,8 @@ export type TActionCategory =
| "selection"
| "history"
| "timeline"
| "controls";
| "controls"
| "assets";
export interface TActionDefinition {
description: string;
@@ -114,10 +115,14 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
"cancel-interaction": {
description: "Cancel current interaction",
category: "controls",
defaultShortcuts: ["escape"],
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
defaultShortcuts: ["escape"],
},
"duplicate-selected": {
description: "Duplicate selected element",
@@ -146,11 +151,25 @@ export const ACTIONS = {
category: "history",
defaultShortcuts: ["ctrl+shift+z", "ctrl+y"],
},
"remove-media-asset": {
description: "Remove media asset",
category: "assets",
args: { projectId: "string", assetId: "string" },
},
"remove-media-assets": {
description: "Remove media assets",
category: "assets",
args: { projectId: "string", assetIds: "string[]" },
},
} as const satisfies Record<string, TActionDefinition>;
export type TAction = keyof typeof ACTIONS;
export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
export function getActionDefinition({
action,
}: {
action: TAction;
}): TActionDefinition {
return ACTIONS[action];
}
+79
View File
@@ -0,0 +1,79 @@
import type { TActionWithOptionalArgs } from "./types";
/**
* Alt is also regarded as macOS OPTION (⌥) key
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
*/
export type ModifierKeys =
| "ctrl"
| "alt"
| "shift"
| "ctrl+shift"
| "alt+shift"
| "ctrl+alt"
| "ctrl+alt+shift";
export type Key =
| "a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z"
| "0"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "up"
| "down"
| "left"
| "right"
| "/"
| "?"
| "."
| "enter"
| "tab"
| "space"
| "escape"
| "esc"
| "backspace"
| "delete"
| "home"
| "end";
/* eslint-enable */
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
// Singular keybindings (these will be disabled when an input-ish area has been focused)
export type SingleCharacterShortcutKey = `${Key}`;
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
export type KeybindingConfig = {
[key in ShortcutKey]?: TActionWithOptionalArgs;
};
+26 -1
View File
@@ -57,5 +57,30 @@ export const invokeAction: InvokeActionFunc = <A extends TAction>(
args?: TArgOfAction<A>,
trigger?: TInvocationTrigger,
) => {
boundActions[action]?.forEach((handler) => handler(args, trigger));
if (trigger === "keypress") {
// #region agent log
fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H4",
location: "actions/registry.ts:invokeAction",
message: "Action invoked from keypress",
data: {
action,
handlerCount: boundActions[action]?.length ?? 0,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
boundActions[action]?.forEach((handler) => {
handler(args, trigger);
});
};
+2
View File
@@ -8,6 +8,8 @@ export type TActionArgsMap = {
"seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined;
"remove-media-asset": { projectId: string; assetId: string };
"remove-media-assets": { projectId: string; assetIds: string[] };
};
type TKeysWithValueUndefined<T> = {
@@ -1,313 +0,0 @@
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();
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
} from "@/types/animation";
} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
@@ -1,8 +1,10 @@
import type { Effect, EffectParamValues } from "@/types/effects";
import type { ParamValues } from "@/lib/params";
import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
EffectParamPath,
NumberAnimationChannel,
} from "@/types/animation";
} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
@@ -11,19 +13,56 @@ import {
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
const EFFECT_PARAM_PATH_PREFIX = "effects.";
const EFFECT_PARAM_PATH_SUFFIX = ".params.";
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
function buildEffectParamPath({
export function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
}): string {
}): EffectParamPath {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
export function isEffectParamPath({
propertyPath,
}: {
propertyPath: string;
}): propertyPath is EffectParamPath {
return (
propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
);
}
export function parseEffectParamPath({
propertyPath,
}: {
propertyPath: string;
}): { effectId: string; paramKey: string } | null {
if (!isEffectParamPath({ propertyPath })) {
return null;
}
const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
if (separatorIndex <= 0) {
return null;
}
const effectId = withoutPrefix.slice(0, separatorIndex);
const paramKey = withoutPrefix.slice(
separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
);
if (!effectId || !paramKey) {
return null;
}
return { effectId, paramKey };
}
export function resolveEffectParamsAtTime({
effect,
animations,
@@ -32,8 +71,8 @@ export function resolveEffectParamsAtTime({
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
}): EffectParamValues {
const resolved: EffectParamValues = {};
}): ParamValues {
const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
@@ -0,0 +1,79 @@
import type {
ElementAnimations,
GraphicParamPath,
} from "@/lib/animation/types";
import type { ParamValues } from "@/lib/params";
import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/lib/graphics";
import { getChannel } from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
export function buildGraphicParamPath({
paramKey,
}: {
paramKey: string;
}): GraphicParamPath {
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
}
export function isGraphicParamPath({
propertyPath,
}: {
propertyPath: string;
}): propertyPath is GraphicParamPath {
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
}
export function parseGraphicParamPath({
propertyPath,
}: {
propertyPath: string;
}): { paramKey: string } | null {
if (!isGraphicParamPath({ propertyPath })) {
return null;
}
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
return paramKey.length > 0 ? { paramKey } : null;
}
export function resolveGraphicParamsAtTime({
element,
localTime,
}: {
element: {
definitionId: string;
params: ParamValues;
animations?: ElementAnimations;
};
localTime: number;
}): ParamValues {
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const baseParams = resolveGraphicParams(definition, element.params);
const resolved: ParamValues = { ...baseParams };
for (const param of definition.params) {
const path = buildGraphicParamPath({ paramKey: param.key });
const channel = getChannel({
animations: element.animations,
propertyPath: path,
});
if (!channel || channel.keyframes.length === 0) {
continue;
}
resolved[param.key] = getChannelValueAtTime({
channel,
time: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default,
}) as number | string | boolean;
}
return resolved;
}
+31 -1
View File
@@ -1,6 +1,7 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
@@ -13,6 +14,7 @@ export {
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
@@ -21,7 +23,6 @@ export {
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
resolveVolumeAtTime,
} from "./resolve";
export {
@@ -31,6 +32,9 @@ export {
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
@@ -39,3 +43,29 @@ export {
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
resolveGraphicParamsAtTime,
} from "./graphic-param-channel";
export {
isAnimationPath,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
} from "./property-groups";
export {
getVectorChannelForPath,
isVectorValue,
} from "./vector-channel";
+56 -15
View File
@@ -5,7 +5,10 @@ import type {
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
} from "@/types/animation";
VectorAnimationChannel,
VectorValue,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
function byTimeAscending({
@@ -59,12 +62,7 @@ function parseHexColor({
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
) {
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -77,12 +75,7 @@ function parseHexColor({
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
) {
if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -177,7 +170,10 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel;
}
function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
function evaluateChannelValueAtTime<
TKeyframe extends { time: number; value: TValue },
TValue,
>({
keyframes,
time,
fallbackValue,
@@ -214,7 +210,11 @@ function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TVa
return lastKeyframe.value;
}
for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
for (
let keyframeIndex = 0;
keyframeIndex < keyframes.length - 1;
keyframeIndex++
) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
@@ -304,6 +304,36 @@ export function getColorValueAtTime({
});
}
export function getVectorChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: VectorAnimationChannel | undefined;
time: number;
fallbackValue: VectorValue;
}): VectorValue {
return evaluateChannelValueAtTime({
keyframes: channel?.keyframes,
time,
fallbackValue,
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
if (leftKeyframe.interpolation === "hold") {
return leftKeyframe.value;
}
return {
x:
leftKeyframe.value.x +
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
y:
leftKeyframe.value.y +
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
};
},
});
}
function getDiscreteValueAtTime({
channel,
time,
@@ -358,6 +388,17 @@ export function getChannelValueAtTime({
});
}
if (channel.valueKind === "vector") {
if (!isVectorValue(fallbackValue)) {
return fallbackValue;
}
return getVectorChannelValueAtTime({
channel,
time,
fallbackValue: fallbackValue as VectorValue,
});
}
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
+11 -6
View File
@@ -1,9 +1,10 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationPropertyPath,
AnimationPath,
ElementAnimations,
ElementKeyframe,
} from "@/types/animation";
} from "@/lib/animation/types";
import { isAnimationPath } from "./target-resolver";
export function getElementKeyframes({
animations,
@@ -16,12 +17,16 @@ export function getElementKeyframes({
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel || channel.keyframes.length === 0) {
if (
!channel ||
channel.keyframes.length === 0 ||
!isAnimationPath({ propertyPath })
) {
return [];
}
return channel.keyframes.map((keyframe) => ({
propertyPath: propertyPath as AnimationPropertyPath,
propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
@@ -36,7 +41,7 @@ export function hasKeyframesForPath({
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
@@ -48,7 +53,7 @@ export function getKeyframeAtTime({
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
+173 -58
View File
@@ -2,6 +2,7 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
@@ -9,15 +10,19 @@ import type {
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
VectorAnimationChannel,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
import { snapToStep } from "@/utils/math";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
type NumericRange,
} from "./property-registry";
function isNearlySameTime({
@@ -126,6 +131,19 @@ function buildKeyframe({
};
}
if (channel.valueKind === "vector") {
if (!isVectorValue(value)) {
throw new Error("Vector channel keyframes require {x, y} 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",
@@ -140,32 +158,178 @@ function buildKeyframe({
};
}
function createEmptyChannel({
propertyPath,
function createEmptyChannelForValueKind({
valueKind,
}: {
propertyPath: AnimationPropertyPath;
valueKind: AnimationValueKind;
}): AnimationChannel {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
if (valueKind === "number") {
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
if (propertyDefinition.valueKind === "color") {
if (valueKind === "color") {
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
if (valueKind === "vector") {
return {
valueKind: "vector",
keyframes: [],
} satisfies VectorAnimationChannel;
}
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
function clampNumericRange({
value,
numericRange,
}: {
value: number;
numericRange: NumericRange | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
function coerceAnimationValueForPath({
value,
valueKind,
numericRange,
}: {
value: AnimationValue;
valueKind: AnimationValueKind;
numericRange?: NumericRange;
}): AnimationValue | null {
if (valueKind === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return clampNumericRange({ value, numericRange });
}
if (valueKind === "color") {
return typeof value === "string" ? value : null;
}
if (valueKind === "vector") {
return isVectorValue(value) ? value : null;
}
return typeof value === "string" || typeof value === "boolean" ? value : null;
}
export function upsertPathKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
valueKind,
defaultInterpolation,
numericRange,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForPath({
value,
valueKind,
numericRange,
});
if (coercedValue === null) {
return animations;
}
const channel = getChannel({ animations, propertyPath });
const targetChannel =
channel && channel.valueKind === valueKind
? channel
: createEmptyChannelForValueKind({ valueKind });
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value: coercedValue,
interpolation: interpolation ?? defaultInterpolation,
keyframeId,
});
return (
setChannel({
animations,
propertyPath,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
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 propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return upsertPathKeyframe({
animations,
propertyPath,
time,
value: coercedValue,
interpolation,
keyframeId,
valueKind: propertyDefinition.valueKind,
defaultInterpolation: getDefaultInterpolationForProperty({
propertyPath,
}),
numericRange: propertyDefinition.numericRange,
});
}
export function upsertKeyframe({
channel,
time,
@@ -516,62 +680,13 @@ export function splitAnimationsAtTime({
};
}
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;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
@@ -593,7 +708,7 @@ export function retimeElementKeyframe({
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
+1 -1
View File
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
} from "@/lib/animation/types";
export function getNumberChannelForPath({
animations,
@@ -0,0 +1,39 @@
import type {
AnimationPropertyGroup,
AnimationPropertyPath,
ElementAnimations,
} from "@/lib/animation/types";
import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
import { getKeyframeAtTime } from "./keyframe-query";
export interface GroupKeyframeRef {
propertyPath: AnimationPropertyPath;
keyframeId: string;
}
export function getGroupKeyframesAtTime({
animations,
group,
time,
}: {
animations: ElementAnimations | undefined;
group: AnimationPropertyGroup;
time: number;
}): GroupKeyframeRef[] {
return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
});
}
export function hasGroupKeyframeAtTime({
animations,
group,
time,
}: {
animations: ElementAnimations | undefined;
group: AnimationPropertyGroup;
time: number;
}): boolean {
return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
}
+56 -37
View File
@@ -4,25 +4,35 @@ import type {
AnimationValue,
AnimationValueKind,
DiscreteValue,
} from "@/types/animation";
import type { TimelineElement } from "@/types/timeline";
VectorValue,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import type { TimelineElement } from "@/lib/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";
import {
canElementHaveAudio,
isVisualElement,
} from "@/lib/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { snapToStep } from "@/utils/math";
interface NumericRange {
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
interface AnimationPropertyDefinition {
export type NumericRange = NumericSpec;
export interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
numericRange?: NumericSpec;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
@@ -38,58 +48,57 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position.x": {
valueKind: "number",
"transform.position": {
valueKind: "vector",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.x : null,
isVisualElement(element) ? element.transform.position : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value as number },
position: value as VectorValue,
},
}
: element,
},
"transform.position.y": {
"transform.scaleX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value as number },
},
transform: { ...element.transform, scaleX: value as number },
}
: element,
},
"transform.scale": {
"transform.scaleY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: MIN_TRANSFORM_SCALE },
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scale : null,
isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scale: value as number },
transform: { ...element.transform, scaleY: value as number },
}
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
@@ -104,7 +113,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
@@ -116,12 +125,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
volume: {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0, max: 1 },
supportsElement: ({ element }) => element.type === "audio",
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
element.type === "audio" ? element.volume : null,
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
element.type === "audio"
canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
},
@@ -152,11 +161,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0 },
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -169,11 +178,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: 0 },
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -186,10 +195,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetX": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -202,10 +212,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetY": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -218,7 +229,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.cornerRadius": {
valueKind: "number",
defaultInterpolation: "linear",
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
@@ -304,20 +315,24 @@ export function getDefaultInterpolationForProperty({
return propertyDefinition.defaultInterpolation;
}
function clampNumericRange({
function applyNumericSpec({
value,
numericRange,
}: {
value: number;
numericRange: NumericRange | undefined;
numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, value));
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
export function coerceAnimationValueForProperty({
@@ -334,7 +349,7 @@ export function coerceAnimationValueForProperty({
return null;
}
return clampNumericRange({
return applyNumericSpec({
value,
numericRange: propertyDefinition.numericRange,
});
@@ -344,6 +359,10 @@ export function coerceAnimationValueForProperty({
return typeof value === "string" ? value : null;
}
if (propertyDefinition.valueKind === "vector") {
return isVectorValue(value) ? value : null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
+31 -44
View File
@@ -1,8 +1,16 @@
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
import type { Transform } from "@/types/timeline";
import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
import type {
AnimationPropertyPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { Transform } from "@/lib/rendering";
import {
getColorValueAtTime,
getNumberChannelValueAtTime,
getVectorChannelValueAtTime,
} from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
import { getVectorChannelForPath } from "./vector-channel";
export function getElementLocalTime({
timelineTime,
@@ -36,31 +44,29 @@ export function resolveTransformAtTime({
}): 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({
position: getVectorChannelValueAtTime({
channel: getVectorChannelForPath({
animations,
propertyPath: "transform.scale",
propertyPath: "transform.position",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scale,
fallbackValue: baseTransform.position,
}),
scaleX: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.scaleX",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scaleX,
}),
scaleY: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
propertyPath: "transform.scaleY",
}),
time: safeLocalTime,
fallbackValue: baseTransform.scaleY,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
@@ -127,22 +133,3 @@ export function resolveColorAtTime({
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,
});
}
@@ -0,0 +1,279 @@
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
AnimationValueKind,
} from "@/lib/animation/types";
import {
parseEffectParamPath,
isEffectParamPath,
} from "@/lib/animation/effect-param-channel";
import {
isGraphicParamPath,
parseGraphicParamPath,
} from "@/lib/animation/graphic-param-channel";
import type { ParamDefinition } from "@/lib/params";
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
import { getGraphicDefinition } from "@/lib/graphics";
import type { TimelineElement } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapToStep } from "@/utils/math";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
isAnimationPropertyPath,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
export interface AnimationPathDescriptor {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericSpec;
getBaseValue(): AnimationValue | null;
setBaseValue(value: AnimationValue): TimelineElement;
}
export function getParamValueKind({
param,
}: {
param: ParamDefinition;
}): AnimationValueKind {
if (param.type === "number") {
return "number";
}
if (param.type === "color") {
return "color";
}
return "discrete";
}
export function getParamDefaultInterpolation({
param,
}: {
param: ParamDefinition;
}): AnimationInterpolation {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
}
function getParamNumericRange({
param,
}: {
param: ParamDefinition;
}): NumericSpec | undefined {
if (param.type !== "number") {
return undefined;
}
return {
min: param.min,
max: param.max,
step: param.step,
};
}
function coerceParamValue({
param,
value,
}: {
param: ParamDefinition;
value: AnimationValue;
}): number | string | boolean | null {
if (param.type === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
if (param.type === "color") {
return typeof value === "string" ? value : null;
}
if (param.type === "boolean") {
return typeof value === "boolean" ? value : null;
}
if (typeof value !== "string") {
return null;
}
return param.options.some((option) => option.value === value) ? value : null;
}
function buildGraphicParamDescriptor({
element,
paramKey,
}: {
element: TimelineElement;
paramKey: string;
}): AnimationPathDescriptor | null {
if (element.type !== "graphic") {
return null;
}
const definition = getGraphicDefinition({
definitionId: element.definitionId,
});
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRange: getParamNumericRange({ param }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: (value) => {
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
params: {
...element.params,
[param.key]: coercedValue,
},
};
},
};
}
function buildEffectParamDescriptor({
element,
effectId,
paramKey,
}: {
element: TimelineElement;
effectId: string;
paramKey: string;
}): AnimationPathDescriptor | null {
if (!isVisualElement(element)) {
return null;
}
const effect = element.effects?.find((candidate) => candidate.id === effectId);
if (!effect) {
return null;
}
registerDefaultEffects();
const definition = effectsRegistry.get(effect.type);
const param = definition.params.find((candidate) => candidate.key === paramKey);
if (!param) {
return null;
}
return {
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRange: getParamNumericRange({ param }),
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: (value) => {
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
effects:
element.effects?.map((candidate) =>
candidate.id !== effectId
? candidate
: {
...candidate,
params: {
...candidate.params,
[param.key]: coercedValue,
},
},
) ?? element.effects,
};
},
};
}
export function isAnimationPath({
propertyPath,
}: {
propertyPath: string;
}): propertyPath is AnimationPath {
return (
isAnimationPropertyPath({ propertyPath }) ||
isGraphicParamPath({ propertyPath }) ||
isEffectParamPath({ propertyPath })
);
}
export function resolveAnimationTarget({
element,
path,
}: {
element: TimelineElement;
path: AnimationPath;
}): AnimationPathDescriptor | null {
if (isAnimationPropertyPath({ propertyPath: path })) {
const propertyDefinition = getAnimationPropertyDefinition({
propertyPath: path,
});
if (!propertyDefinition.supportsElement({ element })) {
return null;
}
return {
valueKind: propertyDefinition.valueKind,
defaultInterpolation: propertyDefinition.defaultInterpolation,
numericRange: propertyDefinition.numericRange,
getBaseValue: () =>
getElementBaseValueForProperty({
element,
propertyPath: path,
}),
setBaseValue: (value) => {
const coercedValue = coerceAnimationValueForProperty({
propertyPath: path,
value,
});
if (coercedValue === null) {
return element;
}
return withElementBaseValueForProperty({
element,
propertyPath: path,
value: coercedValue,
});
},
};
}
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
if (graphicParamTarget) {
return buildGraphicParamDescriptor({
element,
paramKey: graphicParamTarget.paramKey,
});
}
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
if (effectParamTarget) {
return buildEffectParamDescriptor({
element,
effectId: effectParamTarget.effectId,
paramKey: effectParamTarget.paramKey,
});
}
return null;
}
+119
View File
@@ -0,0 +1,119 @@
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
"background.cornerRadius",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath =
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export type ContinuousKeyframeInterpolation = "linear" | "hold";
export type DiscreteKeyframeInterpolation = "hold";
export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
interface BaseAnimationKeyframe<
TValue extends AnimationValue,
TInterpolation extends AnimationInterpolation,
> {
id: string;
time: number; // relative to element start time
value: TValue;
interpolation: TInterpolation;
}
export interface NumberKeyframe
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
export interface ColorKeyframe
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
export interface DiscreteKeyframe
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
export interface VectorKeyframe
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
export type AnimationKeyframe =
| NumberKeyframe
| ColorKeyframe
| DiscreteKeyframe
| VectorKeyframe;
interface BaseAnimationChannel<
TValueKind extends AnimationValueKind,
TKeyframe extends AnimationKeyframe,
> {
valueKind: TValueKind;
keyframes: TKeyframe[];
}
export interface NumberAnimationChannel
extends BaseAnimationChannel<"number", NumberKeyframe> {}
export interface ColorAnimationChannel
extends BaseAnimationChannel<"color", ColorKeyframe> {}
export interface DiscreteAnimationChannel
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
export interface VectorAnimationChannel
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
export type AnimationChannel =
| NumberAnimationChannel
| ColorAnimationChannel
| DiscreteAnimationChannel
| VectorAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
AnimationChannel | undefined
>;
export interface ElementAnimations {
channels: ElementAnimationChannelMap;
}
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
}
@@ -0,0 +1,72 @@
import type {
AnimationPropertyPath,
ElementAnimations,
VectorAnimationChannel,
VectorValue,
} from "@/lib/animation/types";
export function isVectorValue(value: unknown): value is VectorValue {
return (
typeof value === "object" &&
value !== null &&
"x" in value &&
"y" in value &&
typeof (value as VectorValue).x === "number" &&
typeof (value as VectorValue).y === "number"
);
}
export function getVectorChannelForPath({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
}): VectorAnimationChannel | undefined {
const channel = animations?.channels[propertyPath];
if (!channel || channel.valueKind !== "vector") {
return undefined;
}
return channel;
}
export function getVectorChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: VectorAnimationChannel | undefined;
time: number;
fallbackValue: VectorValue;
}): VectorValue {
if (!channel || channel.keyframes.length === 0) {
return fallbackValue;
}
const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
const first = keyframes[0];
const last = keyframes[keyframes.length - 1];
if (!first || !last) return fallbackValue;
if (time <= first.time) return first.value;
if (time >= last.time) return last.value;
for (let i = 0; i < keyframes.length - 1; i++) {
const left = keyframes[i];
const right = keyframes[i + 1];
if (time < left.time || time > right.time) continue;
if (left.interpolation === "hold") return left.value;
const span = right.time - left.time;
if (span === 0) return right.value;
const t = (time - left.time) / span;
return {
x: left.value.x + (right.value.x - left.value.x) * t,
y: left.value.y + (right.value.y - left.value.y) * t,
};
}
return last.value;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
MarblePost,
MarblePostList,
MarbleTagList,
} from "@/types/blog";
} from "@/lib/blog/types";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
+92
View File
@@ -0,0 +1,92 @@
export type Post = {
id: string;
slug: string;
title: string;
content: string;
description: string;
coverImage: string;
publishedAt: Date;
updatedAt: Date;
authors: {
id: string;
name: string;
image: string;
}[];
category: {
id: string;
slug: string;
name: string;
};
tags: {
id: string;
slug: string;
name: string;
}[];
attribution: {
author: string;
url: string;
} | null;
};
export type Pagination = {
limit: number;
currpage: number;
nextPage: number | null;
prevPage: number | null;
totalItems: number;
totalPages: number;
};
export type MarblePostList = {
posts: Post[];
pagination: Pagination;
};
export type MarblePost = {
post: Post;
};
export type Tag = {
id: string;
name: string;
slug: string;
};
export type MarbleTag = {
tag: Tag;
};
export type MarbleTagList = {
tags: Tag[];
pagination: Pagination;
};
export type Category = {
id: string;
name: string;
slug: string;
};
export type MarbleCategory = {
category: Category;
};
export type MarbleCategoryList = {
categories: Category[];
pagination: Pagination;
};
export type Author = {
id: string;
name: string;
image: string;
};
export type MarbleAuthor = {
author: Author;
};
export type MarbleAuthorList = {
authors: Author[];
pagination: Pagination;
};
+24
View File
@@ -0,0 +1,24 @@
type CancelFn = () => void;
const cancellers = new Set<CancelFn>();
export function registerCanceller({ fn }: { fn: CancelFn }): () => void {
cancellers.add(fn);
return () => {
cancellers.delete(fn);
};
}
export function cancelInteraction(): boolean {
if (cancellers.size === 0) return false;
const activeCancellers = Array.from(cancellers);
cancellers.clear();
for (const cancel of activeCancellers) {
cancel();
}
return true;
}
@@ -1,433 +0,0 @@
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);
});
});
-1
View File
@@ -1,6 +1,5 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./media";
@@ -1,8 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
export class AddMediaAssetCommand extends Command {
private assetId: string;
@@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command {
})
.catch((error) => {
console.error("Failed to save media item:", error);
const currentAssets = editor.media.getAssets();
editor.media.setAssets({
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
});
const currentTracks = editor.timeline.getTracks();
const orphanedElements: Array<{ trackId: string; elementId: string }> =
[];
for (const track of currentTracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) {
orphanedElements.push({
trackId: track.id,
elementId: element.id,
});
}
}
}
if (orphanedElements.length > 0) {
editor.timeline.deleteElements({ elements: orphanedElements });
}
if (storageService.isQuotaExceededError({ error })) {
toast.error("Not enough browser storage", {
description: error instanceof Error ? error.message : undefined,
});
}
});
}
@@ -1,10 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
@@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command {
return;
}
if (this.removedAsset.url) {
URL.revokeObjectURL(this.removedAsset.url);
}
if (this.removedAsset.thumbnailUrl) {
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
}
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
@@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command {
undo(): void {
const editor = EditorCore.getInstance();
if (this.savedAssets) {
editor.media.setAssets({ assets: this.savedAssets });
}
if (this.savedAssets && this.removedAsset) {
const restoredAsset: MediaAsset = {
...this.removedAsset,
url: URL.createObjectURL(this.removedAsset.file),
};
if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
editor.media.setAssets({
assets: this.savedAssets.map((a) =>
a.id === this.assetId ? restoredAsset : a,
),
});
if (this.removedAsset) {
storageService
.saveMediaAsset({
projectId: this.projectId,
mediaAsset: this.removedAsset,
mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
}
}
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/types/project";
import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { Bookmark, TScene } from "@/types/timeline";
import type { Bookmark, TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
@@ -1,7 +1,7 @@
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 type { TimelineTrack, VisualElement } from "@/lib/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
@@ -1,7 +1,7 @@
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 type { TimelineTrack, VisualElement } from "@/lib/timeline";
function removeEffectFromElement({
element,
@@ -1,7 +1,7 @@
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 type { TimelineTrack, VisualElement } from "@/lib/timeline";
function reorderEffectsOnElement({
element,
@@ -1,7 +1,7 @@
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 type { TimelineTrack, VisualElement } from "@/lib/timeline";
export function toggleEffectOnElement({
element,
@@ -1,8 +1,8 @@
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";
import type { ParamValues } from "@/lib/params";
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function updateEffectParamsOnElement({
element,
@@ -11,7 +11,7 @@ function updateEffectParamsOnElement({
}: {
element: VisualElement;
effectId: string;
params: Partial<EffectParamValues>;
params: Partial<ParamValues>;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
@@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command {
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly params: Partial<EffectParamValues>;
private readonly params: Partial<ParamValues>;
constructor({
trackId,
@@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
params: Partial<ParamValues>;
}) {
super();
this.trackId = trackId;
@@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return updateEffectParamsOnElement({
element: element as VisualElement,
effectId: this.effectId,
params: this.params,
});
update: (element) => {
return updateEffectParamsOnElement({
element: element as VisualElement,
effectId: this.effectId,
params: this.params,
});
},
});
@@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
export * from "./masks";
export * from "./retime";
@@ -6,7 +6,7 @@ import type {
TimelineElement,
TrackType,
ElementType,
} from "@/types/timeline";
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
@@ -19,8 +19,9 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { MediaAsset } from "@/lib/media/types";
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -168,6 +169,14 @@ export class InsertElementCommand extends Command {
return false;
}
if (element.type === "graphic") {
registerDefaultGraphics();
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
console.error("Graphic element must have a valid definitionId");
return false;
}
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
@@ -344,9 +353,6 @@ export class InsertElementCommand extends Command {
}: {
element: { type: ElementType };
}): TrackType {
if (element.type === "video" || element.type === "image") {
return "video";
}
return element.type;
return ELEMENT_TRACK_MAP[element.type];
}
}
@@ -3,7 +3,7 @@ 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";
import type { TimelineTrack } from "@/lib/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -2,15 +2,13 @@ import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
getElementBaseValueForProperty,
removeElementKeyframe,
supportsAnimationProperty,
withElementBaseValueForProperty,
resolveAnimationTarget,
} 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";
import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
function sampleValueBeforeRemoval({
element,
@@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
}): number | null {
}): AnimationValue | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
@@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({
return null;
}
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
if (baseValue === null || typeof baseValue !== "number") {
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
return null;
}
const baseValue = target.getBaseValue();
if (baseValue === null) {
return null;
}
const sampled = getChannelValueAtTime({
return getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
@@ -51,9 +52,14 @@ function removeKeyframeAndPersist({
keyframeId,
}: {
element: TimelineElement;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
}): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
return element;
}
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
@@ -71,11 +77,7 @@ function removeKeyframeAndPersist({
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
? withElementBaseValueForProperty({
element,
propertyPath,
value: valueBefore,
})
? target.setBaseValue(valueBefore)
: element;
return { ...baseElement, animations: nextAnimations };
@@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
constructor({
@@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
}) {
super();
@@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) =>
removeKeyframeAndPersist({
element,
@@ -1,15 +1,15 @@
import { EditorCore } from "@/core";
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
import { resolveAnimationTarget, retimeElementKeyframe } 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";
import type { AnimationPath } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/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 propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly nextTime: number;
@@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
nextTime: number;
}) {
@@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
return element;
}
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
@@ -3,7 +3,7 @@ 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";
import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -1,19 +1,19 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import type {
AnimationPath,
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/types/animation";
} from "@/lib/animation/types";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPropertyPath;
private readonly propertyPath: AnimationPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
@@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: (element) =>
supportsAnimationProperty({
element,
propertyPath: this.propertyPath,
}),
update: (element) => {
const target = resolveAnimationTarget({
element,
path: this.propertyPath,
});
if (!target) {
return element;
}
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
animations: upsertElementKeyframe({
animations: upsertPathKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
valueKind: target.valueKind,
defaultInterpolation: target.defaultInterpolation,
numericRange: target.numericRange,
}),
};
},
@@ -0,0 +1,2 @@
export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
@@ -0,0 +1,64 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
function removeMaskFromElement({
element,
maskId,
}: {
element: MaskableElement;
maskId: string;
}): MaskableElement {
const currentMasks = element.masks ?? [];
const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId);
return { ...element, masks: filteredMasks };
}
export class RemoveMaskCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly maskId: string;
constructor({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.maskId = maskId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) =>
removeMaskFromElement({
element: element as MaskableElement,
maskId: this.maskId,
}),
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,75 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
import type { Mask } from "@/lib/masks/types";
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
export function toggleMaskInvertedOnElement({
element,
maskId,
}: {
element: MaskableElement;
maskId: string;
}): MaskableElement {
const currentMasks = element.masks ?? [];
const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({
...mask,
params: {
...mask.params,
inverted: !mask.params.inverted,
},
});
const updatedMasks = currentMasks.map((mask) =>
mask.id !== maskId ? mask : toggleMask(mask),
);
return { ...element, masks: updatedMasks };
}
export class ToggleMaskInvertedCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly maskId: string;
constructor({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.maskId = maskId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) =>
toggleMaskInvertedOnElement({
element: element as MaskableElement,
maskId: this.maskId,
}),
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
} from "@/types/timeline";
} from "@/lib/timeline";
import {
buildEmptyTrack,
isMainTrack,
@@ -0,0 +1 @@
export { UpdateElementRetimeCommand } from "./update-element-retime";
@@ -0,0 +1,114 @@
import { EditorCore } from "@/core";
import { clampRetimeRate } from "@/constants/retime-constants";
import { clampAnimationsToDuration } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
function getSourceDuration({
trimStart,
trimEnd,
duration,
sourceDuration,
retime,
}: {
trimStart: number;
trimEnd: number;
duration: number;
sourceDuration?: number;
retime?: RetimeConfig;
}): number {
if (typeof sourceDuration === "number") {
return sourceDuration;
}
return (
trimStart +
getSourceSpanAtClipTime({
clipTime: duration,
retime,
}) +
trimEnd
);
}
export class UpdateElementRetimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly retime: RetimeConfig | undefined;
constructor({
trackId,
elementId,
retime,
}: {
trackId: string;
elementId: string;
retime?: RetimeConfig;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.retime = retime;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isRetimableElement,
update: (element) => {
if (!isRetimableElement(element)) {
return element;
}
const nextRetime = this.retime
? {
...this.retime,
rate: clampRetimeRate({ rate: this.retime.rate }),
}
: undefined;
const sourceDuration = getSourceDuration({
trimStart: element.trimStart,
trimEnd: element.trimEnd,
duration: element.duration,
sourceDuration: element.sourceDuration,
retime: element.retime,
});
const visibleSourceSpan = Math.max(
0,
sourceDuration - element.trimStart - element.trimEnd,
);
const nextDuration = getTimelineDurationForSourceSpan({
sourceSpan: visibleSourceSpan,
retime: nextRetime,
});
return {
...element,
retime: nextRetime,
duration: nextDuration,
animations: clampAnimationsToDuration({
animations: element.animations,
duration: nextDuration,
}),
};
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -1,9 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
const retimeRef = isRetimableElement(element)
? element.retime
: undefined;
const leftSourceSpan = getSourceSpanAtClipTime({
clipTime: leftVisibleDuration,
retime: retimeRef,
});
const totalSourceSpan = getSourceSpanAtClipTime({
clipTime: element.duration,
retime: retimeRef,
});
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command {
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: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
},
];
}
// "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
});
return [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
},
{
...element,
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
}
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: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
}
// "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
});
return [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
{
...element,
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
});
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { canElementBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
@@ -1,8 +1,9 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
import { rippleShiftElements } from "@/lib/timeline";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command {
if (!targetElement) return track;
const nextDuration = this.duration ?? targetElement.duration;
const nextStartTime = this.startTime ?? targetElement.startTime;
const requestedStartTime = this.startTime ?? targetElement.startTime;
const nextStartTime = enforceMainTrackStart({
tracks: this.savedState ?? [],
targetTrackId: track.id,
requestedStartTime,
excludeElementId: this.elementId,
});
const oldEndTime = targetElement.startTime + targetElement.duration;
const newEndTime = nextStartTime + nextDuration;
@@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command {
trimEnd: this.trimEnd,
startTime: nextStartTime,
duration: nextDuration,
...(isRetimableElement(targetElement)
? { retime: targetElement.retime }
: {}),
animations: clampAnimationsToDuration({
animations: targetElement.animations,
duration: nextDuration,
@@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command {
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
const shiftedOthers = rippleShiftElements({
elements: track.elements.filter((element) => element.id !== this.elementId),
elements: track.elements.filter(
(element) => element.id !== this.elementId,
),
afterTime: oldEndTime,
shiftAmount,
});
@@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command {
elements: track.elements.map((element) =>
element.id === this.elementId
? updatedElement
: (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
: (shiftedOthers.find((shifted) => shifted.id === element.id) ??
element),
),
} as typeof track;
}
@@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command {
return {
...track,
elements: track.elements.map((element) =>
element.id === this.elementId ? updatedElement : element
element.id === this.elementId ? updatedElement : element,
),
} as typeof track;
});
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TrackType, TimelineTrack } from "@/types/timeline";
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command {
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline";
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { TimelineDragData } from "@/types/drag";
import type { TimelineDragData } from "@/lib/timeline/drag";
const MIME_TYPE = "application/x-timeline-drag";
let lastDragData: TimelineDragData | null = null;
@@ -3,6 +3,7 @@ precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_sigma;
uniform float u_step;
uniform vec2 u_direction;
varying vec2 v_texCoord;
@@ -13,13 +14,12 @@ void main() {
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;
float pos = float(i) * u_step;
float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma));
color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight;
totalWeight += weight;
}
gl_FragColor = color / totalWeight;
}
}
+86 -23
View File
@@ -1,6 +1,72 @@
import type { EffectDefinition } from "@/types/effects";
import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import blurFragmentShader from "./blur.frag.glsl";
const MAX_SINGLE_PASS_SIGMA = 10;
const MAX_STEP = 4;
const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
const MAX_ITERATIONS = 8;
/**
* Builds multi-pass gaussian blur passes for a given sigma.
* Shared by the blur effect and background blur they each
* compute their own sigma from their own intensity scale.
*/
export function buildGaussianBlurPasses({
sigmaX,
sigmaY,
}: {
sigmaX: number;
sigmaY: number;
}): ResolvedEffectPass[] {
const maxSigma = Math.max(sigmaX, sigmaY);
if (maxSigma < 0.001) return [];
const iterations = Math.min(
MAX_ITERATIONS,
Math.max(
1,
Math.ceil(
(maxSigma * maxSigma) /
(MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA),
),
),
);
const perPassSigmaX = sigmaX / Math.sqrt(iterations);
const perPassSigmaY = sigmaY / Math.sqrt(iterations);
const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
const passes: ResolvedEffectPass[] = [];
for (let i = 0; i < iterations; i++) {
passes.push({
fragmentShader: blurFragmentShader,
uniforms: {
u_sigma: perPassSigmaX,
u_step: stepX,
u_direction: [1, 0],
},
});
passes.push({
fragmentShader: blurFragmentShader,
uniforms: {
u_sigma: perPassSigmaY,
u_step: stepY,
u_direction: [0, 1],
},
});
}
return passes;
}
function intensityToSigma(intensity: number, resolution: number, reference: number): number {
return (intensity / 5) * (resolution / reference);
}
function parseIntensity(effectParams: Record<string, unknown>): number {
const raw = effectParams.intensity;
return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
}
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
@@ -19,32 +85,29 @@ export const blurEffectDefinition: EffectDefinition = {
renderer: {
type: "webgl",
passes: [
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, width }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001),
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, width }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
u_step: 1,
u_direction: [1, 0],
};
}),
},
},
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, height }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001),
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, height }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
u_step: 1,
u_direction: [0, 1],
};
}),
},
},
],
buildPasses: ({ effectParams, width, height }) => {
const intensity = parseIntensity(effectParams);
return buildGaussianBlurPasses({
sigmaX: intensityToSigma(intensity, width, 1920),
sigmaY: intensityToSigma(intensity, height, 1080),
});
},
},
};
@@ -1,13 +1,13 @@
import { hasEffect, registerEffect } from "../registry";
import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
const defaultEffects = [blurEffectDefinition];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
if (hasEffect({ effectType: definition.type })) {
if (effectsRegistry.has(definition.type)) {
continue;
}
registerEffect({ definition });
effectsRegistry.register(definition.type, definition);
}
}
+29 -16
View File
@@ -1,29 +1,42 @@
import { generateUUID } from "@/utils/id";
import { getEffect } from "./registry";
import type { Effect, EffectParamValues } from "@/types/effects";
import type { VisualElement } from "@/types/timeline";
import { buildDefaultParamValues } from "@/lib/registry";
import { effectsRegistry } from "./registry";
import type { ParamValues } from "@/lib/params";
import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
export { effectsRegistry } from "./registry";
export { registerDefaultEffects } from "./definitions";
export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
"video",
"image",
"text",
"sticker",
];
export function resolveEffectPasses({
definition,
effectParams,
width,
height,
}: {
definition: EffectDefinition;
effectParams: ParamValues;
width: number;
height: number;
}): ResolvedEffectPass[] {
if (definition.renderer.buildPasses) {
return definition.renderer.buildPasses({ effectParams, width, height });
}
return definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
uniforms: pass.uniforms({ effectParams, width, height }),
}));
}
export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES;
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;
}
const definition = effectsRegistry.get(effectType);
const params: ParamValues = buildDefaultParamValues(definition.params);
return {
id: generateUUID(),
+6 -27
View File
@@ -1,31 +1,10 @@
import type { EffectDefinition } from "@/types/effects";
import { DefinitionRegistry } from "@/lib/registry";
import type { EffectDefinition } from "@/lib/effects/types";
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}`);
export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> {
constructor() {
super("effect");
}
return definition;
}
export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}
export const effectsRegistry = new EffectsRegistry();
+42
View File
@@ -0,0 +1,42 @@
import type { ParamDefinition, ParamValues } from "@/lib/params";
export interface Effect {
id: string;
type: string;
params: ParamValues;
enabled: boolean;
}
export interface ResolvedEffectPass {
fragmentShader: string;
uniforms: Record<string, number | number[]>;
}
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {
effectParams: ParamValues;
width: number;
height: number;
}): Record<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
buildPasses?: (params: {
effectParams: ParamValues;
width: number;
height: number;
}) => ResolvedEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: ParamDefinition[];
renderer: EffectRenderer;
}
+32 -1
View File
@@ -1,5 +1,36 @@
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
import type { ExportFormat } from "@/types/export";
export const EXPORT_QUALITY_VALUES = [
"low",
"medium",
"high",
"very_high",
] as const;
export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const;
export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number];
export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
export interface ExportOptions {
format: ExportFormat;
quality: ExportQuality;
fps?: number;
includeAudio?: boolean;
}
export interface ExportResult {
success: boolean;
buffer?: ArrayBuffer;
error?: string;
cancelled?: boolean;
}
export interface ExportState {
isExporting: boolean;
progress: number;
result: ExportResult | null;
}
export function getExportMimeType({
format,
+12 -14
View File
@@ -1,14 +1,16 @@
import type { FontAtlas } from "@/types/fonts";
import type { FontAtlas } from "@/lib/fonts/types";
import { SYSTEM_FONTS } from "@/constants/font-constants";
const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
const FONT_ATLAS_PATH = "/fonts/font-atlas.json";
const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-";
const fullLoaded = new Set<string>();
let cachedAtlas: FontAtlas | null = null;
let atlasFetchPromise: Promise<FontAtlas | null> | null = null;
function encodeFamily(family: string): string {
function encodeGoogleFontsFamily(family: string): string {
return family.replace(/ /g, "+");
}
@@ -19,17 +21,19 @@ export function getCachedFontAtlas(): FontAtlas | null {
export function clearFontAtlasCache(): void {
cachedAtlas = null;
atlasFetchPromise = null;
fullLoaded.clear();
}
async function fetchAtlas(): Promise<FontAtlas | null> {
if (cachedAtlas) return cachedAtlas;
export function loadFontAtlas(): Promise<FontAtlas | null> {
if (cachedAtlas) return Promise.resolve(cachedAtlas);
if (atlasFetchPromise) return atlasFetchPromise;
atlasFetchPromise = fetch("/fonts/font-atlas.json")
atlasFetchPromise = fetch(FONT_ATLAS_PATH)
.then(async (response) => {
if (!response.ok) return null;
const data: FontAtlas = await response.json();
cachedAtlas = data;
preloadChunkImages({ atlas: data });
return data;
})
.catch(() => null);
@@ -42,18 +46,12 @@ function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
...Object.values(atlas.fonts).map((entry) => entry.ch),
);
for (let i = 0; i <= maxChunk; i++) {
// hint browser to preload chunk images without blocking
const img = new Image();
img.src = `/fonts/font-chunk-${i}.avif`;
img.src = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`;
}
}
export function prefetchFontAtlas(): Promise<FontAtlas | null> {
return fetchAtlas().then((atlas) => {
if (atlas) preloadChunkImages({ atlas });
return atlas;
});
}
export async function loadFullFont({
family,
weights = [400, 700],
@@ -63,7 +61,7 @@ export async function loadFullFont({
}): Promise<void> {
if (fullLoaded.has(family)) return;
const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`;
const url = `${GOOGLE_FONTS_CSS}?family=${encodeGoogleFontsFamily(family)}:wght@${weights.join(";")}&display=swap`;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
+24
View File
@@ -0,0 +1,24 @@
export interface FontOption {
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
}
export interface GoogleFontMeta {
family: string;
category: string;
}
export interface FontAtlasEntry {
x: number;
y: number;
w: number;
ch: number;
s: string[];
}
export interface FontAtlas {
fonts: Record<string, FontAtlasEntry>;
}
@@ -0,0 +1,73 @@
import type { ParamDefinition } from "@/lib/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
interface EllipseParams {
fill: string;
stroke: string;
strokeWidth: number;
strokeAlign: GraphicStrokeAlign;
}
const ELLIPSE_PARAMS: ParamDefinition<keyof EllipseParams & string>[] = [
{
key: "fill",
label: "Fill",
type: "color",
default: "#ffffff",
},
{
key: "stroke",
label: "Color",
type: "color",
default: "#000000",
group: "stroke",
},
{
key: "strokeWidth",
label: "Width",
type: "number",
default: 0,
min: 0,
max: 64,
step: 1,
shortLabel: "W",
group: "stroke",
},
STROKE_ALIGN_PARAM,
];
export const ellipseGraphicDefinition: GraphicDefinition = {
id: "ellipse",
name: "Ellipse",
keywords: ["ellipse", "circle", "oval"],
params: ELLIPSE_PARAMS,
render({ ctx, params, width, height }) {
const fill = String(params.fill ?? "#ffffff");
const stroke = String(params.stroke ?? "#000000");
const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
const centerX = width / 2;
const centerY = height / 2;
const radiusX = Math.max(1, width / 2 - inset);
const radiusY = Math.max(1, height / 2 - inset);
ctx.clearRect(0, 0, width, height);
const path = new Path2D();
path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill(path);
if (strokeWidth > 0) {
applyAlignedStroke({
ctx,
path,
strokeWidth,
strokeAlign,
strokeColor: stroke,
});
}
},
};
@@ -0,0 +1,29 @@
import { graphicsRegistry } from "../registry";
import { ellipseGraphicDefinition } from "./ellipse";
import { polygonGraphicDefinition } from "./polygon";
import { rectangleGraphicDefinition } from "./rectangle";
import { starGraphicDefinition } from "./star";
const defaultGraphicDefinitions = [
rectangleGraphicDefinition,
ellipseGraphicDefinition,
polygonGraphicDefinition,
starGraphicDefinition,
];
export function registerDefaultGraphics(): void {
for (const definition of defaultGraphicDefinitions) {
if (graphicsRegistry.has(definition.id)) {
continue;
}
graphicsRegistry.register(definition.id, definition);
}
}
export {
ellipseGraphicDefinition,
polygonGraphicDefinition,
rectangleGraphicDefinition,
starGraphicDefinition,
};
export { STROKE_ALIGN_PARAM } from "./shared";
@@ -0,0 +1,211 @@
import type { ParamDefinition } from "@/lib/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
interface Point {
x: number;
y: number;
}
interface PolygonParams {
fill: string;
stroke: string;
strokeWidth: number;
strokeAlign: GraphicStrokeAlign;
sides: number;
cornerRadius: number;
}
const POLYGON_PARAMS: ParamDefinition<keyof PolygonParams & string>[] = [
{
key: "fill",
label: "Fill",
type: "color",
default: "#ffffff",
},
{
key: "stroke",
label: "Color",
type: "color",
default: "#000000",
group: "stroke",
},
{
key: "strokeWidth",
label: "Width",
type: "number",
default: 0,
min: 0,
max: 64,
step: 1,
shortLabel: "W",
group: "stroke",
},
STROKE_ALIGN_PARAM,
{
key: "sides",
label: "Sides",
type: "number",
default: 5,
min: 3,
max: 12,
step: 1,
shortLabel: "S",
},
{
key: "cornerRadius",
label: "Corner radius",
type: "number",
default: 0,
min: 0,
max: 50,
step: 1,
shortLabel: "R",
},
];
function buildPolygonVertices({
centerX,
centerY,
radius,
sides,
}: {
centerX: number;
centerY: number;
radius: number;
sides: number;
}): Point[] {
return Array.from({ length: sides }, (_, index) => {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / sides;
return {
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
};
});
}
function distance({ a, b }: { a: Point; b: Point }): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function normalize(point: Point): Point {
const length = Math.hypot(point.x, point.y) || 1;
return {
x: point.x / length,
y: point.y / length,
};
}
function traceRoundedPolygonPath({
path,
vertices,
radius,
}: {
path: Path2D;
vertices: Point[];
radius: number;
}): void {
if (vertices.length < 3) {
return;
}
if (radius <= 0) {
path.moveTo(vertices[0].x, vertices[0].y);
for (let index = 1; index < vertices.length; index++) {
path.lineTo(vertices[index].x, vertices[index].y);
}
path.closePath();
return;
}
for (let index = 0; index < vertices.length; index++) {
const previous = vertices[(index - 1 + vertices.length) % vertices.length];
const current = vertices[index];
const next = vertices[(index + 1) % vertices.length];
const toPrevious = normalize({
x: previous.x - current.x,
y: previous.y - current.y,
});
const toNext = normalize({
x: next.x - current.x,
y: next.y - current.y,
});
const angle = Math.acos(
Math.max(-1, Math.min(1, toPrevious.x * toNext.x + toPrevious.y * toNext.y)),
);
const maxOffset =
Math.min(distance({ a: previous, b: current }), distance({ a: current, b: next })) / 2;
const tangentOffset = Math.min(radius / Math.tan(angle / 2), maxOffset);
const start = {
x: current.x + toPrevious.x * tangentOffset,
y: current.y + toPrevious.y * tangentOffset,
};
const end = {
x: current.x + toNext.x * tangentOffset,
y: current.y + toNext.y * tangentOffset,
};
if (index === 0) {
path.moveTo(start.x, start.y);
} else {
path.lineTo(start.x, start.y);
}
path.arcTo(
current.x,
current.y,
end.x,
end.y,
Math.min(radius, maxOffset),
);
}
path.closePath();
}
export const polygonGraphicDefinition: GraphicDefinition = {
id: "polygon",
name: "Polygon",
keywords: ["polygon", "triangle", "pentagon", "hexagon", "diamond"],
params: POLYGON_PARAMS,
render({ ctx, params, width, height }) {
const fill = String(params.fill ?? "#ffffff");
const stroke = String(params.stroke ?? "#000000");
const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
const sides = Math.max(3, Math.min(12, Math.round(Number(params.sides ?? 5))));
const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
const radius = Math.max(1, Math.min(width, height) / 2 - inset);
const maxCornerRadius = radius * Math.sin(Math.PI / sides);
const cornerRadiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
const cornerRadius =
maxCornerRadius * Math.min(cornerRadiusPercent, 50) / 50;
const vertices = buildPolygonVertices({
centerX: width / 2,
centerY: height / 2,
radius,
sides,
});
ctx.clearRect(0, 0, width, height);
const path = new Path2D();
traceRoundedPolygonPath({
path,
vertices,
radius: cornerRadius,
});
ctx.fillStyle = fill;
ctx.fill(path);
if (strokeWidth > 0) {
applyAlignedStroke({
ctx,
path,
strokeWidth,
strokeAlign,
strokeColor: stroke,
});
}
},
};
@@ -0,0 +1,85 @@
import type { ParamDefinition } from "@/lib/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
interface RectangleParams {
fill: string;
stroke: string;
strokeWidth: number;
strokeAlign: GraphicStrokeAlign;
cornerRadius: number;
}
const RECTANGLE_PARAMS: ParamDefinition<keyof RectangleParams & string>[] = [
{
key: "fill",
label: "Fill",
type: "color",
default: "#ffffff",
},
{
key: "stroke",
label: "Color",
type: "color",
default: "#000000",
group: "stroke",
},
{
key: "strokeWidth",
label: "Width",
type: "number",
default: 0,
min: 0,
max: 64,
step: 1,
shortLabel: "W",
group: "stroke",
},
STROKE_ALIGN_PARAM,
{
key: "cornerRadius",
label: "Corner radius",
type: "number",
default: 0,
min: 0,
max: 50,
step: 1,
shortLabel: "R",
},
];
export const rectangleGraphicDefinition: GraphicDefinition = {
id: "rectangle",
name: "Rectangle",
keywords: ["rectangle", "square", "box"],
params: RECTANGLE_PARAMS,
render({ ctx, params, width, height }) {
const fill = String(params.fill ?? "#ffffff");
const stroke = String(params.stroke ?? "#000000");
const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
const drawWidth = Math.max(1, width - inset * 2);
const drawHeight = Math.max(1, height - inset * 2);
const radiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
const radius =
(Math.min(drawWidth, drawHeight) / 2) * Math.min(radiusPercent, 50) / 50;
ctx.clearRect(0, 0, width, height);
const path = new Path2D();
path.roundRect(inset, inset, drawWidth, drawHeight, radius);
ctx.fillStyle = fill;
ctx.fill(path);
if (strokeWidth > 0) {
applyAlignedStroke({
ctx,
path,
strokeWidth,
strokeAlign,
strokeColor: stroke,
});
}
},
};
@@ -0,0 +1,16 @@
import type { ParamDefinition } from "@/lib/params";
export type GraphicStrokeAlign = "inside" | "center" | "outside";
export const STROKE_ALIGN_PARAM: ParamDefinition<"strokeAlign"> = {
key: "strokeAlign",
label: "Stroke align",
type: "select",
default: "center",
group: "stroke",
options: [
{ value: "inside", label: "Inside" },
{ value: "center", label: "Center" },
{ value: "outside", label: "Outside" },
],
};
@@ -0,0 +1,109 @@
import type { ParamDefinition } from "@/lib/params";
import { applyAlignedStroke } from "../stroke";
import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
import type { GraphicDefinition } from "../types";
interface StarParams {
fill: string;
stroke: string;
strokeWidth: number;
strokeAlign: GraphicStrokeAlign;
points: number;
depth: number;
}
const STAR_PARAMS: ParamDefinition<keyof StarParams & string>[] = [
{
key: "fill",
label: "Fill",
type: "color",
default: "#ffffff",
},
{
key: "stroke",
label: "Color",
type: "color",
default: "#000000",
group: "stroke",
},
{
key: "strokeWidth",
label: "Width",
type: "number",
default: 0,
min: 0,
max: 64,
step: 1,
shortLabel: "W",
group: "stroke",
},
STROKE_ALIGN_PARAM,
{
key: "points",
label: "Points",
type: "number",
default: 5,
min: 3,
max: 12,
step: 1,
shortLabel: "P",
},
{
key: "depth",
label: "Depth",
type: "number",
default: 45,
min: 1,
max: 99,
step: 1,
shortLabel: "D",
},
];
export const starGraphicDefinition: GraphicDefinition = {
id: "star",
name: "Star",
keywords: ["star", "sparkle", "burst"],
params: STAR_PARAMS,
render({ ctx, params, width, height }) {
const fill = String(params.fill ?? "#ffffff");
const stroke = String(params.stroke ?? "#000000");
const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
const points = Math.max(3, Math.min(12, Math.round(Number(params.points ?? 5))));
const depth = Math.max(1, Math.min(99, Number(params.depth ?? 45))) / 100;
const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
const outerRadius = Math.max(1, Math.min(width, height) / 2 - inset);
const innerRadius = outerRadius * depth;
const centerX = width / 2;
const centerY = height / 2;
ctx.clearRect(0, 0, width, height);
const path = new Path2D();
for (let index = 0; index < points * 2; index++) {
const radius = index % 2 === 0 ? outerRadius : innerRadius;
const angle = -Math.PI / 2 + (index * Math.PI) / points;
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
if (index === 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.closePath();
ctx.fillStyle = fill;
ctx.fill(path);
if (strokeWidth > 0) {
applyAlignedStroke({
ctx,
path,
strokeWidth,
strokeAlign,
strokeColor: stroke,
});
}
},
};
+125
View File
@@ -0,0 +1,125 @@
import { buildDefaultParamValues } from "@/lib/registry";
import type { ParamValues } from "@/lib/params";
import { graphicsRegistry } from "./registry";
import {
registerDefaultGraphics,
ellipseGraphicDefinition,
polygonGraphicDefinition,
rectangleGraphicDefinition,
starGraphicDefinition,
} from "./definitions";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
type GraphicInstance,
type GraphicDefinition,
} from "./types";
const graphicPreviewUrlCache = new Map<string, string>();
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
const FALLBACK_FILL_OPACITY = 0.08;
const FALLBACK_MIN_FONT_SIZE = 12;
const FALLBACK_FONT_SIZE_RATIO = 0.15;
function buildFallbackPreviewUrl({
name,
size,
}: {
name: string;
size: number;
}): string {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
</svg>
`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function getGraphicDefinition({
definitionId,
}: {
definitionId: string;
}): GraphicDefinition {
registerDefaultGraphics();
return graphicsRegistry.get(definitionId);
}
export function buildDefaultGraphicInstance({
definitionId,
}: {
definitionId: string;
}): GraphicInstance {
const definition = getGraphicDefinition({ definitionId });
return {
definitionId,
params: buildDefaultParamValues(definition.params),
};
}
export function resolveGraphicParams(
definition: GraphicDefinition,
params?: ParamValues,
): ParamValues {
return {
...buildDefaultParamValues(definition.params),
...(params ?? {}),
};
}
export function buildGraphicPreviewUrl({
definitionId,
params,
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
}: {
definitionId: string;
params?: ParamValues;
size?: number;
}): string {
const definition = getGraphicDefinition({ definitionId });
const resolvedParams = resolveGraphicParams(definition, params);
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (typeof document === "undefined") {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
return buildFallbackPreviewUrl({ name: definition.name, size });
}
definition.render({
ctx,
params: resolvedParams,
width: size,
height: size,
});
const previewUrl = canvas.toDataURL("image/png");
graphicPreviewUrlCache.set(cacheKey, previewUrl);
return previewUrl;
}
export {
DEFAULT_GRAPHIC_SOURCE_SIZE,
ellipseGraphicDefinition,
graphicsRegistry,
polygonGraphicDefinition,
rectangleGraphicDefinition,
registerDefaultGraphics,
starGraphicDefinition,
};
export type {
GraphicDefinition,
GraphicInstance,
GraphicRenderContext,
} from "./types";
+10
View File
@@ -0,0 +1,10 @@
import { DefinitionRegistry } from "@/lib/registry";
import type { GraphicDefinition } from "./types";
export class GraphicsRegistry extends DefinitionRegistry<string, GraphicDefinition> {
constructor() {
super("graphic");
}
}
export const graphicsRegistry = new GraphicsRegistry();
+102
View File
@@ -0,0 +1,102 @@
import type { GraphicStrokeAlign } from "./definitions/shared";
type GraphicRenderContext =
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
function createTempCanvas({
width,
height,
}: {
width: number;
height: number;
}): OffscreenCanvas | HTMLCanvasElement {
try {
return new OffscreenCanvas(width, height);
} catch {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
}
function applyStroke({
ctx,
path,
strokeWidth,
strokeColor,
}: {
ctx: GraphicRenderContext;
path: Path2D;
strokeWidth: number;
strokeColor: string;
}) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.stroke(path);
}
export function applyAlignedStroke({
ctx,
path,
strokeWidth,
strokeAlign,
strokeColor,
}: {
ctx: GraphicRenderContext;
path: Path2D;
strokeWidth: number;
strokeAlign: GraphicStrokeAlign;
strokeColor: string;
}): void {
if (strokeWidth <= 0) {
return;
}
if (strokeAlign === "inside") {
ctx.save();
ctx.clip(path);
applyStroke({
ctx,
path,
strokeWidth: strokeWidth * 2,
strokeColor,
});
ctx.restore();
return;
}
if (strokeAlign === "outside") {
const strokeCanvas = createTempCanvas({
width: ctx.canvas.width,
height: ctx.canvas.height,
});
const strokeCtx = strokeCanvas.getContext("2d") as GraphicRenderContext | null;
if (!strokeCtx) {
return;
}
applyStroke({
ctx: strokeCtx,
path,
strokeWidth: strokeWidth * 2,
strokeColor,
});
// Keep only the outer half of the doubled stroke so alpha fills do not
// leave a visible inner stroke behind.
strokeCtx.globalCompositeOperation = "destination-out";
strokeCtx.fill(path);
ctx.drawImage(strokeCanvas, 0, 0);
return;
}
applyStroke({
ctx,
path,
strokeWidth,
strokeColor,
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { ParamDefinition, ParamValues } from "@/lib/params";
export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
export interface GraphicRenderContext {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
params: ParamValues;
width: number;
height: number;
}
export interface GraphicDefinition {
id: string;
name: string;
keywords: string[];
params: ParamDefinition[];
render(context: GraphicRenderContext): void;
}
export interface GraphicInstance {
definitionId: string;
params: ParamValues;
}
@@ -0,0 +1,24 @@
import { PlusSignIcon, RulerIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import type { GuideDefinition } from "@/lib/guides/types";
function CustomGuideOptions() {
return (
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
<HugeiconsIcon icon={PlusSignIcon} />
Add guide line
</Button>
</div>
);
}
export const customGuide = {
id: "custom",
label: "Custom",
renderPreview: () => <HugeiconsIcon size={16} icon={RulerIcon} />,
renderTriggerIcon: () => <HugeiconsIcon icon={RulerIcon} />,
renderOverlay: () => null,
renderOptions: () => <CustomGuideOptions />,
} as const satisfies GuideDefinition;
@@ -0,0 +1,125 @@
import {
GridTableIcon,
LayoutThreeColumnIcon,
LayoutThreeRowIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { NumberField } from "@/components/ui/number-field";
import {
GRID_MIN,
GRID_MAX,
DEFAULT_GRID_CONFIG,
} from "@/constants/guide-constants";
import { usePreviewStore } from "@/stores/preview-store";
import { clampRound } from "@/utils/math";
import { cn } from "@/utils/ui";
import type { GuideDefinition } from "@/lib/guides/types";
function GridLines({
rows,
cols,
color,
}: {
rows: number;
cols: number;
color: string;
}) {
const verticals = Array.from(
{ length: cols - 1 },
(_, i) => ((i + 1) / cols) * 100,
);
const horizontals = Array.from(
{ length: rows - 1 },
(_, i) => ((i + 1) / rows) * 100,
);
return (
<>
{verticals.map((pct) => (
<div
key={`v-${pct}`}
className={cn("absolute top-0 bottom-0 w-px", color)}
style={{ left: `${pct}%` }}
/>
))}
{horizontals.map((pct) => (
<div
key={`h-${pct}`}
className={cn("absolute left-0 right-0 h-px", color)}
style={{ top: `${pct}%` }}
/>
))}
</>
);
}
function GridGuidePreview() {
return (
<div className="relative aspect-video w-full">
<GridLines rows={3} cols={4} color="bg-foreground/15" />
</div>
);
}
function GridGuideOverlay() {
const { rows, cols } = usePreviewStore((s) => s.gridConfig);
return (
<div className="absolute inset-0">
<GridLines rows={rows} cols={cols} color="bg-white/35" />
</div>
);
}
function GridGuideOptions() {
const rows = usePreviewStore((s) => s.gridConfig.rows);
const cols = usePreviewStore((s) => s.gridConfig.cols);
const setGridConfig = usePreviewStore((s) => s.setGridConfig);
const clampGridValue = (value: number) =>
clampRound({ value, min: GRID_MIN, max: GRID_MAX });
return (
<div className="flex gap-2">
<NumberField
icon={<HugeiconsIcon icon={LayoutThreeRowIcon} />}
value={rows}
min={GRID_MIN}
max={GRID_MAX}
isDefault={rows === DEFAULT_GRID_CONFIG.rows}
onReset={() => setGridConfig({ rows: DEFAULT_GRID_CONFIG.rows })}
onScrub={(value) => setGridConfig({ rows: clampGridValue(value) })}
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(parsed))
setGridConfig({ rows: clampGridValue(parsed) });
}}
className="flex-1"
/>
<NumberField
icon={<HugeiconsIcon icon={LayoutThreeColumnIcon} />}
value={cols}
min={GRID_MIN}
max={GRID_MAX}
isDefault={cols === DEFAULT_GRID_CONFIG.cols}
onReset={() => setGridConfig({ cols: DEFAULT_GRID_CONFIG.cols })}
onScrub={(value) => setGridConfig({ cols: clampGridValue(value) })}
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(parsed))
setGridConfig({ cols: clampGridValue(parsed) });
}}
className="flex-1"
/>
</div>
);
}
export const gridGuide = {
id: "grid",
label: "Grid",
renderPreview: () => <GridGuidePreview />,
renderTriggerIcon: () => <HugeiconsIcon icon={GridTableIcon} />,
renderOverlay: () => <GridGuideOverlay />,
renderOptions: () => <GridGuideOptions />,
} as const satisfies GuideDefinition;
@@ -0,0 +1,65 @@
import Image from "next/image";
import type { GuideDefinition } from "@/lib/guides/types";
import { TikTokLayout } from "./tiktok-layout";
function PlatformLogo({
domain,
className = "size-4",
}: {
domain: string;
className?: string;
}) {
return (
<Image
src={`https://cdn.brandfetch.io/${domain}/w/64/h/64`}
alt=""
width={18}
height={18}
className={className}
draggable={false}
unoptimized
/>
);
}
function PlatformGuidePreview({ domain }: { domain: string }) {
return <PlatformLogo domain={domain} />;
}
function platformGuide({
id,
label,
domain,
}: {
id: string;
label: string;
domain: string;
}): GuideDefinition {
return {
id,
label,
renderPreview: () => <PlatformGuidePreview domain={domain} />,
renderTriggerIcon: () => <PlatformLogo domain={domain} />,
renderOverlay: () => null,
};
}
export const tiktokGuide: GuideDefinition = {
...platformGuide({ id: "tiktok", label: "TikTok", domain: "tiktok.com" }),
renderOverlay: () => <TikTokLayout />,
};
export const igReelsGuide = platformGuide({
id: "ig-reels",
label: "Reels",
domain: "instagram.com",
});
export const ytShortsGuide = platformGuide({
id: "yt-shorts",
label: "Shorts",
domain: "youtube.com",
});
export const spotlightGuide = platformGuide({
id: "spotlight",
label: "Spotlight",
domain: "snapchat.com",
});
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
import { GUIDE_REGISTRY } from "./registry";
import type { GuideDefinition } from "@/lib/guides/types";
export { GUIDE_REGISTRY, isGuideId } from "./registry";
export type { GuideDefinition, GuideId, GuideRenderProps } from "./registry";
export function getGuideById(guideId: string | null): GuideDefinition | null {
if (!guideId) {
return null;
}
return GUIDE_REGISTRY.find((guide) => guide.id === guideId) ?? null;
}
+28
View File
@@ -0,0 +1,28 @@
import type { GuideDefinition } from "@/lib/guides/types";
import { gridGuide } from "./definitions/grid";
// import { customGuide } from "./definitions/custom";
import {
tiktokGuide,
igReelsGuide,
ytShortsGuide,
spotlightGuide,
} from "./definitions/platforms";
export type { GuideDefinition, GuideRenderProps } from "@/lib/guides/types";
export const GUIDE_REGISTRY = [
gridGuide,
tiktokGuide,
igReelsGuide,
ytShortsGuide,
spotlightGuide,
// todo: wire up custom guide fully, then uncomment this:
// customGuide,
] as const satisfies readonly GuideDefinition[];
export type GuideId = (typeof GUIDE_REGISTRY)[number]["id"];
export function isGuideId(value: string): value is GuideId {
return GUIDE_REGISTRY.some((guide) => guide.id === value);
}
+20
View File
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
export interface GridConfig {
rows: number;
cols: number;
}
export interface GuideRenderProps {
width: number;
height: number;
}
export interface GuideDefinition {
id: string;
label: string;
renderPreview: () => ReactNode;
renderTriggerIcon: () => ReactNode;
renderOverlay: (props: GuideRenderProps) => ReactNode;
renderOptions?: () => ReactNode;
}
-241
View File
@@ -1,241 +0,0 @@
export const ICONIFY_HOSTS = [
"https://api.iconify.design",
"https://api.simplesvg.com",
"https://api.unisvg.com",
];
let currentHost = ICONIFY_HOSTS[0];
async function fetchWithFallback(path: string): Promise<Response> {
for (const host of ICONIFY_HOSTS) {
try {
const response = await fetch(`${host}${path}`, {
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
currentHost = host;
return response;
}
} catch (error) {
console.warn(`Failed to fetch from ${host}:`, error);
}
}
throw new Error("All API hosts failed");
}
export interface IconSet {
prefix: string;
name: string;
total: number;
author?: {
name: string;
url?: string;
};
license?: {
title: string;
spdx?: string;
url?: string;
};
samples?: string[];
category?: string;
palette?: boolean;
}
export interface IconSearchResult {
icons: string[];
total: number;
limit: number;
start: number;
collections: Record<string, IconSet>;
}
export interface CollectionInfo {
prefix: string;
total: number;
title?: string;
uncategorized?: string[];
categories?: Record<string, string[]>;
hidden?: string[];
aliases?: Record<string, string>;
}
export async function getCollections(
category?: string,
): Promise<Record<string, IconSet>> {
try {
const response = await fetchWithFallback("/collections?pretty=1");
const data = (await response.json()) as Record<string, IconSet>;
if (category) {
const filtered = Object.fromEntries(
Object.entries(data).filter(
([_key, info]) => info.category === category,
),
) as Record<string, IconSet>;
return filtered;
}
return data;
} catch (error) {
console.error("Failed to fetch collections:", error);
return {};
}
}
export async function getCollection(
prefix: string,
): Promise<CollectionInfo | null> {
try {
const response = await fetchWithFallback(
`/collection?prefix=${prefix}&pretty=1`,
);
return await response.json();
} catch (error) {
console.error(`Failed to fetch collection ${prefix}:`, error);
return null;
}
}
export async function searchIcons(
query: string,
limit: number = 64,
prefixes?: string[],
category?: string,
): Promise<IconSearchResult> {
const params = new URLSearchParams({
query,
limit: limit.toString(),
pretty: "1",
});
if (prefixes?.length) {
params.append("prefixes", prefixes.join(","));
}
if (category) {
params.append("category", category);
}
try {
const response = await fetchWithFallback(`/search?${params}`);
return await response.json();
} catch (error) {
console.error("Failed to search icons:", error);
return {
icons: [],
total: 0,
limit,
start: 0,
collections: {},
};
}
}
export function buildIconSvgUrl(
host: string,
iconName: string,
params?: {
color?: string;
width?: number;
height?: number;
flip?: "horizontal" | "vertical" | "horizontal,vertical";
rotate?: number | string;
},
): string {
const [prefix, name] = iconName.includes(":")
? iconName.split(":")
: ["", iconName];
if (!prefix || !name) {
throw new Error('Invalid icon name format. Expected "prefix:name"');
}
const urlParams = new URLSearchParams();
if (params?.color) {
urlParams.append("color", params.color.replace("#", "%23"));
}
if (params?.width) {
urlParams.append("width", params.width.toString());
}
if (params?.height) {
urlParams.append("height", params.height.toString());
}
if (params?.flip) {
urlParams.append("flip", params.flip);
}
if (params?.rotate) {
urlParams.append("rotate", params.rotate.toString());
}
const queryString = urlParams.toString();
return `${host}/${prefix}/${name}.svg${queryString ? `?${queryString}` : ""}`;
}
export function getIconSvgUrl(
iconName: string,
params?: Parameters<typeof buildIconSvgUrl>[2],
): string {
return buildIconSvgUrl(currentHost, iconName, params);
}
export async function downloadSvgAsText(
iconName: string,
params?: Parameters<typeof getIconSvgUrl>[1],
): Promise<string> {
const url = getIconSvgUrl(iconName, params);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download SVG: ${response.statusText}`);
}
return await response.text();
}
export function svgToFile(svgText: string, fileName: string): File {
const blob = new Blob([svgText], { type: "image/svg+xml" });
return new File([blob], fileName, { type: "image/svg+xml" });
}
export const POPULAR_COLLECTIONS = {
general: [
{ prefix: "mdi", name: "Material Design Icons" },
{ prefix: "ic", name: "Google Material Icons" },
{ prefix: "ph", name: "Phosphor" },
{ prefix: "heroicons", name: "Heroicons" },
{ prefix: "lucide", name: "Lucide" },
{ prefix: "tabler", name: "Tabler Icons" },
{ prefix: "fe", name: "Feather Icons" },
{ prefix: "bi", name: "Bootstrap Icons" },
],
brands: [
{ prefix: "simple-icons", name: "Simple Icons" },
{ prefix: "logos", name: "SVG Logos" },
{ prefix: "skill-icons", name: "Skill Icons" },
{ prefix: "devicon", name: "Devicon" },
{ prefix: "fa-brands", name: "Font Awesome Brands" },
],
emoji: [
{ prefix: "noto", name: "Noto Emoji" },
{ prefix: "twemoji", name: "Twemoji" },
{ prefix: "fluent-emoji", name: "Fluent Emoji" },
{ prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" },
{ prefix: "emojione", name: "EmojiOne" },
{ prefix: "openmoji", name: "OpenMoji" },
],
};
export function getCategoriesFromCollections(
collections: Record<string, IconSet>,
): string[] {
const categories = new Set<string>();
Object.values(collections).forEach((collection) => {
if (collection.category) {
categories.add(collection.category);
}
});
return Array.from(categories).sort();
}
@@ -0,0 +1,235 @@
import { describe, expect, test } from "bun:test";
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds";
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
const bounds: ElementBounds = {
cx: 200,
cy: 150,
width: 200,
height: 100,
rotation: 0,
};
const canvasSize = {
width: 400,
height: 300,
};
const snapThreshold = {
x: 8,
y: 8,
};
function buildSplitParams(
overrides: Partial<SplitMaskParams> = {},
): SplitMaskParams {
return {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
centerX: 0,
centerY: 0,
rotation: 0,
...overrides,
};
}
function buildRectangleParams(
overrides: Partial<RectangleMaskParams> = {},
): RectangleMaskParams {
return {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
centerX: 0,
centerY: 0,
width: 0.4,
height: 0.2,
rotation: 0,
scale: 1,
...overrides,
};
}
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
{ x: number; y: number },
{ x: number; y: number },
];
}
describe("mask geometry", () => {
test("resolves split mask center from centerX and centerY", () => {
expect(
getMaskSnapGeometry({
params: buildSplitParams({
centerX: 0.25,
centerY: -0.5,
rotation: 45,
}),
bounds,
}),
).toEqual({
position: { x: 50, y: -50 },
size: { width: 0, height: 0 },
rotation: 45,
});
});
test("resolves box mask center and size from centerX and centerY", () => {
expect(
getMaskSnapGeometry({
params: buildRectangleParams({
centerX: -0.25,
centerY: 0.5,
width: 0.5,
height: 0.6,
rotation: 30,
}),
bounds,
}),
).toEqual({
position: { x: -50, y: 50 },
size: { width: 100, height: 60 },
rotation: 30,
});
});
test("returns a vertical split stroke segment for rotation 0", () => {
const segment = getSplitMaskStrokeSegment({
resolvedParams: buildSplitParams(),
width: bounds.width,
height: bounds.height,
});
expect(segment).not.toBeNull();
if (!segment) {
throw new Error("Expected split stroke segment for rotation 0");
}
expect(sortSegment(segment)).toEqual([
{ x: bounds.width / 2, y: 0 },
{ x: bounds.width / 2, y: bounds.height },
]);
});
test("returns a horizontal split stroke segment for rotation 90", () => {
const segment = getSplitMaskStrokeSegment({
resolvedParams: buildSplitParams({ rotation: 90 }),
width: bounds.width,
height: bounds.height,
});
expect(segment).not.toBeNull();
if (!segment) {
throw new Error("Expected split stroke segment for rotation 90");
}
expect(sortSegment(segment)).toEqual([
{ x: 0, y: bounds.height / 2 },
{ x: bounds.width, y: bounds.height / 2 },
]);
});
});
describe("mask snapping", () => {
test("snaps split mask movement using the shared position pipeline", () => {
const result = snapMaskInteraction({
handleId: "position",
startParams: buildSplitParams({
centerX: 0.03,
centerY: -0.04,
}),
proposedParams: buildSplitParams({
centerX: 0.03,
centerY: -0.04,
}),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.centerX).toBe(0);
expect(result.params.centerY).toBe(0);
expect(result.activeLines).toEqual([
{ type: "vertical", position: 0 },
{ type: "horizontal", position: 0 },
]);
});
test("snaps box mask movement against element center and edges", () => {
const result = snapMaskInteraction({
handleId: "position",
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
centerX: 0.29,
centerY: 0.03,
}),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.centerX).toBeCloseTo(0.3);
expect(result.params.centerY).toBe(0);
expect(result.activeLines).toEqual([
{ type: "vertical", position: 100 },
{ type: "horizontal", position: 0 },
]);
});
test("snaps mask rotation through the shared rotation path", () => {
const result = snapMaskInteraction({
handleId: "rotation",
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
rotation: 88,
}),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.rotation).toBe(90);
expect(result.activeLines).toEqual([]);
});
test("snaps edge resize for box masks", () => {
const result = snapMaskInteraction({
handleId: "right",
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.98,
}),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.width).toBe(1);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
test("snaps corner resize for box masks", () => {
const result = snapMaskInteraction({
handleId: "bottom-right",
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.99,
height: 0.495,
}),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.width).toBe(1);
expect(result.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
});
@@ -0,0 +1,270 @@
import {
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
MIN_MASK_DIMENSION,
} from "@/constants/mask-constants";
import { computeFeatherUpdate } from "../param-update";
import type {
BaseMaskParams,
MaskDefaultContext,
MaskParamUpdateArgs,
RectangleMaskParams,
} from "@/lib/masks/types";
import type {
NumberParamDefinition,
ParamDefinition,
ParamValues,
} from "@/lib/params";
const PERCENTAGE_DISPLAY: Pick<
NumberParamDefinition,
"displayMultiplier" | "step"
> = {
displayMultiplier: 100,
step: 1,
};
export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
keyof RectangleMaskParams & string
>[] = [
{
key: "centerX",
label: "X",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "centerY",
label: "Y",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "width",
label: "Width",
type: "number",
default: 0.6,
min: 1,
...PERCENTAGE_DISPLAY,
},
{
key: "height",
label: "Height",
type: "number",
default: 0.6,
min: 1,
...PERCENTAGE_DISPLAY,
},
{
key: "rotation",
label: "Rotation",
type: "number",
default: 0,
min: 0,
max: 360,
step: 1,
},
{
key: "scale",
label: "Scale",
type: "number",
default: 1,
min: 1,
max: 500,
...PERCENTAGE_DISPLAY,
},
{
key: "strokeAlign",
label: "Stroke Align",
type: "select",
default: "center",
options: [
{ value: "inside", label: "Inside" },
{ value: "center", label: "Center" },
{ value: "outside", label: "Outside" },
],
},
];
export function getDefaultBaseMaskParams(): BaseMaskParams {
return {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
};
}
export function getStrokeOffset({
strokeAlign,
strokeWidth,
}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
if (strokeAlign === "inside") {
return -(strokeWidth / 2);
}
if (strokeAlign === "outside") {
return strokeWidth / 2;
}
return 0;
}
export function getDefaultSquareMaskParams({
elementSize,
}: MaskDefaultContext): RectangleMaskParams {
const absWidth = Math.abs(elementSize?.width ?? 0);
const absHeight = Math.abs(elementSize?.height ?? 0);
const shortSide = Math.min(absWidth, absHeight);
const squareSide =
shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
const width =
absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
const height =
absHeight > 0
? squareSide / absHeight
: DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
return {
...getDefaultBaseMaskParams(),
centerX: 0,
centerY: 0,
width,
height,
rotation: 0,
scale: 1,
};
}
export function getBoxLikeGeometry({
params,
width,
height,
}: {
params: RectangleMaskParams;
width: number;
height: number;
}) {
return {
centerX: width / 2 + params.centerX * width,
centerY: height / 2 + params.centerY * height,
maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
rotationRad: (params.rotation * Math.PI) / 180,
};
}
export function computeBoxMaskParamUpdate({
handleId,
startParams,
deltaX,
deltaY,
bounds,
}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
if (handleId === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
};
}
if (handleId === "rotation") {
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
const newRotation = (startParams.rotation + currentAngle) % 360;
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
}
if (handleId === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
deltaX,
deltaY,
directionX: -Math.sin(angleRad),
directionY: Math.cos(angleRad),
});
}
const halfWidth = startParams.width * bounds.width;
const halfHeight = startParams.height * bounds.height;
if (handleId === "right" || handleId === "left") {
const sign = handleId === "right" ? 1 : -1;
return {
width: Math.max(
MIN_MASK_DIMENSION,
startParams.width + (sign * deltaX * 2) / bounds.width,
),
};
}
if (handleId === "bottom" || handleId === "top") {
const sign = handleId === "bottom" ? 1 : -1;
return {
height: Math.max(
MIN_MASK_DIMENSION,
startParams.height + (sign * deltaY * 2) / bounds.height,
),
};
}
if (
handleId === "top-left" ||
handleId === "top-right" ||
handleId === "bottom-left" ||
handleId === "bottom-right"
) {
const signX = handleId.includes("right") ? 1 : -1;
const signY = handleId.includes("bottom") ? 1 : -1;
const distance = Math.sqrt(
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
);
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
const scale = originalDistance > 0 ? distance / originalDistance : 1;
return {
width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
};
}
if (handleId === "scale") {
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
return {
scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
};
}
return {};
}
export function rotatePoint({
x,
y,
centerX,
centerY,
rotationRad,
}: {
x: number;
y: number;
centerX: number;
centerY: number;
rotationRad: number;
}) {
const dx = x - centerX;
const dy = y - centerY;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
return {
x: centerX + dx * cos - dy * sin,
y: centerY + dx * sin + dy * cos,
};
}
@@ -0,0 +1,72 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
getStrokeOffset,
} from "./box-like";
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "ellipse",
name: "Ellipse",
overlayShape: "box",
buildOverlayPath({ width, height }) {
const rx = Math.max((width - 1) / 2, 0);
const ry = Math.max((height - 1) / 2, 0);
const cx = width / 2;
const cy = height / 2;
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
},
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
buildDefault(context) {
return {
type: "ellipse",
params: getDefaultSquareMaskParams(context),
};
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const path = new Path2D();
path.ellipse(
centerX,
centerY,
maskWidth / 2,
maskHeight / 2,
rotationRad,
0,
Math.PI * 2,
);
return path;
},
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
const path = new Path2D();
path.ellipse(
centerX,
centerY,
Math.max(1, maskWidth / 2 + offset),
Math.max(1, maskHeight / 2 + offset),
rotationRad,
0,
Math.PI * 2,
);
return path;
},
},
};
@@ -0,0 +1,39 @@
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
import { masksRegistry, type MaskIconProps } from "../registry";
import { ellipseMaskDefinition } from "./ellipse";
import { rectangleMaskDefinition } from "./rectangle";
import { splitMaskDefinition } from "./split";
import {
PanelRightDashedIcon,
SquareIcon,
CircleIcon,
} from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({
definition,
icon,
}: {
definition: MaskDefinition<TParams>;
icon: MaskIconProps;
}) {
if (masksRegistry.has(definition.type)) {
return;
}
masksRegistry.registerMask({ definition, icon });
}
export function registerDefaultMasks(): void {
registerDefaultMask({
definition: splitMaskDefinition,
icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
});
registerDefaultMask({
definition: rectangleMaskDefinition,
icon: { icon: SquareIcon },
});
registerDefaultMask({
definition: ellipseMaskDefinition,
icon: { icon: CircleIcon },
});
}
@@ -0,0 +1,94 @@
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
} from "./box-like";
function buildRectanglePath({
centerX,
centerY,
halfWidth,
halfHeight,
rotationRad,
}: {
centerX: number;
centerY: number;
halfWidth: number;
halfHeight: number;
rotationRad: number;
}): Path2D {
const corners = [
{ x: centerX - halfWidth, y: centerY - halfHeight },
{ x: centerX + halfWidth, y: centerY - halfHeight },
{ x: centerX + halfWidth, y: centerY + halfHeight },
{ x: centerX - halfWidth, y: centerY + halfHeight },
].map((point) =>
rotatePoint({
...point,
centerX,
centerY,
rotationRad,
}),
);
const path = new Path2D();
path.moveTo(corners[0].x, corners[0].y);
for (const corner of corners.slice(1)) {
path.lineTo(corner.x, corner.y);
}
path.closePath();
return path;
}
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
type: "rectangle",
name: "Rectangle",
overlayShape: "box",
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
buildDefault(context) {
return {
type: "rectangle",
params: getDefaultSquareMaskParams(context),
};
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
return buildRectanglePath({
centerX,
centerY,
halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2,
rotationRad,
});
},
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
return buildRectanglePath({
centerX,
centerY,
halfWidth: Math.max(1, maskWidth / 2 + offset),
halfHeight: Math.max(1, maskHeight / 2 + offset),
rotationRad,
});
},
},
};

Some files were not shown because too many files have changed in this diff Show More