mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user