diff options
Diffstat (limited to 'apps/web/src/lib')
163 files changed, 16993 insertions, 16854 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index f6bdd65c..e48126d0 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -1,205 +1,205 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/lib/actions/keybinding"; -import type { TActionWithOptionalArgs } from "./types"; - -export type TActionCategory = - | "playback" - | "navigation" - | "editing" - | "selection" - | "history" - | "timeline" - | "controls" - | "assets"; - -export interface TActionBaseDefinition { - description: string; - category: TActionCategory; - args?: Record<string, unknown>; -} - -export interface TActionDefinition extends TActionBaseDefinition { - defaultShortcuts?: readonly ShortcutKey[]; -} - -export const ACTIONS = { - "toggle-play": { - description: "Play/Pause", - category: "playback", - }, - "stop-playback": { - description: "Stop playback", - category: "playback", - }, - "seek-forward": { - description: "Seek forward 1 second", - category: "playback", - args: { seconds: "number" }, - }, - "seek-backward": { - description: "Seek backward 1 second", - category: "playback", - args: { seconds: "number" }, - }, - "frame-step-forward": { - description: "Frame step forward", - category: "navigation", - }, - "frame-step-backward": { - description: "Frame step backward", - category: "navigation", - }, - "jump-forward": { - description: "Jump forward 5 seconds", - category: "navigation", - args: { seconds: "number" }, - }, - "jump-backward": { - description: "Jump backward 5 seconds", - category: "navigation", - args: { seconds: "number" }, - }, - "goto-start": { - description: "Go to timeline start", - category: "navigation", - }, - "goto-end": { - description: "Go to timeline end", - category: "navigation", - }, - split: { - description: "Split elements at playhead", - category: "editing", - }, - "split-left": { - description: "Split and remove left", - category: "editing", - }, - "split-right": { - description: "Split and remove right", - category: "editing", - }, - "delete-selected": { - description: "Delete selected elements", - category: "editing", - }, - "copy-selected": { - description: "Copy selected elements", - category: "editing", - }, - "paste-copied": { - description: "Paste elements at playhead", - category: "editing", - }, - "toggle-snapping": { - description: "Toggle snapping", - category: "editing", - }, - "toggle-ripple-editing": { - description: "Toggle ripple editing", - category: "editing", - }, - "select-all": { - description: "Select all elements", - category: "selection", - }, - "cancel-interaction": { - description: "Cancel current interaction", - category: "controls", - }, - "deselect-all": { - description: "Deselect all elements", - category: "selection", - }, - "duplicate-selected": { - description: "Duplicate selected element", - category: "selection", - }, - "toggle-elements-muted-selected": { - description: "Mute/unmute selected elements", - category: "selection", - }, - "toggle-elements-visibility-selected": { - description: "Show/hide selected elements", - category: "selection", - }, - "toggle-bookmark": { - description: "Toggle bookmark at playhead", - category: "timeline", - }, - undo: { - description: "Undo", - category: "history", - }, - redo: { - description: "Redo", - category: "history", - }, - "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, TActionBaseDefinition>; - -export type TAction = keyof typeof ACTIONS; - -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; - -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record<TAction, readonly ShortcutKey[]> -> = ACTION_DEFAULT_SHORTCUTS; - -export function getActionDefinition({ - action, -}: { - action: TAction; -}): TActionDefinition { - return { - ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], - }; -} - -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; - - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { - for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; - } - } - - return shortcuts; -} +import type {
+ KeybindingConfig,
+ ShortcutKey,
+} from "@/lib/actions/keybinding";
+import type { TActionWithOptionalArgs } from "./types";
+
+export type TActionCategory =
+ | "playback"
+ | "navigation"
+ | "editing"
+ | "selection"
+ | "history"
+ | "timeline"
+ | "controls"
+ | "assets";
+
+export interface TActionBaseDefinition {
+ description: string;
+ category: TActionCategory;
+ args?: Record<string, unknown>;
+}
+
+export interface TActionDefinition extends TActionBaseDefinition {
+ defaultShortcuts?: readonly ShortcutKey[];
+}
+
+export const ACTIONS = {
+ "toggle-play": {
+ description: "Play/Pause",
+ category: "playback",
+ },
+ "stop-playback": {
+ description: "Stop playback",
+ category: "playback",
+ },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "playback",
+ args: { seconds: "number" },
+ },
+ "frame-step-forward": {
+ description: "Frame step forward",
+ category: "navigation",
+ },
+ "frame-step-backward": {
+ description: "Frame step backward",
+ category: "navigation",
+ },
+ "jump-forward": {
+ description: "Jump forward 5 seconds",
+ category: "navigation",
+ args: { seconds: "number" },
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "navigation",
+ args: { seconds: "number" },
+ },
+ "goto-start": {
+ description: "Go to timeline start",
+ category: "navigation",
+ },
+ "goto-end": {
+ description: "Go to timeline end",
+ category: "navigation",
+ },
+ split: {
+ description: "Split elements at playhead",
+ category: "editing",
+ },
+ "split-left": {
+ description: "Split and remove left",
+ category: "editing",
+ },
+ "split-right": {
+ description: "Split and remove right",
+ category: "editing",
+ },
+ "delete-selected": {
+ description: "Delete selected elements",
+ category: "editing",
+ },
+ "copy-selected": {
+ description: "Copy selected elements",
+ category: "editing",
+ },
+ "paste-copied": {
+ description: "Paste elements at playhead",
+ category: "editing",
+ },
+ "toggle-snapping": {
+ description: "Toggle snapping",
+ category: "editing",
+ },
+ "toggle-ripple-editing": {
+ description: "Toggle ripple editing",
+ category: "editing",
+ },
+ "select-all": {
+ description: "Select all elements",
+ category: "selection",
+ },
+ "cancel-interaction": {
+ description: "Cancel current interaction",
+ category: "controls",
+ },
+ "deselect-all": {
+ description: "Deselect all elements",
+ category: "selection",
+ },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "selection",
+ },
+ "toggle-elements-muted-selected": {
+ description: "Mute/unmute selected elements",
+ category: "selection",
+ },
+ "toggle-elements-visibility-selected": {
+ description: "Show/hide selected elements",
+ category: "selection",
+ },
+ "toggle-bookmark": {
+ description: "Toggle bookmark at playhead",
+ category: "timeline",
+ },
+ undo: {
+ description: "Undo",
+ category: "history",
+ },
+ redo: {
+ description: "Redo",
+ category: "history",
+ },
+ "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, TActionBaseDefinition>;
+
+export type TAction = keyof typeof ACTIONS;
+
+const ACTION_DEFAULT_SHORTCUTS = {
+ "toggle-play": ["space", "k"],
+ "seek-forward": ["l"],
+ "seek-backward": ["j"],
+ "frame-step-forward": ["right"],
+ "frame-step-backward": ["left"],
+ "jump-forward": ["shift+right"],
+ "jump-backward": ["shift+left"],
+ "goto-start": ["home", "enter"],
+ "goto-end": ["end"],
+ split: ["s"],
+ "split-left": ["q"],
+ "split-right": ["w"],
+ "delete-selected": ["backspace", "delete"],
+ "copy-selected": ["ctrl+c"],
+ "paste-copied": ["ctrl+v"],
+ "toggle-snapping": ["n"],
+ "select-all": ["ctrl+a"],
+ "cancel-interaction": ["escape"],
+ "duplicate-selected": ["ctrl+d"],
+ undo: ["ctrl+z"],
+ redo: ["ctrl+shift+z", "ctrl+y"],
+} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
+
+const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
+ Record<TAction, readonly ShortcutKey[]>
+> = ACTION_DEFAULT_SHORTCUTS;
+
+export function getActionDefinition({
+ action,
+}: {
+ action: TAction;
+}): TActionDefinition {
+ return {
+ ...ACTIONS[action],
+ defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
+ };
+}
+
+export function getDefaultShortcuts(): KeybindingConfig {
+ const shortcuts: KeybindingConfig = {};
+
+ for (const [action, defaultShortcuts] of Object.entries(
+ ACTION_DEFAULT_SHORTCUTS,
+ ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
+ for (const shortcut of defaultShortcuts) {
+ shortcuts[shortcut] = action;
+ }
+ }
+
+ return shortcuts;
+}
diff --git a/apps/web/src/lib/actions/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts index 0a61893b..65ef4538 100644 --- a/apps/web/src/lib/actions/keybinding.ts +++ b/apps/web/src/lib/actions/keybinding.ts @@ -1,79 +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; -}; +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;
+};
diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts index 38627b95..41258c7b 100644 --- a/apps/web/src/lib/actions/types.ts +++ b/apps/web/src/lib/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react"; -import type { TAction } from "./definitions"; - -export type { TAction }; - -export type TActionArgsMap = { - "seek-forward": { seconds: number } | undefined; - "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> = { - [K in keyof T]: undefined extends T[K] ? K : never; -}[keyof T]; - -export type TActionWithArgs = keyof TActionArgsMap; - -export type TActionWithOptionalArgs = - | TActionWithNoArgs - | TKeysWithValueUndefined<TActionArgsMap>; - -export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>; - -export type TArgOfAction<A extends TAction> = A extends TActionWithArgs - ? TActionArgsMap[A] - : undefined; - -export type TActionFunc<A extends TAction> = A extends TActionWithArgs - ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void - : (_?: undefined, trigger?: TInvocationTrigger) => void; - -export type TInvocationTrigger = "keypress" | "mouseclick"; - -export type TBoundActionList = { - [A in TAction]?: Array<TActionFunc<A>>; -}; - -export type TActionHandlerOptions = - | MutableRefObject<boolean> - | boolean - | undefined; +import type { MutableRefObject } from "react";
+import type { TAction } from "./definitions";
+
+export type { TAction };
+
+export type TActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined;
+ "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> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+export type TActionWithArgs = keyof TActionArgsMap;
+
+export type TActionWithOptionalArgs =
+ | TActionWithNoArgs
+ | TKeysWithValueUndefined<TActionArgsMap>;
+
+export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
+
+export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
+ ? TActionArgsMap[A]
+ : undefined;
+
+export type TActionFunc<A extends TAction> = A extends TActionWithArgs
+ ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
+ : (_?: undefined, trigger?: TInvocationTrigger) => void;
+
+export type TInvocationTrigger = "keypress" | "mouseclick";
+
+export type TBoundActionList = {
+ [A in TAction]?: Array<TActionFunc<A>>;
+};
+
+export type TActionHandlerOptions =
+ | MutableRefObject<boolean>
+ | boolean
+ | undefined;
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts index a34b469b..c1c05f9a 100644 --- a/apps/web/src/lib/animation/color-channel.ts +++ b/apps/web/src/lib/animation/color-channel.ts @@ -1,19 +1,19 @@ -import type { - AnimationPropertyPath, - ColorAnimationChannel, - ElementAnimations, -} from "@/lib/animation/types"; - -export function getColorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): ColorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "color") { - return undefined; - } - return channel; -} +import type {
+ AnimationPropertyPath,
+ ColorAnimationChannel,
+ ElementAnimations,
+} from "@/lib/animation/types";
+
+export function getColorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): ColorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "color") {
+ return undefined;
+ }
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts index 7db544e3..22f78ba5 100644 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ b/apps/web/src/lib/animation/graphic-param-channel.ts @@ -1,77 +1,77 @@ -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: 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; -} +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: 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;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 3dcddad3..819a59e5 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -1,71 +1,71 @@ -export { - getChannelValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setChannel, - splitAnimationsAtTime, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - type NumericRange, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - 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"; +export {
+ getChannelValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setChannel,
+ splitAnimationsAtTime,
+ upsertElementKeyframe,
+ upsertPathKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "./resolve";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ type NumericRange,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export {
+ getElementKeyframes,
+ 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";
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 637b0289..62c8a171 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,72 +1,72 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import type { - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { isAnimationPath } from "./target-resolver"; - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if ( - !channel || - channel.keyframes.length === 0 || - !isAnimationPath(propertyPath) - ) { - return []; - } - - return channel.keyframes.map((keyframe) => ({ - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - })); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const channel = animations?.channels[propertyPath]; - return Boolean(channel && channel.keyframes.length > 0); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.keyframes.length === 0) return null; - const keyframe = channel.keyframes.find( - (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, - ); - if (!keyframe) return null; - return { - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - }; -} +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import { isAnimationPath } from "./target-resolver";
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.channels).flatMap(
+ ([propertyPath, channel]) => {
+ if (
+ !channel ||
+ channel.keyframes.length === 0 ||
+ !isAnimationPath(propertyPath)
+ ) {
+ return [];
+ }
+
+ return channel.keyframes.map((keyframe) => ({
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ }));
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const channel = animations?.channels[propertyPath];
+ return Boolean(channel && channel.keyframes.length > 0);
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.keyframes.length === 0) return null;
+ const keyframe = channel.keyframes.find(
+ (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ );
+ if (!keyframe) return null;
+ return {
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ };
+}
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts index 859da863..3e193131 100644 --- a/apps/web/src/lib/animation/number-channel.ts +++ b/apps/web/src/lib/animation/number-channel.ts @@ -1,20 +1,20 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - NumberAnimationChannel, -} from "@/lib/animation/types"; - -export function getNumberChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): NumberAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "number") { - return undefined; - } - - return channel; -} +import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/lib/animation/types";
+
+export function getNumberChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): NumberAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "number") {
+ return undefined;
+ }
+
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts index 61b4fc4a..1e475dac 100644 --- a/apps/web/src/lib/animation/property-groups.ts +++ b/apps/web/src/lib/animation/property-groups.ts @@ -1,39 +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; -} +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;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 46990754..6d20e324 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -1,369 +1,369 @@ -import type { - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - AnimationValueKind, - DiscreteValue, - 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, -} from "@/constants/text-constants"; -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"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export type NumericRange = NumericSpec; - -export interface AnimationPropertyDefinition { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.position": { - valueKind: "vector", - defaultInterpolation: "linear", - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: value as VectorValue, - }, - } - : element, - }, - "transform.scaleX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }, - "transform.scaleY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - 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, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }, - opacity: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }, - volume: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }, - color: { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }, - "background.paddingY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }, - "background.offsetX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }, - "background.offsetY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }, - "background.cornerRadius": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }, -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); - if (coercedValue === null) { - return element; - } - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - 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, steppedValue)); -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - - if (propertyDefinition.valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ - value, - numericRange: propertyDefinition.numericRange, - }); - } - - if (propertyDefinition.valueKind === "color") { - 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; - } - - return null; -} +import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ AnimationValueKind,
+ DiscreteValue,
+ 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,
+} from "@/constants/text-constants";
+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";
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export type NumericRange = NumericSpec;
+
+export interface AnimationPropertyDefinition {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.position": {
+ valueKind: "vector",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: value as VectorValue,
+ },
+ }
+ : element,
+ },
+ "transform.scaleX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleX : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleX: value as number },
+ }
+ : element,
+ },
+ "transform.scaleY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleY : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ 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,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ },
+ opacity: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, max: 1, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ },
+ volume: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
+ getValue: ({ element }) =>
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
+ setValue: ({ element, value }) =>
+ canElementHaveAudio(element)
+ ? { ...element, volume: value as number }
+ : element,
+ },
+ color: {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ },
+ "background.paddingY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ },
+ "background.offsetX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ },
+ "background.offsetY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ },
+ "background.cornerRadius": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ },
+};
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
+}
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
+ if (coercedValue === null) {
+ return element;
+ }
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return element;
+ }
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+function applyNumericSpec({
+ value,
+ numericRange,
+}: {
+ value: number;
+ 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, steppedValue));
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+
+ if (propertyDefinition.valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return applyNumericSpec({
+ value,
+ numericRange: propertyDefinition.numericRange,
+ });
+ }
+
+ if (propertyDefinition.valueKind === "color") {
+ 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;
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 8709627d..5c356f28 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,135 +1,135 @@ -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, - elementStartTime, - elementDuration, -}: { - timelineTime: number; - elementStartTime: number; - elementDuration: number; -}): number { - const localTime = timelineTime - elementStartTime; - if (localTime <= 0) { - return 0; - } - - if (localTime >= elementDuration) { - return elementDuration; - } - - return localTime; -} - -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: getVectorChannelValueAtTime({ - channel: getVectorChannelForPath({ - animations, - propertyPath: "transform.position", - }), - time: safeLocalTime, - 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({ - animations, - propertyPath: "transform.rotate", - }), - time: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "opacity", - }), - time: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): string { - return getColorValueAtTime({ - channel: getColorChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} +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,
+ elementStartTime,
+ elementDuration,
+}: {
+ timelineTime: number;
+ elementStartTime: number;
+ elementDuration: number;
+}): number {
+ const localTime = timelineTime - elementStartTime;
+ if (localTime <= 0) {
+ return 0;
+ }
+
+ if (localTime >= elementDuration) {
+ return elementDuration;
+ }
+
+ return localTime;
+}
+
+export function resolveTransformAtTime({
+ baseTransform,
+ animations,
+ localTime,
+}: {
+ baseTransform: Transform;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): Transform {
+ const safeLocalTime = Math.max(0, localTime);
+ return {
+ position: getVectorChannelValueAtTime({
+ channel: getVectorChannelForPath({
+ animations,
+ propertyPath: "transform.position",
+ }),
+ time: safeLocalTime,
+ 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({
+ animations,
+ propertyPath: "transform.rotate",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "opacity",
+ }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): string {
+ return getColorValueAtTime({
+ channel: getColorChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts index 10b83775..6675d443 100644 --- a/apps/web/src/lib/animation/target-resolver.ts +++ b/apps/web/src/lib/animation/target-resolver.ts @@ -1,277 +1,277 @@ -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: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(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; -} +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: string,
+): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath(propertyPath) ||
+ isGraphicParamPath(propertyPath) ||
+ isEffectParamPath(propertyPath)
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath(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;
+}
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 913911d0..ac2e0e78 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,119 +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; -} +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;
+}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts index 50c62e40..f7f65049 100644 --- a/apps/web/src/lib/animation/vector-channel.ts +++ b/apps/web/src/lib/animation/vector-channel.ts @@ -1,72 +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; -} +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;
+}
diff --git a/apps/web/src/lib/auth/client.ts b/apps/web/src/lib/auth/client.ts index 63b441f7..a4380e54 100644 --- a/apps/web/src/lib/auth/client.ts +++ b/apps/web/src/lib/auth/client.ts @@ -1,6 +1,6 @@ -import { createAuthClient } from "better-auth/react"; -import { webEnv } from "@opencut/env/web"; - -export const { signIn, signUp, useSession } = createAuthClient({ - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, -}); +import { createAuthClient } from "better-auth/react";
+import { webEnv } from "@opencut/env/web";
+
+export const { signIn, signUp, useSession } = createAuthClient({
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+});
diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts index 978d7383..aaca7936 100644 --- a/apps/web/src/lib/auth/server.ts +++ b/apps/web/src/lib/auth/server.ts @@ -1,43 +1,43 @@ -import { betterAuth, type RateLimit } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { Redis } from "@upstash/redis"; -import { db } from "@/lib/db"; -import { webEnv } from "@opencut/env/web"; - -const redis = new Redis({ - url: webEnv.UPSTASH_REDIS_REST_URL, - token: webEnv.UPSTASH_REDIS_REST_TOKEN, -}); - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - usePlural: true, - }), - secret: webEnv.BETTER_AUTH_SECRET, - user: { - deleteUser: { - enabled: true, - }, - }, - emailAndPassword: { - enabled: true, - }, - rateLimit: { - storage: "secondary-storage", - customStorage: { - get: async (key) => { - const value = await redis.get(key); - return value as RateLimit | undefined; - }, - set: async (key, value) => { - await redis.set(key, value); - }, - }, - }, - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, - appName: "OpenCut", - trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], -}); - -export type Auth = typeof auth; +import { betterAuth, type RateLimit } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { Redis } from "@upstash/redis";
+import { db } from "@/lib/db";
+import { webEnv } from "@opencut/env/web";
+
+const redis = new Redis({
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const auth = betterAuth({
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ usePlural: true,
+ }),
+ secret: webEnv.BETTER_AUTH_SECRET,
+ user: {
+ deleteUser: {
+ enabled: true,
+ },
+ },
+ emailAndPassword: {
+ enabled: true,
+ },
+ rateLimit: {
+ storage: "secondary-storage",
+ customStorage: {
+ get: async (key) => {
+ const value = await redis.get(key);
+ return value as RateLimit | undefined;
+ },
+ set: async (key, value) => {
+ await redis.set(key, value);
+ },
+ },
+ },
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+ appName: "OpenCut",
+ trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
+});
+
+export type Auth = typeof auth;
diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts index 5f7c7474..9e04a5bb 100644 --- a/apps/web/src/lib/blog/query.ts +++ b/apps/web/src/lib/blog/query.ts @@ -1,128 +1,128 @@ -import type { - MarbleAuthorList, - MarbleCategoryList, - Pagination, - MarblePost, - MarblePostList, - MarbleTagList, -} from "@/lib/blog/types"; -import { unified } from "unified"; -import rehypeParse from "rehype-parse"; -import rehypeStringify from "rehype-stringify"; -import rehypeSlug from "rehype-slug"; -import rehypeAutolinkHeadings from "rehype-autolink-headings"; -import rehypeSanitize from "rehype-sanitize"; - -const url = - process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com"; -const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46"; - -const EMPTY_PAGINATION: Pagination = { - limit: 0, - currpage: 1, - nextPage: null, - prevPage: null, - totalItems: 0, - totalPages: 0, -}; - -const EMPTY_POSTS: MarblePostList = { - posts: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_TAGS: MarbleTagList = { - tags: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_CATEGORIES: MarbleCategoryList = { - categories: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_AUTHORS: MarbleAuthorList = { - authors: [], - pagination: EMPTY_PAGINATION, -}; - -function isMarbleConfigured() { - return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key); -} - -async function fetchFromMarble<T>({ - endpoint, - fallback, -}: { - endpoint: string; - fallback: T; -}): Promise<T> { - if (!isMarbleConfigured()) { - return fallback; - } - - try { - const response = await fetch(`${url}/${key}/${endpoint}`); - if (!response.ok) { - console.warn( - `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`, - ); - return fallback; - } - return (await response.json()) as T; - } catch (error) { - console.error(`Error fetching ${endpoint}:`, error); - return fallback; - } -} - -export async function getPosts() { - return fetchFromMarble<MarblePostList>({ - endpoint: "posts", - fallback: EMPTY_POSTS, - }); -} - -export async function getTags() { - return fetchFromMarble<MarbleTagList>({ - endpoint: "tags", - fallback: EMPTY_TAGS, - }); -} - -export async function getSinglePost({ slug }: { slug: string }) { - return fetchFromMarble<MarblePost | null>({ - endpoint: `posts/${slug}`, - fallback: null, - }); -} - -export async function getCategories() { - return fetchFromMarble<MarbleCategoryList>({ - endpoint: "categories", - fallback: EMPTY_CATEGORIES, - }); -} - -export async function getAuthors() { - return fetchFromMarble<MarbleAuthorList>({ - endpoint: "authors", - fallback: EMPTY_AUTHORS, - }); -} - -export async function processHtmlContent({ - html, -}: { - html: string; -}): Promise<string> { - const processor = unified() - .use(rehypeSanitize) - .use(rehypeParse, { fragment: true }) - .use(rehypeSlug) - .use(rehypeAutolinkHeadings, { behavior: "append" }) - .use(rehypeStringify); - - const file = await processor.process({ value: html, type: "html" }); - return String(file); -} +import type {
+ MarbleAuthorList,
+ MarbleCategoryList,
+ Pagination,
+ MarblePost,
+ MarblePostList,
+ MarbleTagList,
+} from "@/lib/blog/types";
+import { unified } from "unified";
+import rehypeParse from "rehype-parse";
+import rehypeStringify from "rehype-stringify";
+import rehypeSlug from "rehype-slug";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+import rehypeSanitize from "rehype-sanitize";
+
+const url =
+ process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
+const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
+
+const EMPTY_PAGINATION: Pagination = {
+ limit: 0,
+ currpage: 1,
+ nextPage: null,
+ prevPage: null,
+ totalItems: 0,
+ totalPages: 0,
+};
+
+const EMPTY_POSTS: MarblePostList = {
+ posts: [],
+ pagination: EMPTY_PAGINATION,
+};
+
+const EMPTY_TAGS: MarbleTagList = {
+ tags: [],
+ pagination: EMPTY_PAGINATION,
+};
+
+const EMPTY_CATEGORIES: MarbleCategoryList = {
+ categories: [],
+ pagination: EMPTY_PAGINATION,
+};
+
+const EMPTY_AUTHORS: MarbleAuthorList = {
+ authors: [],
+ pagination: EMPTY_PAGINATION,
+};
+
+function isMarbleConfigured() {
+ return !["", "placeholder", "build-placeholder", "your_workspace_key_here"].includes(key);
+}
+
+async function fetchFromMarble<T>({
+ endpoint,
+ fallback,
+}: {
+ endpoint: string;
+ fallback: T;
+}): Promise<T> {
+ if (!isMarbleConfigured()) {
+ return fallback;
+ }
+
+ try {
+ const response = await fetch(`${url}/${key}/${endpoint}`);
+ if (!response.ok) {
+ console.warn(
+ `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
+ );
+ return fallback;
+ }
+ return (await response.json()) as T;
+ } catch (error) {
+ console.error(`Error fetching ${endpoint}:`, error);
+ return fallback;
+ }
+}
+
+export async function getPosts() {
+ return fetchFromMarble<MarblePostList>({
+ endpoint: "posts",
+ fallback: EMPTY_POSTS,
+ });
+}
+
+export async function getTags() {
+ return fetchFromMarble<MarbleTagList>({
+ endpoint: "tags",
+ fallback: EMPTY_TAGS,
+ });
+}
+
+export async function getSinglePost({ slug }: { slug: string }) {
+ return fetchFromMarble<MarblePost | null>({
+ endpoint: `posts/${slug}`,
+ fallback: null,
+ });
+}
+
+export async function getCategories() {
+ return fetchFromMarble<MarbleCategoryList>({
+ endpoint: "categories",
+ fallback: EMPTY_CATEGORIES,
+ });
+}
+
+export async function getAuthors() {
+ return fetchFromMarble<MarbleAuthorList>({
+ endpoint: "authors",
+ fallback: EMPTY_AUTHORS,
+ });
+}
+
+export async function processHtmlContent({
+ html,
+}: {
+ html: string;
+}): Promise<string> {
+ const processor = unified()
+ .use(rehypeSanitize)
+ .use(rehypeParse, { fragment: true })
+ .use(rehypeSlug)
+ .use(rehypeAutolinkHeadings, { behavior: "append" })
+ .use(rehypeStringify);
+
+ const file = await processor.process({ value: html, type: "html" });
+ return String(file);
+}
diff --git a/apps/web/src/lib/blog/types.ts b/apps/web/src/lib/blog/types.ts index 16795b02..7b2f450a 100644 --- a/apps/web/src/lib/blog/types.ts +++ b/apps/web/src/lib/blog/types.ts @@ -1,92 +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; -}; +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;
+};
diff --git a/apps/web/src/lib/cancel-interaction.ts b/apps/web/src/lib/cancel-interaction.ts index 0cb07f4f..297a2062 100644 --- a/apps/web/src/lib/cancel-interaction.ts +++ b/apps/web/src/lib/cancel-interaction.ts @@ -1,24 +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; -} +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;
+}
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index a3dec0e6..86fa3e35 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -5,11 +5,15 @@ 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"; +import { getHighestImportedVideoFps } from "@/lib/project/fps"; +import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; export class AddMediaAssetCommand extends Command { private assetId: string; private savedAssets: MediaAsset[] | null = null; private createdAsset: MediaAsset | null = null; + private previousProjectFps: number | null = null; + private appliedProjectFps: number | null = null; constructor( private projectId: string, @@ -31,6 +35,10 @@ export class AddMediaAssetCommand extends Command { editor.media.setAssets({ assets: [...this.savedAssets, this.createdAsset], }); + this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null; + this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({ + importedAssets: [this.createdAsset], + }); storageService .saveMediaAsset({ @@ -64,6 +72,8 @@ export class AddMediaAssetCommand extends Command { editor.timeline.deleteElements({ elements: orphanedElements }); } + this.restoreProjectFpsAfterFailedSave({ editor }); + if (storageService.isQuotaExceededError({ error })) { toast.error("Not enough browser storage", { description: error instanceof Error ? error.message : undefined, @@ -90,4 +100,28 @@ export class AddMediaAssetCommand extends Command { getAssetId(): string { return this.assetId; } + + private restoreProjectFpsAfterFailedSave({ + editor, + }: { + editor: EditorCore; + }): void { + if (this.previousProjectFps === null || this.appliedProjectFps === null) return; + + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + if (activeProject.settings.fps !== this.appliedProjectFps) return; + + const highestRemainingVideoFps = getHighestImportedVideoFps({ + mediaAssets: editor.media.getAssets(), + }); + if ( + highestRemainingVideoFps !== null && + highestRemainingVideoFps >= this.appliedProjectFps + ) { + return; + } + + new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute(); + } } diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts index f519baef..84b395ed 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -1,99 +1,99 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -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 "@/lib/timeline"; - -export class RemoveMediaAssetCommand extends Command { - private savedAssets: MediaAsset[] | null = null; - private savedTracks: TimelineTrack[] | null = null; - private removedAsset: MediaAsset | null = null; - - constructor( - private projectId: string, - private assetId: string, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const assets = editor.media.getAssets(); - - this.savedAssets = [...assets]; - this.savedTracks = editor.timeline.getTracks(); - - this.removedAsset = - assets.find((media) => media.id === this.assetId) ?? null; - - if (!this.removedAsset) { - console.error("Media asset not found:", this.assetId); - 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({ - assets: assets.filter((media) => media.id !== this.assetId), - }); - - const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; - - for (const track of this.savedTracks) { - for (const element of track.elements) { - if (hasMediaId(element) && element.mediaId === this.assetId) { - elementsToRemove.push({ trackId: track.id, elementId: element.id }); - } - } - } - - if (elementsToRemove.length > 0) { - editor.timeline.deleteElements({ elements: elementsToRemove }); - } - - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item:", error); - }); - } - - undo(): void { - const editor = EditorCore.getInstance(); - - if (this.savedAssets && this.removedAsset) { - const restoredAsset: MediaAsset = { - ...this.removedAsset, - url: URL.createObjectURL(this.removedAsset.file), - }; - - editor.media.setAssets({ - assets: this.savedAssets.map((a) => - a.id === this.assetId ? restoredAsset : a, - ), - }); - - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: restoredAsset, - }) - .catch((error) => { - console.error("Failed to restore media item on undo:", error); - }); - } - - if (this.savedTracks) { - editor.timeline.updateTracks(this.savedTracks); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+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 "@/lib/timeline";
+
+export class RemoveMediaAssetCommand extends Command {
+ private savedAssets: MediaAsset[] | null = null;
+ private savedTracks: TimelineTrack[] | null = null;
+ private removedAsset: MediaAsset | null = null;
+
+ constructor(
+ private projectId: string,
+ private assetId: string,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const assets = editor.media.getAssets();
+
+ this.savedAssets = [...assets];
+ this.savedTracks = editor.timeline.getTracks();
+
+ this.removedAsset =
+ assets.find((media) => media.id === this.assetId) ?? null;
+
+ if (!this.removedAsset) {
+ console.error("Media asset not found:", this.assetId);
+ 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({
+ assets: assets.filter((media) => media.id !== this.assetId),
+ });
+
+ const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
+
+ for (const track of this.savedTracks) {
+ for (const element of track.elements) {
+ if (hasMediaId(element) && element.mediaId === this.assetId) {
+ elementsToRemove.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ if (elementsToRemove.length > 0) {
+ editor.timeline.deleteElements({ elements: elementsToRemove });
+ }
+
+ storageService
+ .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
+ .catch((error) => {
+ console.error("Failed to delete media item:", error);
+ });
+ }
+
+ undo(): void {
+ const editor = EditorCore.getInstance();
+
+ if (this.savedAssets && this.removedAsset) {
+ const restoredAsset: MediaAsset = {
+ ...this.removedAsset,
+ url: URL.createObjectURL(this.removedAsset.file),
+ };
+
+ editor.media.setAssets({
+ assets: this.savedAssets.map((a) =>
+ a.id === this.assetId ? restoredAsset : a,
+ ),
+ });
+
+ storageService
+ .saveMediaAsset({
+ projectId: this.projectId,
+ mediaAsset: restoredAsset,
+ })
+ .catch((error) => {
+ console.error("Failed to restore media item on undo:", error);
+ });
+ }
+
+ if (this.savedTracks) {
+ editor.timeline.updateTracks(this.savedTracks);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts index 68c8dee3..38ef4edd 100644 --- a/apps/web/src/lib/commands/preview-tracker.ts +++ b/apps/web/src/lib/commands/preview-tracker.ts @@ -1,23 +1,23 @@ -export class PreviewTracker<T> { - private snapshot: T | null = null; - - begin({ state }: { state: T }): void { - if (this.snapshot === null) { - this.snapshot = structuredClone(state); - } - } - - isActive(): boolean { - return this.snapshot !== null; - } - - getSnapshot(): T | null { - return this.snapshot; - } - - end(): T | null { - const snapshot = this.snapshot; - this.snapshot = null; - return snapshot; - } -} +export class PreviewTracker<T> {
+ private snapshot: T | null = null;
+
+ begin({ state }: { state: T }): void {
+ if (this.snapshot === null) {
+ this.snapshot = structuredClone(state);
+ }
+ }
+
+ isActive(): boolean {
+ return this.snapshot !== null;
+ }
+
+ getSnapshot(): T | null {
+ return this.snapshot;
+ }
+
+ end(): T | null {
+ const snapshot = this.snapshot;
+ this.snapshot = null;
+ return snapshot;
+ }
+}
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts index b16431c1..8320095b 100644 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ b/apps/web/src/lib/commands/project/update-project-settings.ts @@ -1,46 +1,46 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TProject, TProjectSettings } from "@/lib/project/types"; - -export class UpdateProjectSettingsCommand extends Command { - private savedSettings: TProjectSettings | null = null; - private savedUpdatedAt: Date | null = null; - - constructor(private updates: Partial<TProjectSettings>) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - this.savedSettings = activeProject.settings; - this.savedUpdatedAt = activeProject.metadata.updatedAt; - - const updatedProject: TProject = { - ...activeProject, - settings: { ...activeProject.settings, ...this.updates }, - metadata: { ...activeProject.metadata, updatedAt: new Date() }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } - - undo(): void { - if (!this.savedSettings || !this.savedUpdatedAt) return; - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const updatedProject: TProject = { - ...activeProject, - settings: this.savedSettings, - metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TProject, TProjectSettings } from "@/lib/project/types";
+
+export class UpdateProjectSettingsCommand extends Command {
+ private savedSettings: TProjectSettings | null = null;
+ private savedUpdatedAt: Date | null = null;
+
+ constructor(private updates: Partial<TProjectSettings>) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ this.savedSettings = activeProject.settings;
+ this.savedUpdatedAt = activeProject.metadata.updatedAt;
+
+ const updatedProject: TProject = {
+ ...activeProject,
+ settings: { ...activeProject.settings, ...this.updates },
+ metadata: { ...activeProject.metadata, updatedAt: new Date() },
+ };
+
+ editor.project.setActiveProject({ project: updatedProject });
+ editor.save.markDirty();
+ }
+
+ undo(): void {
+ if (!this.savedSettings || !this.savedUpdatedAt) return;
+ const editor = EditorCore.getInstance();
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const updatedProject: TProject = {
+ ...activeProject,
+ settings: this.savedSettings,
+ metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
+ };
+
+ editor.project.setActiveProject({ project: updatedProject });
+ editor.save.markDirty();
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts index 28bda2fd..7675570d 100644 --- a/apps/web/src/lib/commands/scene/create-scene.ts +++ b/apps/web/src/lib/commands/scene/create-scene.ts @@ -1,40 +1,40 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { buildDefaultScene } from "@/lib/scenes"; - -export class CreateSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private createdScene: TScene | null = null; - - constructor( - private name: string, - private isMain: boolean = false, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedScenes = [...editor.scenes.getScenes()]; - - this.createdScene = buildDefaultScene({ - name: this.name, - isMain: this.isMain, - }); - - const updatedScenes = [...this.savedScenes, this.createdScene]; - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } - - getSceneId(): string { - return this.createdScene?.id ?? ""; - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { buildDefaultScene } from "@/lib/scenes";
+
+export class CreateSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private createdScene: TScene | null = null;
+
+ constructor(
+ private name: string,
+ private isMain: boolean = false,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedScenes = [...editor.scenes.getScenes()];
+
+ this.createdScene = buildDefaultScene({
+ name: this.name,
+ isMain: this.isMain,
+ });
+
+ const updatedScenes = [...this.savedScenes, this.createdScene];
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+
+ getSceneId(): string {
+ return this.createdScene?.id ?? "";
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts index 58c790e6..da8aac24 100644 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ b/apps/web/src/lib/commands/scene/delete-scene.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; - -export class DeleteSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private savedActiveSceneId: string | null = null; - private deletedScene: TScene | null = null; - - constructor(private sceneId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - const activeScene = editor.scenes.getActiveScene(); - - this.savedScenes = [...scenes]; - this.savedActiveSceneId = activeScene?.id ?? null; - - this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; - - if (!this.deletedScene) { - console.error("Scene not found:", this.sceneId); - return; - } - - const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); - if (!canDelete) { - console.error("Cannot delete scene:", reason); - return; - } - - const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); - - const newActiveScene = getFallbackSceneAfterDelete({ - scenes: updatedScenes, - deletedSceneId: this.sceneId, - currentSceneId: activeScene?.id ?? null, - }); - - editor.scenes.setScenes({ - scenes: updatedScenes, - activeSceneId: newActiveScene?.id, - }); - } - - undo(): void { - if (this.savedScenes && this.deletedScene) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ - scenes: this.savedScenes, - activeSceneId: this.savedActiveSceneId ?? undefined, - }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
+
+export class DeleteSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private savedActiveSceneId: string | null = null;
+ private deletedScene: TScene | null = null;
+
+ constructor(private sceneId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const scenes = editor.scenes.getScenes();
+ const activeScene = editor.scenes.getActiveScene();
+
+ this.savedScenes = [...scenes];
+ this.savedActiveSceneId = activeScene?.id ?? null;
+
+ this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
+
+ if (!this.deletedScene) {
+ console.error("Scene not found:", this.sceneId);
+ return;
+ }
+
+ const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
+ if (!canDelete) {
+ console.error("Cannot delete scene:", reason);
+ return;
+ }
+
+ const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
+
+ const newActiveScene = getFallbackSceneAfterDelete({
+ scenes: updatedScenes,
+ deletedSceneId: this.sceneId,
+ currentSceneId: activeScene?.id ?? null,
+ });
+
+ editor.scenes.setScenes({
+ scenes: updatedScenes,
+ activeSceneId: newActiveScene?.id,
+ });
+ }
+
+ undo(): void {
+ if (this.savedScenes && this.deletedScene) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({
+ scenes: this.savedScenes,
+ activeSceneId: this.savedActiveSceneId ?? undefined,
+ });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts index 2b843c99..b680108d 100644 --- a/apps/web/src/lib/commands/scene/index.ts +++ b/apps/web/src/lib/commands/scene/index.ts @@ -1,7 +1,7 @@ -export { CreateSceneCommand } from "./create-scene"; -export { DeleteSceneCommand } from "./delete-scene"; -export { RenameSceneCommand } from "./rename-scene"; -export { ToggleBookmarkCommand } from "./toggle-bookmark"; -export { RemoveBookmarkCommand } from "./remove-bookmark"; -export { UpdateBookmarkCommand } from "./update-bookmark"; -export { MoveBookmarkCommand } from "./move-bookmark"; +export { CreateSceneCommand } from "./create-scene";
+export { DeleteSceneCommand } from "./delete-scene";
+export { RenameSceneCommand } from "./rename-scene";
+export { ToggleBookmarkCommand } from "./toggle-bookmark";
+export { RemoveBookmarkCommand } from "./remove-bookmark";
+export { UpdateBookmarkCommand } from "./update-bookmark";
+export { MoveBookmarkCommand } from "./move-bookmark";
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts index 28d7a45c..7b5b5aac 100644 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class MoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private fromTime: number, - private toTime: number, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const fromFrameTime = getFrameTime({ - time: this.fromTime, - fps: activeProject.settings.fps, - }); - const toFrameTime = getFrameTime({ - time: this.toTime, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = moveBookmarkInArray({ - bookmarks: activeScene.bookmarks, - fromTime: fromFrameTime, - toTime: toFrameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class MoveBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+
+ constructor(
+ private fromTime: number,
+ private toTime: number,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ const fromFrameTime = getFrameTime({
+ time: this.fromTime,
+ fps: activeProject.settings.fps,
+ });
+ const toFrameTime = getFrameTime({
+ time: this.toTime,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = moveBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ fromTime: fromFrameTime,
+ toTime: toFrameTime,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts index 19515b97..c5f8692c 100644 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { - getFrameTime, - removeBookmarkFromArray, -} from "@/lib/timeline/bookmarks"; - -export class RemoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = removeBookmarkFromArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - if (updatedBookmarks.length === activeScene.bookmarks.length) { - return; - } - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import {
+ getFrameTime,
+ removeBookmarkFromArray,
+} from "@/lib/timeline/bookmarks";
+
+export class RemoveBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private frameTime: number = 0;
+
+ constructor(private time: number) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ this.frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = removeBookmarkFromArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime: this.frameTime,
+ });
+
+ if (updatedBookmarks.length === activeScene.bookmarks.length) {
+ return;
+ }
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts index f1d65e27..554e3940 100644 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ b/apps/web/src/lib/commands/scene/rename-scene.ts @@ -1,46 +1,46 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; - -export class RenameSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private previousName: string | null = null; - - constructor( - private sceneId: string, - private newName: string, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - - this.savedScenes = [...scenes]; - - const scene = scenes.find((s) => s.id === this.sceneId); - if (!scene) { - console.error("Scene not found:", this.sceneId); - return; - } - - this.previousName = scene.name; - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: this.sceneId, - updates: { name: this.newName, updatedAt: new Date() }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes && this.previousName !== null) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+
+export class RenameSceneCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private previousName: string | null = null;
+
+ constructor(
+ private sceneId: string,
+ private newName: string,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const scenes = editor.scenes.getScenes();
+
+ this.savedScenes = [...scenes];
+
+ const scene = scenes.find((s) => s.id === this.sceneId);
+ if (!scene) {
+ console.error("Scene not found:", this.sceneId);
+ return;
+ }
+
+ this.previousName = scene.name;
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: this.sceneId,
+ updates: { name: this.newName, updatedAt: new Date() },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes && this.previousName !== null) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts index 518a30aa..69fe3213 100644 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts @@ -1,52 +1,52 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class ToggleBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = toggleBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/lib/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class ToggleBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+ private frameTime: number = 0;
+
+ constructor(private time: number) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ this.frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = toggleBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime: this.frameTime,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts index 6aeef42b..2e320b3d 100644 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -1,55 +1,55 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { Bookmark, TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class UpdateBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private time: number, - private updates: Partial<Omit<Bookmark, "time">>, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = updateBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime, - updates: this.updates, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { Bookmark, TScene } from "@/lib/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class UpdateBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+
+ constructor(
+ private time: number,
+ private updates: Partial<Omit<Bookmark, "time">>,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ const frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = updateBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime,
+ updates: this.updates,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 00758eb7..d9c54ec3 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,293 +1,293 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - TimelineTrack, - TimelineElement, - ClipboardItem, -} from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { wouldElementOverlap } from "@/lib/timeline/element-utils"; -import { - buildEmptyTrack, - getHighestInsertIndexForTrack, - isMainTrack, - enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; -import { cloneAnimations } from "@/lib/animation"; - -export class PasteCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private pastedElements: { trackId: string; elementId: string }[] = []; - private previousSelection: { trackId: string; elementId: string }[] = []; - - constructor( - private time: number, - private clipboardItems: ClipboardItem[], - ) { - super(); - } - - execute(): void { - if (this.clipboardItems.length === 0) return; - - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - this.previousSelection = editor.selection.getSelectedElements(); - this.pastedElements = []; - - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), - ); - - const updatedTracks = [...this.savedState]; - const itemsByTrackId = groupClipboardItemsByTrackId({ - clipboardItems: this.clipboardItems, - }); - - for (const [trackId, items] of itemsByTrackId) { - const elementsToAdd = buildPastedElements({ - items, - minStart, - time: this.time, - }); - - if (elementsToAdd.length === 0) { - continue; - } - - const trackType = items[0].trackType; - const sourceTrackIndex = updatedTracks.findIndex( - (track) => track.id === trackId, - ); - const resolvedTargetIndex = resolveTargetTrackIndex({ - tracks: updatedTracks, - sourceTrackIndex, - trackType, - elements: elementsToAdd, - }); - - if (resolvedTargetIndex >= 0) { - const targetTrack = updatedTracks[resolvedTargetIndex]; - let adjustedElements = elementsToAdd; - - if (isMainTrack(targetTrack)) { - const earliestElement = elementsToAdd.reduce((earliest, element) => - element.startTime < earliest.startTime ? element : earliest, - ); - const adjustedEarliestStartTime = enforceMainTrackStart({ - tracks: updatedTracks, - targetTrackId: targetTrack.id, - requestedStartTime: earliestElement.startTime, - }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; - - if (delta !== 0) { - adjustedElements = elementsToAdd.map((element) => ({ - ...element, - startTime: Math.max(0, element.startTime + delta), - })); - } - } - - updatedTracks[resolvedTargetIndex] = { - ...targetTrack, - elements: [...targetTrack.elements, ...adjustedElements], - } as TimelineTrack; - for (const element of adjustedElements) { - this.pastedElements.push({ - trackId: targetTrack.id, - elementId: element.id, - }); - } - continue; - } - - const insertIndex = resolveInsertIndexForNewTrack({ - tracks: updatedTracks, - sourceTrackIndex, - trackType, - }); - const newTrack = buildTrackWithElements({ - trackType, - elements: elementsToAdd, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - for (const element of elementsToAdd) { - this.pastedElements.push({ - trackId: newTrack.id, - elementId: element.id, - }); - } - } - - editor.timeline.updateTracks(updatedTracks); - - if (this.pastedElements.length > 0) { - editor.selection.setSelectedElements({ elements: this.pastedElements }); - } - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ - elements: this.previousSelection, - }); - } - } - - getPastedElements(): { trackId: string; elementId: string }[] { - return this.pastedElements; - } -} - -function groupClipboardItemsByTrackId({ - clipboardItems, -}: { - clipboardItems: ClipboardItem[]; -}): Map<string, ClipboardItem[]> { - const groupedItems = new Map<string, ClipboardItem[]>(); - - for (const item of clipboardItems) { - const existingItems = groupedItems.get(item.trackId) ?? []; - groupedItems.set(item.trackId, [...existingItems, item]); - } - - return groupedItems; -} - -function buildPastedElements({ - items, - minStart, - time, -}: { - items: ClipboardItem[]; - minStart: number; - time: number; -}): TimelineElement[] { - const elementsToAdd: TimelineElement[] = []; - - for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); - const newElementId = generateUUID(); - - elementsToAdd.push({ - ...item.element, - id: newElementId, - startTime, - animations: cloneAnimations({ - animations: item.element.animations, - shouldRegenerateKeyframeIds: true, - }), - } as TimelineElement); - } - - return elementsToAdd; -} - -function resolveTargetTrackIndex({ - tracks, - sourceTrackIndex, - trackType, - elements, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): number { - if (sourceTrackIndex >= 0) { - const aboveIndex = sourceTrackIndex - 1; - if (aboveIndex < 0) { - return -1; - } - - const aboveTrack = tracks[aboveIndex]; - if (aboveTrack.type !== trackType) { - return -1; - } - - const canPlaceOnAbove = canPlaceElementsOnTrack({ - track: aboveTrack, - elements, - }); - return canPlaceOnAbove ? aboveIndex : -1; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex < 0) { - return -1; - } - - const highestCompatibleTrack = tracks[highestCompatibleIndex]; - const canPlaceOnHighest = canPlaceElementsOnTrack({ - track: highestCompatibleTrack, - elements, - }); - - return canPlaceOnHighest ? highestCompatibleIndex : -1; -} - -function resolveInsertIndexForNewTrack({ - tracks, - sourceTrackIndex, - trackType, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; -}): number { - if (sourceTrackIndex >= 0) { - return sourceTrackIndex; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex >= 0) { - return highestCompatibleIndex; - } - - return getHighestInsertIndexForTrack({ tracks, trackType }); -} - -function canPlaceElementsOnTrack({ - track, - elements, -}: { - track: TimelineTrack; - elements: TimelineElement[]; -}): boolean { - for (const element of elements) { - const endTime = element.startTime + element.duration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime, - }); - if (hasOverlap) { - return false; - } - } - - return true; -} - -function buildTrackWithElements({ - trackType, - elements, -}: { - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): TimelineTrack { - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType }); - return { - ...newTrackBase, - elements, - } as TimelineTrack; -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ ClipboardItem,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { wouldElementOverlap } from "@/lib/timeline/element-utils";
+import {
+ buildEmptyTrack,
+ getHighestInsertIndexForTrack,
+ isMainTrack,
+ enforceMainTrackStart,
+} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
+
+export class PasteCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private pastedElements: { trackId: string; elementId: string }[] = [];
+ private previousSelection: { trackId: string; elementId: string }[] = [];
+
+ constructor(
+ private time: number,
+ private clipboardItems: ClipboardItem[],
+ ) {
+ super();
+ }
+
+ execute(): void {
+ if (this.clipboardItems.length === 0) return;
+
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.previousSelection = editor.selection.getSelectedElements();
+ this.pastedElements = [];
+
+ const minStart = Math.min(
+ ...this.clipboardItems.map((item) => item.element.startTime),
+ );
+
+ const updatedTracks = [...this.savedState];
+ const itemsByTrackId = groupClipboardItemsByTrackId({
+ clipboardItems: this.clipboardItems,
+ });
+
+ for (const [trackId, items] of itemsByTrackId) {
+ const elementsToAdd = buildPastedElements({
+ items,
+ minStart,
+ time: this.time,
+ });
+
+ if (elementsToAdd.length === 0) {
+ continue;
+ }
+
+ const trackType = items[0].trackType;
+ const sourceTrackIndex = updatedTracks.findIndex(
+ (track) => track.id === trackId,
+ );
+ const resolvedTargetIndex = resolveTargetTrackIndex({
+ tracks: updatedTracks,
+ sourceTrackIndex,
+ trackType,
+ elements: elementsToAdd,
+ });
+
+ if (resolvedTargetIndex >= 0) {
+ const targetTrack = updatedTracks[resolvedTargetIndex];
+ let adjustedElements = elementsToAdd;
+
+ if (isMainTrack(targetTrack)) {
+ const earliestElement = elementsToAdd.reduce((earliest, element) =>
+ element.startTime < earliest.startTime ? element : earliest,
+ );
+ const adjustedEarliestStartTime = enforceMainTrackStart({
+ tracks: updatedTracks,
+ targetTrackId: targetTrack.id,
+ requestedStartTime: earliestElement.startTime,
+ });
+ const delta = adjustedEarliestStartTime - earliestElement.startTime;
+
+ if (delta !== 0) {
+ adjustedElements = elementsToAdd.map((element) => ({
+ ...element,
+ startTime: Math.max(0, element.startTime + delta),
+ }));
+ }
+ }
+
+ updatedTracks[resolvedTargetIndex] = {
+ ...targetTrack,
+ elements: [...targetTrack.elements, ...adjustedElements],
+ } as TimelineTrack;
+ for (const element of adjustedElements) {
+ this.pastedElements.push({
+ trackId: targetTrack.id,
+ elementId: element.id,
+ });
+ }
+ continue;
+ }
+
+ const insertIndex = resolveInsertIndexForNewTrack({
+ tracks: updatedTracks,
+ sourceTrackIndex,
+ trackType,
+ });
+ const newTrack = buildTrackWithElements({
+ trackType,
+ elements: elementsToAdd,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+ for (const element of elementsToAdd) {
+ this.pastedElements.push({
+ trackId: newTrack.id,
+ elementId: element.id,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.pastedElements.length > 0) {
+ editor.selection.setSelectedElements({ elements: this.pastedElements });
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ editor.selection.setSelectedElements({
+ elements: this.previousSelection,
+ });
+ }
+ }
+
+ getPastedElements(): { trackId: string; elementId: string }[] {
+ return this.pastedElements;
+ }
+}
+
+function groupClipboardItemsByTrackId({
+ clipboardItems,
+}: {
+ clipboardItems: ClipboardItem[];
+}): Map<string, ClipboardItem[]> {
+ const groupedItems = new Map<string, ClipboardItem[]>();
+
+ for (const item of clipboardItems) {
+ const existingItems = groupedItems.get(item.trackId) ?? [];
+ groupedItems.set(item.trackId, [...existingItems, item]);
+ }
+
+ return groupedItems;
+}
+
+function buildPastedElements({
+ items,
+ minStart,
+ time,
+}: {
+ items: ClipboardItem[];
+ minStart: number;
+ time: number;
+}): TimelineElement[] {
+ const elementsToAdd: TimelineElement[] = [];
+
+ for (const item of items) {
+ const relativeOffset = item.element.startTime - minStart;
+ const startTime = Math.max(0, time + relativeOffset);
+ const newElementId = generateUUID();
+
+ elementsToAdd.push({
+ ...item.element,
+ id: newElementId,
+ startTime,
+ animations: cloneAnimations({
+ animations: item.element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ } as TimelineElement);
+ }
+
+ return elementsToAdd;
+}
+
+function resolveTargetTrackIndex({
+ tracks,
+ sourceTrackIndex,
+ trackType,
+ elements,
+}: {
+ tracks: TimelineTrack[];
+ sourceTrackIndex: number;
+ trackType: ClipboardItem["trackType"];
+ elements: TimelineElement[];
+}): number {
+ if (sourceTrackIndex >= 0) {
+ const aboveIndex = sourceTrackIndex - 1;
+ if (aboveIndex < 0) {
+ return -1;
+ }
+
+ const aboveTrack = tracks[aboveIndex];
+ if (aboveTrack.type !== trackType) {
+ return -1;
+ }
+
+ const canPlaceOnAbove = canPlaceElementsOnTrack({
+ track: aboveTrack,
+ elements,
+ });
+ return canPlaceOnAbove ? aboveIndex : -1;
+ }
+
+ const highestCompatibleIndex = tracks.findIndex(
+ (track) => track.type === trackType,
+ );
+ if (highestCompatibleIndex < 0) {
+ return -1;
+ }
+
+ const highestCompatibleTrack = tracks[highestCompatibleIndex];
+ const canPlaceOnHighest = canPlaceElementsOnTrack({
+ track: highestCompatibleTrack,
+ elements,
+ });
+
+ return canPlaceOnHighest ? highestCompatibleIndex : -1;
+}
+
+function resolveInsertIndexForNewTrack({
+ tracks,
+ sourceTrackIndex,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ sourceTrackIndex: number;
+ trackType: ClipboardItem["trackType"];
+}): number {
+ if (sourceTrackIndex >= 0) {
+ return sourceTrackIndex;
+ }
+
+ const highestCompatibleIndex = tracks.findIndex(
+ (track) => track.type === trackType,
+ );
+ if (highestCompatibleIndex >= 0) {
+ return highestCompatibleIndex;
+ }
+
+ return getHighestInsertIndexForTrack({ tracks, trackType });
+}
+
+function canPlaceElementsOnTrack({
+ track,
+ elements,
+}: {
+ track: TimelineTrack;
+ elements: TimelineElement[];
+}): boolean {
+ for (const element of elements) {
+ const endTime = element.startTime + element.duration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: element.startTime,
+ endTime,
+ });
+ if (hasOverlap) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function buildTrackWithElements({
+ trackType,
+ elements,
+}: {
+ trackType: ClipboardItem["trackType"];
+ elements: TimelineElement[];
+}): TimelineTrack {
+ const newTrackId = generateUUID();
+ const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
+ return {
+ ...newTrackBase,
+ elements,
+ } as TimelineTrack;
+}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 4b439870..9b27a3c2 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,79 +1,79 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; - -export class DeleteElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - private readonly rippleEnabled: boolean; - - constructor({ - elements, - rippleEnabled = false, - }: { - elements: { trackId: string; elementId: string }[]; - rippleEnabled?: boolean; - }) { - super(); - this.elements = elements; - this.rippleEnabled = rippleEnabled; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState - .map((track) => { - const elementsToDeleteOnTrack = this.elements.filter( - (target) => target.trackId === track.id, - ); - const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; - - if (!hasElementsToDelete) { - return track; - } - - const deletedElementInfos = elementsToDeleteOnTrack - .map((target) => - track.elements.find((element) => element.id === target.elementId), - ) - .filter((element): element is NonNullable<typeof element> => element !== undefined) - .map((element) => ({ startTime: element.startTime, duration: element.duration })); - - let elements = track.elements.filter( - (element) => - !this.elements.some( - (target) => - target.trackId === track.id && target.elementId === element.id, - ), - ); - - if (this.rippleEnabled && deletedElementInfos.length > 0) { - const sortedByStartDesc = [...deletedElementInfos].sort( - (a, b) => b.startTime - a.startTime, - ); - for (const { startTime, duration } of sortedByStartDesc) { - elements = rippleShiftElements({ - elements, - afterTime: startTime, - shiftAmount: duration, - }); - } - } - - return { ...track, elements } as typeof track; - }) - .filter((track) => track.elements.length > 0 || isMainTrack(track)); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
+
+export class DeleteElementsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly rippleEnabled: boolean;
+
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
+ super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState
+ .map((track) => {
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+
+ if (!hasElementsToDelete) {
+ return track;
+ }
+
+ const deletedElementInfos = elementsToDeleteOnTrack
+ .map((target) =>
+ track.elements.find((element) => element.id === target.elementId),
+ )
+ .filter((element): element is NonNullable<typeof element> => element !== undefined)
+ .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+
+ let elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
+
+ if (this.rippleEnabled && deletedElementInfos.length > 0) {
+ const sortedByStartDesc = [...deletedElementInfos].sort(
+ (a, b) => b.startTime - a.startTime,
+ );
+ for (const { startTime, duration } of sortedByStartDesc) {
+ elements = rippleShiftElements({
+ elements,
+ afterTime: startTime,
+ shiftAmount: duration,
+ });
+ }
+ }
+
+ return { ...track, elements } as typeof track;
+ })
+ .filter((track) => track.elements.length > 0 || isMainTrack(track));
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 207e2373..346f8c73 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,128 +1,128 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getHighestInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; -import { cloneAnimations } from "@/lib/animation"; - -interface DuplicateElementsParams { - elements: { trackId: string; elementId: string }[]; -} - -export class DuplicateElementsCommand extends Command { - private duplicatedElements: { trackId: string; elementId: string }[] = []; - private savedState: TimelineTrack[] | null = null; - private previousSelection: { trackId: string; elementId: string }[] = []; - private elements: DuplicateElementsParams["elements"]; - - constructor({ elements }: DuplicateElementsParams) { - super(); - this.elements = elements; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - this.previousSelection = editor.selection.getSelectedElements(); - this.duplicatedElements = []; - - const updatedTracks = [...this.savedState]; - - for (const track of this.savedState) { - const elementsToDuplicate = this.elements.filter( - (elementEntry) => elementEntry.trackId === track.id, - ); - - if (elementsToDuplicate.length === 0) { - continue; - } - - const elementIdsToDuplicate = new Set( - elementsToDuplicate.map((element) => element.elementId), - ); - const newTrackElements: TimelineElement[] = []; - - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ - id: newTrackId, - type: track.type, - }); - - for (const element of track.elements) { - if (!elementIdsToDuplicate.has(element.id)) { - continue; - } - - const newId = generateUUID(); - this.duplicatedElements.push({ - trackId: newTrackId, - elementId: newId, - }); - newTrackElements.push( - buildDuplicateElement({ - element, - id: newId, - startTime: element.startTime, - }), - ); - } - - const newTrack = { - ...newTrackBase, - elements: newTrackElements, - } as TimelineTrack; - - const insertIndex = getHighestInsertIndexForTrack({ - tracks: updatedTracks, - trackType: track.type, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - } - - editor.timeline.updateTracks(updatedTracks); - - if (this.duplicatedElements.length > 0) { - editor.selection.setSelectedElements({ - elements: this.duplicatedElements, - }); - } - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ - elements: this.previousSelection, - }); - } - } - - getDuplicatedElements(): { trackId: string; elementId: string }[] { - return this.duplicatedElements; - } -} - -function buildDuplicateElement({ - element, - id, - startTime, -}: { - element: TimelineElement; - id: string; - startTime: number; -}): TimelineElement { - return { - ...element, - id, - name: `${element.name} (copy)`, - startTime, - animations: cloneAnimations({ - animations: element.animations, - shouldRegenerateKeyframeIds: true, - }), - }; -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import {
+ buildEmptyTrack,
+ getHighestInsertIndexForTrack,
+} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
+
+interface DuplicateElementsParams {
+ elements: { trackId: string; elementId: string }[];
+}
+
+export class DuplicateElementsCommand extends Command {
+ private duplicatedElements: { trackId: string; elementId: string }[] = [];
+ private savedState: TimelineTrack[] | null = null;
+ private previousSelection: { trackId: string; elementId: string }[] = [];
+ private elements: DuplicateElementsParams["elements"];
+
+ constructor({ elements }: DuplicateElementsParams) {
+ super();
+ this.elements = elements;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ this.previousSelection = editor.selection.getSelectedElements();
+ this.duplicatedElements = [];
+
+ const updatedTracks = [...this.savedState];
+
+ for (const track of this.savedState) {
+ const elementsToDuplicate = this.elements.filter(
+ (elementEntry) => elementEntry.trackId === track.id,
+ );
+
+ if (elementsToDuplicate.length === 0) {
+ continue;
+ }
+
+ const elementIdsToDuplicate = new Set(
+ elementsToDuplicate.map((element) => element.elementId),
+ );
+ const newTrackElements: TimelineElement[] = [];
+
+ const newTrackId = generateUUID();
+ const newTrackBase = buildEmptyTrack({
+ id: newTrackId,
+ type: track.type,
+ });
+
+ for (const element of track.elements) {
+ if (!elementIdsToDuplicate.has(element.id)) {
+ continue;
+ }
+
+ const newId = generateUUID();
+ this.duplicatedElements.push({
+ trackId: newTrackId,
+ elementId: newId,
+ });
+ newTrackElements.push(
+ buildDuplicateElement({
+ element,
+ id: newId,
+ startTime: element.startTime,
+ }),
+ );
+ }
+
+ const newTrack = {
+ ...newTrackBase,
+ elements: newTrackElements,
+ } as TimelineTrack;
+
+ const insertIndex = getHighestInsertIndexForTrack({
+ tracks: updatedTracks,
+ trackType: track.type,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.duplicatedElements.length > 0) {
+ editor.selection.setSelectedElements({
+ elements: this.duplicatedElements,
+ });
+ }
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ editor.selection.setSelectedElements({
+ elements: this.previousSelection,
+ });
+ }
+ }
+
+ getDuplicatedElements(): { trackId: string; elementId: string }[] {
+ return this.duplicatedElements;
+ }
+}
+
+function buildDuplicateElement({
+ element,
+ id,
+ startTime,
+}: {
+ element: TimelineElement;
+ id: string;
+ startTime: number;
+}): TimelineElement {
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts index 34ca12ab..3d27b345 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -1,74 +1,74 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; -import { buildDefaultEffectInstance } from "@/lib/effects"; - -function addEffectToElement({ - element, - effectType, -}: { - element: VisualElement; - effectType: string; -}): VisualElement { - const instance = buildDefaultEffectInstance({ effectType }); - const currentEffects = element.effects ?? []; - return { ...element, effects: [...currentEffects, instance] }; -} - -export class AddClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private effectId: string | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectType: string; - - constructor({ - trackId, - elementId, - effectType, - }: { - trackId: string; - elementId: string; - effectType: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectType = effectType; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const updated = addEffectToElement({ - element: element as VisualElement, - effectType: this.effectType, - }); - const effects = updated.effects ?? []; - this.effectId = effects[effects.length - 1]?.id ?? null; - return updated; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getEffectId(): string | null { - return this.effectId; - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+
+function addEffectToElement({
+ element,
+ effectType,
+}: {
+ element: VisualElement;
+ effectType: string;
+}): VisualElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ const currentEffects = element.effects ?? [];
+ return { ...element, effects: [...currentEffects, instance] };
+}
+
+export class AddClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private effectId: string | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectType: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectType,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectType = effectType;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const updated = addEffectToElement({
+ element: element as VisualElement,
+ effectType: this.effectType,
+ });
+ const effects = updated.effects ?? [];
+ this.effectId = effects[effects.length - 1]?.id ?? null;
+ return updated;
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getEffectId(): string | null {
+ return this.effectId;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts index 133ce661..b03c51e6 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/index.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -1,5 +1,5 @@ -export { AddClipEffectCommand } from "./add-effect"; -export { RemoveClipEffectCommand } from "./remove-effect"; -export { ToggleClipEffectCommand } from "./toggle-effect"; -export { UpdateClipEffectParamsCommand } from "./update-effect-params"; -export { ReorderClipEffectsCommand } from "./reorder-effect"; +export { AddClipEffectCommand } from "./add-effect";
+export { RemoveClipEffectCommand } from "./remove-effect";
+export { ToggleClipEffectCommand } from "./toggle-effect";
+export { UpdateClipEffectParamsCommand } from "./update-effect-params";
+export { ReorderClipEffectsCommand } from "./reorder-effect";
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts index 1b309f13..88b445f3 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -1,65 +1,65 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -function removeEffectFromElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const filtered = currentEffects.filter((effect) => effect.id !== effectId); - return { ...element, effects: filtered }; -} - -export class RemoveClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return removeEffectFromElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
+
+function removeEffectFromElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const filtered = currentEffects.filter((effect) => effect.id !== effectId);
+ return { ...element, effects: filtered };
+}
+
+export class RemoveClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return removeEffectFromElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts index d946be18..af211a29 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -1,73 +1,73 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -function reorderEffectsOnElement({ - element, - fromIndex, - toIndex, -}: { - element: VisualElement; - fromIndex: number; - toIndex: number; -}): VisualElement { - const effects = [...(element.effects ?? [])]; - const [moved] = effects.splice(fromIndex, 1); - effects.splice(toIndex, 0, moved); - return { ...element, effects }; -} - -export class ReorderClipEffectsCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly fromIndex: number; - private readonly toIndex: number; - - constructor({ - trackId, - elementId, - fromIndex, - toIndex, - }: { - trackId: string; - elementId: string; - fromIndex: number; - toIndex: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.fromIndex = fromIndex; - this.toIndex = toIndex; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return reorderEffectsOnElement({ - element: element as VisualElement, - fromIndex: this.fromIndex, - toIndex: this.toIndex, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
+
+function reorderEffectsOnElement({
+ element,
+ fromIndex,
+ toIndex,
+}: {
+ element: VisualElement;
+ fromIndex: number;
+ toIndex: number;
+}): VisualElement {
+ const effects = [...(element.effects ?? [])];
+ const [moved] = effects.splice(fromIndex, 1);
+ effects.splice(toIndex, 0, moved);
+ return { ...element, effects };
+}
+
+export class ReorderClipEffectsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly fromIndex: number;
+ private readonly toIndex: number;
+
+ constructor({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ }: {
+ trackId: string;
+ elementId: string;
+ fromIndex: number;
+ toIndex: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.fromIndex = fromIndex;
+ this.toIndex = toIndex;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return reorderEffectsOnElement({
+ element: element as VisualElement,
+ fromIndex: this.fromIndex,
+ toIndex: this.toIndex,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts index 25e7c356..2e55b676 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -1,67 +1,67 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -export function toggleEffectOnElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const updated = currentEffects.map((effect) => - effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, - ); - return { ...element, effects: updated }; -} - -export class ToggleClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return toggleEffectOnElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
+
+export function toggleEffectOnElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) =>
+ effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
+ );
+ return { ...element, effects: updated };
+}
+
+export class ToggleClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return toggleEffectOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 899754fb..a7e513fe 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -1,15 +1,15 @@ -export { InsertElementCommand } from "./insert-element"; -export { DeleteElementsCommand } from "./delete-elements"; -export { DuplicateElementsCommand } from "./duplicate-elements"; -export { UpdateElementTrimCommand } from "./update-element-trim"; -export { UpdateElementDurationCommand } from "./update-element-duration"; -export { UpdateElementStartTimeCommand } from "./update-element-start-time"; -export { SplitElementsCommand } from "./split-elements"; -export { UpdateElementCommand } from "./update-element"; -export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; -export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; -export { MoveElementCommand } from "./move-elements"; -export * from "./keyframes"; -export * from "./effects"; -export * from "./masks"; -export * from "./retime"; +export { InsertElementCommand } from "./insert-element";
+export { DeleteElementsCommand } from "./delete-elements";
+export { DuplicateElementsCommand } from "./duplicate-elements";
+export { UpdateElementTrimCommand } from "./update-element-trim";
+export { UpdateElementDurationCommand } from "./update-element-duration";
+export { UpdateElementStartTimeCommand } from "./update-element-start-time";
+export { SplitElementsCommand } from "./split-elements";
+export { UpdateElementCommand } from "./update-element";
+export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
+export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
+export { MoveElementCommand } from "./move-elements";
+export * from "./keyframes";
+export * from "./effects";
+export * from "./masks";
+export * from "./retime";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index acd72b66..e6d6ee99 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -1,358 +1,358 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - CreateTimelineElement, - TimelineTrack, - TimelineElement, - TrackType, - ElementType, -} from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { - requiresMediaId, - wouldElementOverlap, -} from "@/lib/timeline/element-utils"; -import { - buildEmptyTrack, - canElementGoOnTrack, - getDefaultInsertIndexForTrack, - validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; -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 } - | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; - -export interface InsertElementParams { - element: CreateTimelineElement; - placement: InsertElementPlacement; -} - -export class InsertElementCommand extends Command { - private elementId: string; - private savedState: TimelineTrack[] | null = null; - private targetTrackId: string | null = null; - - constructor({ element, placement }: InsertElementParams) { - super(); - this.elementId = generateUUID(); - this.element = element; - this.placement = placement; - } - - private element: CreateTimelineElement; - private placement: InsertElementPlacement; - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - if (!this.savedState) { - console.error("Tracks not available"); - return; - } - - if (!this.validateElementBasics({ element: this.element })) { - return; - } - - const totalElementsInTimeline = this.savedState.reduce( - (total, t) => total + t.elements.length, - 0, - ); - const isFirstElement = totalElementsInTimeline === 0; - - const newElement = this.buildElement({ element: this.element }); - const updateResult = this.resolveTracksWithElement({ - tracks: this.savedState, - element: newElement, - }); - - if (!updateResult) { - return; - } - - const { updatedTracks, targetTrackId } = updateResult; - this.targetTrackId = targetTrackId; - - const isVisualMedia = - newElement.type === "video" || newElement.type === "image"; - - if (isFirstElement && isVisualMedia) { - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); - const asset = mediaAssets.find( - (item: MediaAsset) => item.id === newElement.mediaId, - ); - - if (asset?.width && asset?.height) { - const nextCanvasSize = { width: asset.width, height: asset.height }; - const shouldSetOriginalCanvasSize = - !activeProject?.settings.originalCanvasSize; - editor.project.updateSettings({ - settings: { - canvasSize: nextCanvasSize, - ...(shouldSetOriginalCanvasSize - ? { originalCanvasSize: nextCanvasSize } - : {}), - }, - pushHistory: false, - }); - } - - if (asset?.type === "video" && asset?.fps) { - editor.project.updateSettings({ - settings: { fps: asset.fps }, - pushHistory: false, - }); - } - } - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getElementId(): string { - return this.elementId; - } - - getTrackId(): string | null { - return this.targetTrackId; - } - - private buildElement({ - element, - }: { - element: CreateTimelineElement; - }): TimelineElement { - return { - ...element, - id: this.elementId, - startTime: element.startTime, - trimStart: element.trimStart ?? 0, - trimEnd: element.trimEnd ?? 0, - duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - } as TimelineElement; - } - - private validateElementBasics({ - element, - }: { - element: CreateTimelineElement; - }): boolean { - if (requiresMediaId({ element }) && !("mediaId" in element)) { - console.error("Element requires mediaId"); - return false; - } - - if ( - element.type === "audio" && - element.sourceType === "library" && - !element.sourceUrl - ) { - console.error("Library audio element must have sourceUrl"); - return false; - } - - if (element.type === "sticker" && !element.stickerId) { - console.error("Sticker element must have stickerId"); - 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; - } - - if (element.type === "effect" && !element.effectType) { - console.error("Effect element must have effectType"); - return false; - } - - return true; - } - - private resolveTracksWithElement({ - tracks, - element, - }: { - tracks: TimelineTrack[]; - element: TimelineElement; - }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { - const placement = this.placement; - - if (placement.mode === "explicit") { - const targetTrack = tracks.find( - (track) => track.id === placement.trackId, - ); - - if (!targetTrack) { - console.error("Track not found:", placement.trackId); - return null; - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - console.error(validation.errorMessage); - return null; - } - - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: targetTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === targetTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: targetTrack.id }; - } - - const trackType = - placement.trackType ?? this.getTrackTypeForElement({ element }); - - if ( - placement.trackType && - !canElementGoOnTrack({ - elementType: element.type, - trackType, - }) - ) { - console.error( - `${element.type} elements cannot be placed on ${trackType} tracks`, - ); - return null; - } - - const elementEndTime = element.startTime + element.duration; - const existingTrack = tracks.find((track) => { - if ( - !canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }) - ) { - return false; - } - - return !wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime: elementEndTime, - }); - }); - - if (existingTrack) { - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: existingTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === existingTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: existingTrack.id }; - } - - const newTrackId = generateUUID(); - const newTrack = buildEmptyTrack({ - id: newTrackId, - type: trackType, - }); - const newTrackWithElement = { - ...newTrack, - elements: [...newTrack.elements, element], - } as TimelineTrack; - - const updatedTracks = [...tracks]; - const insertIndex = - placement.insertIndex ?? - this.getAutoInsertIndex({ tracks: updatedTracks, trackType }); - updatedTracks.splice(insertIndex, 0, newTrackWithElement); - - return { updatedTracks, targetTrackId: newTrackId }; - } - - private getAutoInsertIndex({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; - }): number { - if (trackType === "text") { - const firstVideoTrackIndex = tracks.findIndex( - (track) => track.type === "video", - ); - if (firstVideoTrackIndex >= 0) { - return firstVideoTrackIndex; - } - } - - return getDefaultInsertIndexForTrack({ - tracks, - trackType, - }); - } - - private adjustElementForMainTrack({ - tracks, - targetTrackId, - element, - }: { - tracks: TimelineTrack[]; - targetTrackId: string; - element: TimelineElement; - }): TimelineElement { - const adjustedStartTime = enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime: element.startTime, - }); - return { ...element, startTime: adjustedStartTime }; - } - - private getTrackTypeForElement({ - element, - }: { - element: { type: ElementType }; - }): TrackType { - return ELEMENT_TRACK_MAP[element.type]; - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ CreateTimelineElement,
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+ ElementType,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import {
+ requiresMediaId,
+ wouldElementOverlap,
+} from "@/lib/timeline/element-utils";
+import {
+ buildEmptyTrack,
+ canElementGoOnTrack,
+ getDefaultInsertIndexForTrack,
+ validateElementTrackCompatibility,
+ enforceMainTrackStart,
+} from "@/lib/timeline/track-utils";
+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 }
+ | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
+
+export interface InsertElementParams {
+ element: CreateTimelineElement;
+ placement: InsertElementPlacement;
+}
+
+export class InsertElementCommand extends Command {
+ private elementId: string;
+ private savedState: TimelineTrack[] | null = null;
+ private targetTrackId: string | null = null;
+
+ constructor({ element, placement }: InsertElementParams) {
+ super();
+ this.elementId = generateUUID();
+ this.element = element;
+ this.placement = placement;
+ }
+
+ private element: CreateTimelineElement;
+ private placement: InsertElementPlacement;
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ if (!this.savedState) {
+ console.error("Tracks not available");
+ return;
+ }
+
+ if (!this.validateElementBasics({ element: this.element })) {
+ return;
+ }
+
+ const totalElementsInTimeline = this.savedState.reduce(
+ (total, t) => total + t.elements.length,
+ 0,
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
+
+ const newElement = this.buildElement({ element: this.element });
+ const updateResult = this.resolveTracksWithElement({
+ tracks: this.savedState,
+ element: newElement,
+ });
+
+ if (!updateResult) {
+ return;
+ }
+
+ const { updatedTracks, targetTrackId } = updateResult;
+ this.targetTrackId = targetTrackId;
+
+ const isVisualMedia =
+ newElement.type === "video" || newElement.type === "image";
+
+ if (isFirstElement && isVisualMedia) {
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+ const asset = mediaAssets.find(
+ (item: MediaAsset) => item.id === newElement.mediaId,
+ );
+
+ if (asset?.width && asset?.height) {
+ const nextCanvasSize = { width: asset.width, height: asset.height };
+ const shouldSetOriginalCanvasSize =
+ !activeProject?.settings.originalCanvasSize;
+ editor.project.updateSettings({
+ settings: {
+ canvasSize: nextCanvasSize,
+ ...(shouldSetOriginalCanvasSize
+ ? { originalCanvasSize: nextCanvasSize }
+ : {}),
+ },
+ pushHistory: false,
+ });
+ }
+
+ if (asset?.type === "video" && asset?.fps) {
+ editor.project.updateSettings({
+ settings: { fps: asset.fps },
+ pushHistory: false,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getElementId(): string {
+ return this.elementId;
+ }
+
+ getTrackId(): string | null {
+ return this.targetTrackId;
+ }
+
+ private buildElement({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): TimelineElement {
+ return {
+ ...element,
+ id: this.elementId,
+ startTime: element.startTime,
+ trimStart: element.trimStart ?? 0,
+ trimEnd: element.trimEnd ?? 0,
+ duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ } as TimelineElement;
+ }
+
+ private validateElementBasics({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): boolean {
+ if (requiresMediaId({ element }) && !("mediaId" in element)) {
+ console.error("Element requires mediaId");
+ return false;
+ }
+
+ if (
+ element.type === "audio" &&
+ element.sourceType === "library" &&
+ !element.sourceUrl
+ ) {
+ console.error("Library audio element must have sourceUrl");
+ return false;
+ }
+
+ if (element.type === "sticker" && !element.stickerId) {
+ console.error("Sticker element must have stickerId");
+ 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;
+ }
+
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
+ return true;
+ }
+
+ private resolveTracksWithElement({
+ tracks,
+ element,
+ }: {
+ tracks: TimelineTrack[];
+ element: TimelineElement;
+ }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
+ const placement = this.placement;
+
+ if (placement.mode === "explicit") {
+ const targetTrack = tracks.find(
+ (track) => track.id === placement.trackId,
+ );
+
+ if (!targetTrack) {
+ console.error("Track not found:", placement.trackId);
+ return null;
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return null;
+ }
+
+ const adjustedElement = this.adjustElementForMainTrack({
+ tracks,
+ targetTrackId: targetTrack.id,
+ element,
+ });
+
+ const updatedTracks = tracks.map((track) =>
+ track.id === targetTrack.id
+ ? {
+ ...track,
+ elements: [...track.elements, adjustedElement],
+ }
+ : track,
+ ) as TimelineTrack[];
+
+ return { updatedTracks, targetTrackId: targetTrack.id };
+ }
+
+ const trackType =
+ placement.trackType ?? this.getTrackTypeForElement({ element });
+
+ if (
+ placement.trackType &&
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType,
+ })
+ ) {
+ console.error(
+ `${element.type} elements cannot be placed on ${trackType} tracks`,
+ );
+ return null;
+ }
+
+ const elementEndTime = element.startTime + element.duration;
+ const existingTrack = tracks.find((track) => {
+ if (
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ })
+ ) {
+ return false;
+ }
+
+ return !wouldElementOverlap({
+ elements: track.elements,
+ startTime: element.startTime,
+ endTime: elementEndTime,
+ });
+ });
+
+ if (existingTrack) {
+ const adjustedElement = this.adjustElementForMainTrack({
+ tracks,
+ targetTrackId: existingTrack.id,
+ element,
+ });
+
+ const updatedTracks = tracks.map((track) =>
+ track.id === existingTrack.id
+ ? {
+ ...track,
+ elements: [...track.elements, adjustedElement],
+ }
+ : track,
+ ) as TimelineTrack[];
+
+ return { updatedTracks, targetTrackId: existingTrack.id };
+ }
+
+ const newTrackId = generateUUID();
+ const newTrack = buildEmptyTrack({
+ id: newTrackId,
+ type: trackType,
+ });
+ const newTrackWithElement = {
+ ...newTrack,
+ elements: [...newTrack.elements, element],
+ } as TimelineTrack;
+
+ const updatedTracks = [...tracks];
+ const insertIndex =
+ placement.insertIndex ??
+ this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
+ updatedTracks.splice(insertIndex, 0, newTrackWithElement);
+
+ return { updatedTracks, targetTrackId: newTrackId };
+ }
+
+ private getAutoInsertIndex({
+ tracks,
+ trackType,
+ }: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+ }): number {
+ if (trackType === "text") {
+ const firstVideoTrackIndex = tracks.findIndex(
+ (track) => track.type === "video",
+ );
+ if (firstVideoTrackIndex >= 0) {
+ return firstVideoTrackIndex;
+ }
+ }
+
+ return getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+ }
+
+ private adjustElementForMainTrack({
+ tracks,
+ targetTrackId,
+ element,
+ }: {
+ tracks: TimelineTrack[];
+ targetTrackId: string;
+ element: TimelineElement;
+ }): TimelineElement {
+ const adjustedStartTime = enforceMainTrackStart({
+ tracks,
+ targetTrackId,
+ requestedStartTime: element.startTime,
+ });
+ return { ...element, startTime: adjustedStartTime };
+ }
+
+ private getTrackTypeForElement({
+ element,
+ }: {
+ element: { type: ElementType };
+ }): TrackType {
+ return ELEMENT_TRACK_MAP[element.type];
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts index c03b14e9..ea19286c 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -1,5 +1,5 @@ -export * from "./remove-effect-param-keyframe"; -export * from "./remove-keyframe"; -export * from "./retime-keyframe"; -export * from "./upsert-effect-param-keyframe"; -export * from "./upsert-keyframe"; +export * from "./remove-effect-param-keyframe";
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-effect-param-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts index 984a5ed7..aafa3c22 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -1,68 +1,68 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RemoveEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const animations = removeEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/lib/timeline";
+
+export class RemoveEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const animations = removeEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index eb5cf742..56676aa5 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,138 +1,138 @@ -import { EditorCore } from "@/core"; -import { - getChannel, - getChannelValueAtTime, - removeElementKeyframe, - resolveAnimationTarget, -} from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; - -function sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): AnimationValue | null { - const channel = getChannel({ - animations: element.animations, - propertyPath, - }); - const keyframe = channel?.keyframes.find( - (candidate) => candidate.id === keyframeId, - ); - if (!channel || !keyframe) { - return null; - } - - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return null; - } - const baseValue = target.getBaseValue(); - if (baseValue === null) { - return null; - } - - return getChannelValueAtTime({ - channel, - time: keyframe.time, - fallbackValue: baseValue, - }); -} - -function removeKeyframeAndPersist({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): TimelineElement { - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return element; - } - - const valueBefore = sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, - }); - - const nextAnimations = removeElementKeyframe({ - animations: element.animations, - propertyPath, - keyframeId, - }); - - const isChannelNowEmpty = - getChannel({ animations: nextAnimations, propertyPath }) === undefined; - const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; - - const baseElement = shouldPersistToBase - ? target.setBaseValue(valueBefore) - : element; - - return { ...baseElement, animations: nextAnimations }; -} - -export class RemoveKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => - removeKeyframeAndPersist({ - element, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core";
+import {
+ getChannel,
+ getChannelValueAtTime,
+ removeElementKeyframe,
+ resolveAnimationTarget,
+} from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+
+function sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): AnimationValue | null {
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath,
+ });
+ const keyframe = channel?.keyframes.find(
+ (candidate) => candidate.id === keyframeId,
+ );
+ if (!channel || !keyframe) {
+ return null;
+ }
+
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return null;
+ }
+ const baseValue = target.getBaseValue();
+ if (baseValue === null) {
+ return null;
+ }
+
+ return getChannelValueAtTime({
+ channel,
+ time: keyframe.time,
+ fallbackValue: baseValue,
+ });
+}
+
+function removeKeyframeAndPersist({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): TimelineElement {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return element;
+ }
+
+ const valueBefore = sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+ });
+
+ const nextAnimations = removeElementKeyframe({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+
+ const isChannelNowEmpty =
+ getChannel({ animations: nextAnimations, propertyPath }) === undefined;
+ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
+
+ const baseElement = shouldPersistToBase
+ ? target.setBaseValue(valueBefore)
+ : element;
+
+ return { ...baseElement, animations: nextAnimations };
+}
+
+export class RemoveKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) =>
+ removeKeyframeAndPersist({
+ element,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts index 9e912f27..7818ed0d 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,74 +1,74 @@ -import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/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: AnimationPath; - private readonly keyframeId: string; - private readonly nextTime: number; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - nextTime, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - nextTime: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - this.nextTime = nextTime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - if (!resolveAnimationTarget({ element, path: this.propertyPath })) { - return element; - } - - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); - return { - ...element, - animations: retimeElementKeyframe({ - animations: element.animations, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - time: boundedTime, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core";
+import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/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: AnimationPath;
+ private readonly keyframeId: string;
+ private readonly nextTime: number;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ nextTime: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.nextTime = nextTime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
+ const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ return {
+ ...element,
+ animations: retimeElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ time: boundedTime,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 285bd80c..8e541b72 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -1,84 +1,84 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class UpsertEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly time: number; - private readonly value: number; - private readonly interpolation: "linear" | "hold" | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - time: number; - value: number; - interpolation?: "linear" | "hold"; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - const animations = upsertEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/lib/timeline";
+
+export class UpsertEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly time: number;
+ private readonly value: number;
+ private readonly interpolation: "linear" | "hold" | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const animations = upsertEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 28e7feff..4b697705 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,95 +1,95 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { - AnimationPath, - AnimationInterpolation, - AnimationValue, -} 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: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; - private readonly interpolation: AnimationInterpolation | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - propertyPath, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - 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: 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, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
+import type {
+ AnimationPath,
+ AnimationInterpolation,
+ AnimationValue,
+} 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: AnimationPath;
+ private readonly time: number;
+ private readonly value: AnimationValue;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ 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: 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,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts index acc47184..5ceec931 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/index.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -1,2 +1,2 @@ -export { RemoveMaskCommand } from "./remove-mask"; -export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; +export { RemoveMaskCommand } from "./remove-mask";
+export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts index ee09cbdc..71c8b413 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -1,64 +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); - } - } -} +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);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts index ac3c8c7c..cfa02842 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -1,75 +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); - } - } -} +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);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index a36e8e93..24605d0b 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -1,161 +1,161 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - TimelineTrack, - TimelineElement, - TrackType, -} from "@/lib/timeline"; -import { - buildEmptyTrack, - isMainTrack, - validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; -import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; - -export class MoveElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly sourceTrackId: string; - private readonly targetTrackId: string; - private readonly elementId: string; - private readonly newStartTime: number; - private readonly createTrack: { type: TrackType; index: number } | undefined; - private readonly rippleEnabled: boolean; - - constructor({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, - rippleEnabled = false, - }: { - sourceTrackId: string; - targetTrackId: string; - elementId: string; - newStartTime: number; - createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; - }) { - super(); - this.sourceTrackId = sourceTrackId; - this.targetTrackId = targetTrackId; - this.elementId = elementId; - this.newStartTime = newStartTime; - this.createTrack = createTrack; - this.rippleEnabled = rippleEnabled; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const sourceTrack = this.savedState.find( - (track) => track.id === this.sourceTrackId, - ); - const element = sourceTrack?.elements.find( - (trackElement) => trackElement.id === this.elementId, - ); - - if (!sourceTrack || !element) { - throw new Error("Source track or element not found"); - } - - let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); - let tracksToUpdate = this.savedState; - if (!targetTrack && this.createTrack) { - const newTrack = buildEmptyTrack({ - id: this.targetTrackId, - type: this.createTrack.type, - }); - tracksToUpdate = [...this.savedState]; - tracksToUpdate.splice(this.createTrack.index, 0, newTrack); - targetTrack = newTrack; - } - if (!targetTrack) { - throw new Error("Target track not found"); - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - throw new Error(validation.errorMessage); - } - - const adjustedStartTime = enforceMainTrackStart({ - tracks: tracksToUpdate, - targetTrackId: this.targetTrackId, - requestedStartTime: this.newStartTime, - excludeElementId: this.elementId, - }); - - // keyframe times remain clip-local, so moving only changes element startTime. - const movedElement: TimelineElement = { - ...element, - startTime: adjustedStartTime, - }; - - const isSameTrack = this.sourceTrackId === this.targetTrackId; - - let updatedTracks = tracksToUpdate.map((track): TimelineTrack => { - if (isSameTrack && track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.map((trackElement) => - trackElement.id === this.elementId ? movedElement : trackElement, - ), - } as typeof track; - } - - if (track.id === this.sourceTrackId) { - const remainingElements = track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ); - const shiftedElements = this.rippleEnabled - ? rippleShiftElements({ - elements: remainingElements, - afterTime: element.startTime, - shiftAmount: element.duration, - }) - : remainingElements; - return { ...track, elements: shiftedElements } as typeof track; - } - - if (track.id === this.targetTrackId) { - return { - ...track, - elements: [...track.elements, movedElement], - } as typeof track; - } - - return track; - }); - - if (!isSameTrack) { - const sourceTrackAfterMove = updatedTracks.find( - (track) => track.id === this.sourceTrackId, - ); - if ( - sourceTrackAfterMove && - sourceTrackAfterMove.elements.length === 0 && - !isMainTrack(sourceTrackAfterMove) - ) { - updatedTracks = updatedTracks.filter( - (track) => track.id !== this.sourceTrackId, - ); - } - } - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+} from "@/lib/timeline";
+import {
+ buildEmptyTrack,
+ isMainTrack,
+ validateElementTrackCompatibility,
+ enforceMainTrackStart,
+} from "@/lib/timeline/track-utils";
+import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
+
+export class MoveElementCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly sourceTrackId: string;
+ private readonly targetTrackId: string;
+ private readonly elementId: string;
+ private readonly newStartTime: number;
+ private readonly createTrack: { type: TrackType; index: number } | undefined;
+ private readonly rippleEnabled: boolean;
+
+ constructor({
+ sourceTrackId,
+ targetTrackId,
+ elementId,
+ newStartTime,
+ createTrack,
+ rippleEnabled = false,
+ }: {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+ createTrack?: { type: TrackType; index: number };
+ rippleEnabled?: boolean;
+ }) {
+ super();
+ this.sourceTrackId = sourceTrackId;
+ this.targetTrackId = targetTrackId;
+ this.elementId = elementId;
+ this.newStartTime = newStartTime;
+ this.createTrack = createTrack;
+ this.rippleEnabled = rippleEnabled;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const sourceTrack = this.savedState.find(
+ (track) => track.id === this.sourceTrackId,
+ );
+ const element = sourceTrack?.elements.find(
+ (trackElement) => trackElement.id === this.elementId,
+ );
+
+ if (!sourceTrack || !element) {
+ throw new Error("Source track or element not found");
+ }
+
+ let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
+ let tracksToUpdate = this.savedState;
+ if (!targetTrack && this.createTrack) {
+ const newTrack = buildEmptyTrack({
+ id: this.targetTrackId,
+ type: this.createTrack.type,
+ });
+ tracksToUpdate = [...this.savedState];
+ tracksToUpdate.splice(this.createTrack.index, 0, newTrack);
+ targetTrack = newTrack;
+ }
+ if (!targetTrack) {
+ throw new Error("Target track not found");
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+
+ if (!validation.isValid) {
+ throw new Error(validation.errorMessage);
+ }
+
+ const adjustedStartTime = enforceMainTrackStart({
+ tracks: tracksToUpdate,
+ targetTrackId: this.targetTrackId,
+ requestedStartTime: this.newStartTime,
+ excludeElementId: this.elementId,
+ });
+
+ // keyframe times remain clip-local, so moving only changes element startTime.
+ const movedElement: TimelineElement = {
+ ...element,
+ startTime: adjustedStartTime,
+ };
+
+ const isSameTrack = this.sourceTrackId === this.targetTrackId;
+
+ let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
+ if (isSameTrack && track.id === this.sourceTrackId) {
+ return {
+ ...track,
+ elements: track.elements.map((trackElement) =>
+ trackElement.id === this.elementId ? movedElement : trackElement,
+ ),
+ } as typeof track;
+ }
+
+ if (track.id === this.sourceTrackId) {
+ const remainingElements = track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ );
+ const shiftedElements = this.rippleEnabled
+ ? rippleShiftElements({
+ elements: remainingElements,
+ afterTime: element.startTime,
+ shiftAmount: element.duration,
+ })
+ : remainingElements;
+ return { ...track, elements: shiftedElements } as typeof track;
+ }
+
+ if (track.id === this.targetTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, movedElement],
+ } as typeof track;
+ }
+
+ return track;
+ });
+
+ if (!isSameTrack) {
+ const sourceTrackAfterMove = updatedTracks.find(
+ (track) => track.id === this.sourceTrackId,
+ );
+ if (
+ sourceTrackAfterMove &&
+ sourceTrackAfterMove.elements.length === 0 &&
+ !isMainTrack(sourceTrackAfterMove)
+ ) {
+ updatedTracks = updatedTracks.filter(
+ (track) => track.id !== this.sourceTrackId,
+ );
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts index 5daa59bf..e9c24c44 100644 --- a/apps/web/src/lib/commands/timeline/element/retime/index.ts +++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts @@ -1 +1 @@ -export { UpdateElementRetimeCommand } from "./update-element-retime"; +export { UpdateElementRetimeCommand } from "./update-element-retime";
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts index d1f859e6..4057ec41 100644 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts @@ -1,114 +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); - } - } -} +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);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts index f416e7c5..30310806 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts @@ -1,57 +1,57 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementHaveAudio } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsMutedCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const mutableElements = this.elements.filter(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element); - }); - - if (mutableElements.length === 0) { - return; - } - - const shouldMute = mutableElements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element) && !element.muted; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = mutableElements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementHaveAudio(element) && - element.muted !== shouldMute - ? { ...element, muted: shouldMute } - : element; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { canElementHaveAudio } from "@/lib/timeline/element-utils";
+import { EditorCore } from "@/core";
+
+export class ToggleElementsMutedCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private elements: { trackId: string; elementId: string }[]) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const mutableElements = this.elements.filter(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementHaveAudio(element);
+ });
+
+ if (mutableElements.length === 0) {
+ return;
+ }
+
+ const shouldMute = mutableElements.some(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementHaveAudio(element) && !element.muted;
+ });
+
+ const updatedTracks = this.savedState.map((track) => {
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = mutableElements.some(
+ ({ trackId, elementId }) =>
+ track.id === trackId && element.id === elementId,
+ );
+ return shouldUpdate &&
+ canElementHaveAudio(element) &&
+ element.muted !== shouldMute
+ ? { ...element, muted: shouldMute }
+ : element;
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts index 24d51c15..690f61aa 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts @@ -1,47 +1,47 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementBeHidden } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const shouldHide = this.elements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementBeHidden(element) && !element.hidden; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementBeHidden(element) && - element.hidden !== shouldHide - ? { ...element, hidden: shouldHide } - : element; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { canElementBeHidden } from "@/lib/timeline/element-utils";
+import { EditorCore } from "@/core";
+
+export class ToggleElementsVisibilityCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private elements: { trackId: string; elementId: string }[]) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const shouldHide = this.elements.some(({ trackId, elementId }) => {
+ const track = this.savedState?.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ return element && canElementBeHidden(element) && !element.hidden;
+ });
+
+ const updatedTracks = this.savedState.map((track) => {
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = this.elements.some(
+ ({ trackId, elementId }) =>
+ track.id === trackId && element.id === elementId,
+ );
+ return shouldUpdate &&
+ canElementBeHidden(element) &&
+ element.hidden !== shouldHide
+ ? { ...element, hidden: shouldHide }
+ : element;
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts index d88cf9cb..4280dd4d 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -1,57 +1,57 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; - -export class UpdateElementDurationCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly duration: number; - - constructor({ - trackId, - elementId, - duration, - }: { - trackId: string; - elementId: string; - duration: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.duration = duration; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - if (track.id !== this.trackId) return track; - const newElements = track.elements.map((element) => - element.id === this.elementId - ? { - ...element, - duration: this.duration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: this.duration, - }), - } - : element, - ); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
+
+export class UpdateElementDurationCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly duration: number;
+
+ constructor({
+ trackId,
+ elementId,
+ duration,
+ }: {
+ trackId: string;
+ elementId: string;
+ duration: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.duration = duration;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id !== this.trackId) return track;
+ const newElements = track.elements.map((element) =>
+ element.id === this.elementId
+ ? {
+ ...element,
+ duration: this.duration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: this.duration,
+ }),
+ }
+ : element,
+ );
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index 75773ec2..d640d001 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,69 +1,69 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; - -export class UpdateElementStartTimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - private readonly startTime: number; - - constructor({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }) { - super(); - this.elements = elements; - this.startTime = startTime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const currentTracks = this.savedState; - const updatedTracks = currentTracks.map((track) => { - const hasElementsToUpdate = this.elements.some( - (elementEntry) => elementEntry.trackId === track.id, - ); - - if (!hasElementsToUpdate) { - return track; - } - - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - (elementEntry) => - elementEntry.elementId === element.id && - elementEntry.trackId === track.id, - ); - if (!shouldUpdate) { - return element; - } - - const baseStartTime = Math.max(0, this.startTime); - const adjustedStartTime = enforceMainTrackStart({ - tracks: currentTracks, - targetTrackId: track.id, - requestedStartTime: baseStartTime, - excludeElementId: element.id, - }); - - return { ...element, startTime: adjustedStartTime }; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
+
+export class UpdateElementStartTimeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly startTime: number;
+
+ constructor({
+ elements,
+ startTime,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ startTime: number;
+ }) {
+ super();
+ this.elements = elements;
+ this.startTime = startTime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const currentTracks = this.savedState;
+ const updatedTracks = currentTracks.map((track) => {
+ const hasElementsToUpdate = this.elements.some(
+ (elementEntry) => elementEntry.trackId === track.id,
+ );
+
+ if (!hasElementsToUpdate) {
+ return track;
+ }
+
+ const newElements = track.elements.map((element) => {
+ const shouldUpdate = this.elements.some(
+ (elementEntry) =>
+ elementEntry.elementId === element.id &&
+ elementEntry.trackId === track.id,
+ );
+ if (!shouldUpdate) {
+ return element;
+ }
+
+ const baseStartTime = Math.max(0, this.startTime);
+ const adjustedStartTime = enforceMainTrackStart({
+ tracks: currentTracks,
+ targetTrackId: track.id,
+ requestedStartTime: baseStartTime,
+ excludeElementId: element.id,
+ });
+
+ return { ...element, startTime: adjustedStartTime };
+ });
+ return { ...track, elements: newElements } as typeof track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index cf0c5cbc..494fa1f6 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -1,47 +1,47 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { updateElementInTracks } from "@/lib/timeline"; - -export class UpdateElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly updates: Partial<TimelineElement>; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial<TimelineElement>; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.updates = updates; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => ({ ...element, ...this.updates }) as TimelineElement, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { updateElementInTracks } from "@/lib/timeline";
+
+export class UpdateElementCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly updates: Partial<TimelineElement>;
+
+ constructor({
+ trackId,
+ elementId,
+ updates,
+ }: {
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.updates = updates;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts index cb285d94..d0bfd5e9 100644 --- a/apps/web/src/lib/commands/timeline/index.ts +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -1,5 +1,5 @@ -export * from "./track"; -export * from "./element"; -export * from "./clipboard"; - -export { TracksSnapshotCommand } from "./tracks-snapshot"; +export * from "./track";
+export * from "./element";
+export * from "./clipboard";
+
+export { TracksSnapshotCommand } from "./tracks-snapshot";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index dd4ceb64..b1f0daa0 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,53 +1,53 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getDefaultInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; - -export class AddTrackCommand extends Command { - private trackId: string; - private savedState: TimelineTrack[] | null = null; - - constructor( - private type: TrackType, - private index?: number, - ) { - super(); - this.trackId = generateUUID(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const newTrack: TimelineTrack = buildEmptyTrack({ - id: this.trackId, - type: this.type, - }); - - const updatedTracks = [...(this.savedState || [])]; - const insertIndex = - this.index ?? - getDefaultInsertIndexForTrack({ - tracks: updatedTracks, - trackType: this.type, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getTrackId(): string { - return this.trackId; - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TrackType, TimelineTrack } from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import {
+ buildEmptyTrack,
+ getDefaultInsertIndexForTrack,
+} from "@/lib/timeline/track-utils";
+
+export class AddTrackCommand extends Command {
+ private trackId: string;
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(
+ private type: TrackType,
+ private index?: number,
+ ) {
+ super();
+ this.trackId = generateUUID();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const newTrack: TimelineTrack = buildEmptyTrack({
+ id: this.trackId,
+ type: this.type,
+ });
+
+ const updatedTracks = [...(this.savedState || [])];
+ const insertIndex =
+ this.index ??
+ getDefaultInsertIndexForTrack({
+ tracks: updatedTracks,
+ trackType: this.type,
+ });
+ updatedTracks.splice(insertIndex, 0, newTrack);
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getTrackId(): string {
+ return this.trackId;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index 35f29b1c..a9859a1a 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,35 +1,35 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/lib/timeline"; -import { getMainTrack } from "@/lib/timeline"; - -export class RemoveTrackCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - const mainTrack = getMainTrack({ tracks: this.savedState }); - if (mainTrack?.id === targetTrack?.id) { - return; - } - const updatedTracks = this.savedState.filter( - (track) => track.id !== this.trackId, - ); - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TimelineTrack } from "@/lib/timeline";
+import { getMainTrack } from "@/lib/timeline";
+
+export class RemoveTrackCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ const mainTrack = getMainTrack({ tracks: this.savedState });
+ if (mainTrack?.id === targetTrack?.id) {
+ return;
+ }
+ const updatedTracks = this.savedState.filter(
+ (track) => track.id !== this.trackId,
+ );
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 9acf732a..120220fe 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,39 +1,39 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTracktHaveAudio } from "@/lib/timeline"; - -export class ToggleTrackMuteCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => - track.id === this.trackId && canTracktHaveAudio(track) - ? { ...track, muted: !track.muted } - : track, - ); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { canTracktHaveAudio } from "@/lib/timeline";
+
+export class ToggleTrackMuteCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ if (!targetTrack) {
+ return;
+ }
+
+ const updatedTracks = this.savedState.map((track) =>
+ track.id === this.trackId && canTracktHaveAudio(track)
+ ? { ...track, muted: !track.muted }
+ : track,
+ );
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts index 4bfd53c0..6d74e238 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -1,40 +1,40 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackBeHidden } from "@/lib/timeline"; - -export class ToggleTrackVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => { - if (track.id === this.trackId && canTrackBeHidden(track)) { - return { ...track, hidden: !track.hidden }; - } - return track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+import { canTrackBeHidden } from "@/lib/timeline";
+
+export class ToggleTrackVisibilityCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+
+ constructor(private trackId: string) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const targetTrack = this.savedState.find(
+ (track) => track.id === this.trackId,
+ );
+ if (!targetTrack) {
+ return;
+ }
+
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id === this.trackId && canTrackBeHidden(track)) {
+ return { ...track, hidden: !track.hidden };
+ }
+ return track;
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index a4370cab..d397431c 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,20 +1,20 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; - -export class TracksSnapshotCommand extends Command { - constructor( - private before: TimelineTrack[], - private after: TimelineTrack[], - ) { - super(); - } - - execute(): void { - EditorCore.getInstance().timeline.updateTracks(this.after); - } - - undo(): void { - EditorCore.getInstance().timeline.updateTracks(this.before); - } -} +import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/lib/timeline";
+import { EditorCore } from "@/core";
+
+export class TracksSnapshotCommand extends Command {
+ constructor(
+ private before: TimelineTrack[],
+ private after: TimelineTrack[],
+ ) {
+ super();
+ }
+
+ execute(): void {
+ EditorCore.getInstance().timeline.updateTracks(this.after);
+ }
+
+ undo(): void {
+ EditorCore.getInstance().timeline.updateTracks(this.before);
+ }
+}
diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts index d847db95..5cbcfbe0 100644 --- a/apps/web/src/lib/drag-data.ts +++ b/apps/web/src/lib/drag-data.ts @@ -1,48 +1,48 @@ -import type { TimelineDragData } from "@/lib/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} +import type { TimelineDragData } from "@/lib/timeline/drag";
+
+const MIME_TYPE = "application/x-timeline-drag";
+let lastDragData: TimelineDragData | null = null;
+
+export function setDragData({
+ dataTransfer,
+ dragData,
+}: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
+}): void {
+ dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
+ dataTransfer.setData("text/plain", JSON.stringify(dragData));
+ lastDragData = dragData;
+}
+
+export function getDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): TimelineDragData | null {
+ const data = dataTransfer.getData(MIME_TYPE);
+ if (data) return JSON.parse(data) as TimelineDragData;
+
+ const textData = dataTransfer.getData("text/plain");
+ if (textData) {
+ try {
+ return JSON.parse(textData) as TimelineDragData;
+ } catch {
+ return lastDragData;
+ }
+ }
+
+ return lastDragData;
+}
+
+export function hasDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): boolean {
+ return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
+}
+
+export function clearDragData(): void {
+ lastDragData = null;
+}
diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl index 5febc835..770faf0c 100644 --- a/apps/web/src/lib/effects/effect.vert.glsl +++ b/apps/web/src/lib/effects/effect.vert.glsl @@ -1,7 +1,7 @@ -attribute vec2 a_position; -varying vec2 v_texCoord; - -void main() { - v_texCoord = a_position * 0.5 + 0.5; - gl_Position = vec4(a_position, 0.0, 1.0); -} +attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts index 2b85007a..29cc3202 100644 --- a/apps/web/src/lib/effects/registry.ts +++ b/apps/web/src/lib/effects/registry.ts @@ -1,10 +1,10 @@ -import { DefinitionRegistry } from "@/lib/registry"; -import type { EffectDefinition } from "@/lib/effects/types"; - -export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> { - constructor() { - super("effect"); - } -} - -export const effectsRegistry = new EffectsRegistry(); +import { DefinitionRegistry } from "@/lib/registry";
+import type { EffectDefinition } from "@/lib/effects/types";
+
+export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> {
+ constructor() {
+ super("effect");
+ }
+}
+
+export const effectsRegistry = new EffectsRegistry();
diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts index 7f85389f..5573ef12 100644 --- a/apps/web/src/lib/effects/types.ts +++ b/apps/web/src/lib/effects/types.ts @@ -1,42 +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; -} +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;
+}
diff --git a/apps/web/src/lib/fonts/types.ts b/apps/web/src/lib/fonts/types.ts index a2db6f91..89ebce08 100644 --- a/apps/web/src/lib/fonts/types.ts +++ b/apps/web/src/lib/fonts/types.ts @@ -1,24 +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>; -} +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>;
+}
diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts index 55440e3c..342780b4 100644 --- a/apps/web/src/lib/gradients/parser.ts +++ b/apps/web/src/lib/gradients/parser.ts @@ -1,652 +1,652 @@ -/* - * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js - */ - -type GradientType = - | "linear-gradient" - | "repeating-linear-gradient" - | "radial-gradient" - | "repeating-radial-gradient"; - -type DirectionalOrientation = { type: "directional"; value: string }; -type AngularOrientation = { type: "angular"; value: string }; -type LinearOrientation = DirectionalOrientation | AngularOrientation; - -type Distance = - | { type: "%"; value: string } - | { type: "position-keyword"; value: string } - | { type: "calc"; value: string } - | { type: "px"; value: string } - | { type: "em"; value: string }; - -type PositionValue = { x?: Distance; y?: Distance }; -type Position = { type: "position"; value: PositionValue }; - -type ExtentKeyword = { type: "extent-keyword"; value: string }; - -type ShapeValue = "circle" | "ellipse"; -type Shape = { - type: "shape"; - value: ShapeValue; - style?: Distance | ExtentKeyword | Position; - at?: Position; -}; - -type DefaultRadial = { type: "default-radial"; at: Position }; - -type RadialOrientation = - | Shape - | (ExtentKeyword & { at?: Position }) - | DefaultRadial; - -export type GradientOrientation = LinearOrientation | Array<RadialOrientation>; - -export type Color = - | { type: "hex"; value: string } - | { type: "literal"; value: string } - | { type: "rgb"; value: Array<string> } - | { type: "rgba"; value: Array<string> } - | { type: "hsl"; value: [string, string, string] } - | { type: "hsla"; value: [string, string, string, string] } - | { type: "var"; value: string }; - -export type ColorStop = Color & { length?: Distance }; - -export type GradientAst = { - type: GradientType; - orientation: GradientOrientation | undefined; - colorStops: Array<ColorStop>; -}; - -type Tokens = { - linearGradient: RegExp; - repeatingLinearGradient: RegExp; - radialGradient: RegExp; - repeatingRadialGradient: RegExp; - sideOrCorner: RegExp; - extentKeywords: RegExp; - positionKeywords: RegExp; - pixelValue: RegExp; - percentageValue: RegExp; - emValue: RegExp; - angleValue: RegExp; - radianValue: RegExp; - startCall: RegExp; - endCall: RegExp; - comma: RegExp; - hexColor: RegExp; - literalColor: RegExp; - rgbColor: RegExp; - rgbaColor: RegExp; - varColor: RegExp; - calcValue: RegExp; - variableName: RegExp; - number: RegExp; - hslColor: RegExp; - hslaColor: RegExp; -}; - -const tokens: Tokens = { - linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i, - repeatingLinearGradient: - /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i, - radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i, - repeatingRadialGradient: - /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i, - sideOrCorner: - /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i, - extentKeywords: - /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/, - positionKeywords: /^(left|center|right|top|bottom)/i, - pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/, - percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/, - emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/, - angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/, - radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/, - startCall: /^\(/, - endCall: /^\)/, - comma: /^,/, - hexColor: /^#([0-9a-fA-F]+)/, - literalColor: /^([a-zA-Z]+)/, - rgbColor: /^rgb/i, - rgbaColor: /^rgba/i, - varColor: /^var/i, - calcValue: /^calc/i, - variableName: /^(--[a-zA-Z0-9-,\s#]+)/, - number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/, - hslColor: /^hsl/i, - hslaColor: /^hsla/i, -}; - -let input = ""; - -const error = ({ message }: { message: string }): never => { - const err = new Error(`${input}: ${message}`); - (err as Error & { source?: string }).source = input; - throw err; -}; - -const getAst = (): Array<GradientAst> => { - const ast = matchListDefinitions(); - - if (input.length > 0) { - error({ message: "Invalid input not EOF" }); - } - - return ast; -}; - -const matchListDefinitions = (): Array<GradientAst> => - matchListing({ matcher: matchDefinition }); - -const matchDefinition = (): GradientAst | undefined => - matchGradient({ - gradientType: "linear-gradient", - pattern: tokens.linearGradient, - orientationMatcher: matchLinearOrientation, - }) || - matchGradient({ - gradientType: "repeating-linear-gradient", - pattern: tokens.repeatingLinearGradient, - orientationMatcher: matchLinearOrientation, - }) || - matchGradient({ - gradientType: "radial-gradient", - pattern: tokens.radialGradient, - orientationMatcher: matchListRadialOrientations, - }) || - matchGradient({ - gradientType: "repeating-radial-gradient", - pattern: tokens.repeatingRadialGradient, - orientationMatcher: matchListRadialOrientations, - }); - -const matchGradient = ({ - gradientType, - pattern, - orientationMatcher, -}: { - gradientType: GradientType; - pattern: RegExp; - orientationMatcher: () => GradientOrientation | undefined; -}): GradientAst | undefined => - matchCall({ - pattern, - callback: () => { - const orientation = orientationMatcher(); - if (orientation && !scan({ regexp: tokens.comma })) { - error({ message: "Missing comma before color stops" }); - } - - return { - type: gradientType, - orientation, - colorStops: matchListing({ matcher: matchColorStop }), - }; - }, - }); - -const matchCall = <T>({ - pattern, - callback, -}: { - pattern: RegExp; - callback: (captures: RegExpExecArray) => T; -}): T | undefined => { - const captures = scan({ regexp: pattern }); - - if (!captures) { - return undefined; - } - - if (!scan({ regexp: tokens.startCall })) { - error({ message: "Missing (" }); - } - - const result = callback(captures); - - if (!scan({ regexp: tokens.endCall })) { - error({ message: "Missing )" }); - } - - return result; -}; - -const matchLinearOrientation = (): LinearOrientation | undefined => { - const sideOrCorner = matchSideOrCorner(); - if (sideOrCorner) { - return sideOrCorner; - } - - const legacyDirection = match({ - type: "position-keyword", - pattern: tokens.positionKeywords, - captureIndex: 1, - }); - if (legacyDirection) { - return { - type: "directional", - value: legacyDirection.value, - }; - } - - return matchAngle(); -}; - -const matchSideOrCorner = (): DirectionalOrientation | undefined => - match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 }); - -const matchAngle = (): AngularOrientation | undefined => - match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) || - match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 }); - -const matchListRadialOrientations = (): - | Array<RadialOrientation> - | undefined => { - const radialOrientation = matchRadialOrientation(); - if (!radialOrientation) { - return undefined; - } - - const radialOrientations: Array<RadialOrientation> = [radialOrientation]; - const lookaheadCache = input; - - if (!scan({ regexp: tokens.comma })) { - return radialOrientations; - } - - const nextRadial = matchRadialOrientation(); - if (!nextRadial) { - input = lookaheadCache; - return radialOrientations; - } - - radialOrientations.push(nextRadial); - return radialOrientations; -}; - -const matchRadialOrientation = (): RadialOrientation | undefined => { - const radialType = matchCircle() || matchEllipse(); - if (radialType) { - radialType.at = matchAtPosition(); - return radialType; - } - - const extent = matchExtentKeyword(); - if (extent) { - const positionAt = matchAtPosition(); - if (positionAt) { - return { ...extent, at: positionAt }; - } - return extent; - } - - const implicitEllipse = matchImplicitEllipse(); - if (implicitEllipse) { - return implicitEllipse; - } - - const atPosition = matchAtPosition(); - if (atPosition) { - return { type: "default-radial", at: atPosition }; - } - - const defaultPosition = matchPositioning(); - if (defaultPosition) { - return { type: "default-radial", at: defaultPosition }; - } - - return undefined; -}; - -const matchImplicitEllipse = (): Shape | undefined => { - const lookaheadCache = input; - - const width = matchDistance(); - if (!width) { - return undefined; - } - - const height = matchDistance(); - if (!height) { - input = lookaheadCache; - return undefined; - } - - const atPos = matchAtPosition(); - if (!atPos) { - input = lookaheadCache; - return undefined; - } - - return { - type: "shape", - value: "ellipse", - style: { type: "position", value: { x: width, y: height } }, - at: atPos, - }; -}; - -const matchCircle = (): Shape | undefined => { - const circle = match({ - type: "shape", - pattern: /^(circle)/i, - captureIndex: 0, - }) as Shape | undefined; - - if (!circle) { - return undefined; - } - - circle.style = matchLength() || matchExtentKeyword(); - circle.value = "circle"; - return circle; -}; - -const matchEllipse = (): Shape | undefined => { - const ellipse = match({ - type: "shape", - pattern: /^(ellipse)/i, - captureIndex: 0, - }) as Shape | undefined; - - if (!ellipse) { - return undefined; - } - - ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword(); - ellipse.value = "ellipse"; - return ellipse; -}; - -const matchExtentKeyword = (): ExtentKeyword | undefined => - match({ - type: "extent-keyword", - pattern: tokens.extentKeywords, - captureIndex: 1, - }); - -const matchAtPosition = (): Position | undefined => { - if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) { - return undefined; - } - - const positioning = matchPositioning(); - if (!positioning) { - error({ message: "Missing positioning value" }); - } - - return positioning; -}; - -const matchPositioning = (): Position | undefined => { - const location = matchCoordinates(); - - if (!location.x && !location.y) { - return undefined; - } - - return { - type: "position", - value: location, - }; -}; - -const matchCoordinates = (): PositionValue => ({ - x: matchDistance(), - y: matchDistance(), -}); - -const matchListing = <T>({ - matcher, -}: { - matcher: () => T | undefined; -}): Array<T> => { - const captures = matcher(); - const result: Array<T> = []; - - if (!captures) { - return result; - } - - result.push(captures); - while (scan({ regexp: tokens.comma })) { - const nextCapture = matcher() ?? error({ message: "One extra comma" }); - result.push(nextCapture); - } - - return result; -}; - -const matchColorStop = (): ColorStop => { - const color = matchColor() ?? error({ message: "Expected color definition" }); - const length = matchDistance(); - return { ...color, length }; -}; - -const matchColor = (): Color | undefined => - matchHexColor() || - matchHSLAColor() || - matchHSLColor() || - matchRGBAColor() || - matchRGBColor() || - matchVarColor() || - matchLiteralColor(); - -const matchLiteralColor = (): Color | undefined => - match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 }); - -const matchHexColor = (): Color | undefined => - match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 }); - -const matchRGBColor = (): Color | undefined => - matchCall({ - pattern: tokens.rgbColor, - callback: () => ({ - type: "rgb", - value: matchListing({ matcher: matchNumber }), - }), - }); - -const matchRGBAColor = (): Color | undefined => - matchCall({ - pattern: tokens.rgbaColor, - callback: () => ({ - type: "rgba", - value: matchListing({ matcher: matchNumber }), - }), - }); - -const matchVarColor = (): Color | undefined => - matchCall({ - pattern: tokens.varColor, - callback: () => ({ - type: "var", - value: matchVariableName(), - }), - }); - -const matchHSLColor = (): Color | undefined => - matchCall({ - pattern: tokens.hslColor, - callback: () => { - const lookahead = scan({ regexp: tokens.percentageValue }); - if (lookahead) { - error({ - message: - "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage", - }); - } - - const hue = matchNumber(); - scan({ regexp: tokens.comma }); - let captures = scan({ regexp: tokens.percentageValue }); - const sat = captures ? captures[1] : null; - scan({ regexp: tokens.comma }); - captures = scan({ regexp: tokens.percentageValue }); - const light = captures ? captures[1] : null; - const ensuredSat = - sat ?? - error({ - message: - "Expected percentage value for saturation and lightness in HSL", - }); - const ensuredLight = - light ?? - error({ - message: - "Expected percentage value for saturation and lightness in HSL", - }); - return { - type: "hsl", - value: [hue, ensuredSat, ensuredLight], - }; - }, - }); - -const matchHSLAColor = (): Color | undefined => - matchCall({ - pattern: tokens.hslaColor, - callback: () => { - const hue = matchNumber(); - scan({ regexp: tokens.comma }); - let captures = scan({ regexp: tokens.percentageValue }); - const sat = captures ? captures[1] : null; - scan({ regexp: tokens.comma }); - captures = scan({ regexp: tokens.percentageValue }); - const light = captures ? captures[1] : null; - scan({ regexp: tokens.comma }); - const alpha = matchNumber(); - const ensuredSat = - sat ?? - error({ - message: - "Expected percentage value for saturation and lightness in HSLA", - }); - const ensuredLight = - light ?? - error({ - message: - "Expected percentage value for saturation and lightness in HSLA", - }); - return { - type: "hsla", - value: [hue, ensuredSat, ensuredLight, alpha], - }; - }, - }); - -const matchVariableName = (): string => { - const captures = - scan({ regexp: tokens.variableName }) ?? - error({ message: "Expected CSS variable name" }); - return captures[1]; -}; - -const matchNumber = (): string => { - const captures = - scan({ regexp: tokens.number }) ?? error({ message: "Expected number" }); - return captures[1]; -}; - -const matchDistance = (): Distance | undefined => - match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) || - matchPositionKeyword() || - matchCalc() || - matchLength(); - -const matchPositionKeyword = (): Distance | undefined => - match({ - type: "position-keyword", - pattern: tokens.positionKeywords, - captureIndex: 1, - }); - -const matchCalc = (): Distance | undefined => - matchCall({ - pattern: tokens.calcValue, - callback: () => { - let openParenCount = 1; - let index = 0; - - while (openParenCount > 0 && index < input.length) { - const char = input.charAt(index); - if (char === "(") { - openParenCount++; - } else if (char === ")") { - openParenCount--; - } - index++; - } - - if (openParenCount > 0) { - error({ message: "Missing closing parenthesis in calc() expression" }); - } - - const calcContent = input.slice(0, index - 1); - consume({ size: index - 1 }); - - return { - type: "calc", - value: calcContent, - }; - }, - }); - -const matchLength = (): Distance | undefined => - match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) || - match({ type: "em", pattern: tokens.emValue, captureIndex: 1 }); - -const match = <TType extends string>({ - type, - pattern, - captureIndex, -}: { - type: TType; - pattern: RegExp; - captureIndex: number; -}): { type: TType; value: string } | undefined => { - const captures = scan({ regexp: pattern }); - if (!captures) { - return undefined; - } - - return { - type, - value: captures[captureIndex], - }; -}; - -const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => { - const blankCaptures = /^[\n\r\t\s]+/.exec(input); - if (blankCaptures) { - consume({ size: blankCaptures[0].length }); - } - - const captures = regexp.exec(input); - if (captures) { - consume({ size: captures[0].length }); - } - - return captures; -}; - -const consume = ({ size }: { size: number }): void => { - input = input.slice(size); -}; - -export const parseGradient = ({ - code, -}: { - code: string; -}): Array<GradientAst> => { - input = code.toString().trim(); - if (input.endsWith(";")) { - input = input.slice(0, -1); - } - return getAst(); -}; - -export const GradientParser = { - parse: parseGradient, -}; +/*
+ * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js
+ */
+
+type GradientType =
+ | "linear-gradient"
+ | "repeating-linear-gradient"
+ | "radial-gradient"
+ | "repeating-radial-gradient";
+
+type DirectionalOrientation = { type: "directional"; value: string };
+type AngularOrientation = { type: "angular"; value: string };
+type LinearOrientation = DirectionalOrientation | AngularOrientation;
+
+type Distance =
+ | { type: "%"; value: string }
+ | { type: "position-keyword"; value: string }
+ | { type: "calc"; value: string }
+ | { type: "px"; value: string }
+ | { type: "em"; value: string };
+
+type PositionValue = { x?: Distance; y?: Distance };
+type Position = { type: "position"; value: PositionValue };
+
+type ExtentKeyword = { type: "extent-keyword"; value: string };
+
+type ShapeValue = "circle" | "ellipse";
+type Shape = {
+ type: "shape";
+ value: ShapeValue;
+ style?: Distance | ExtentKeyword | Position;
+ at?: Position;
+};
+
+type DefaultRadial = { type: "default-radial"; at: Position };
+
+type RadialOrientation =
+ | Shape
+ | (ExtentKeyword & { at?: Position })
+ | DefaultRadial;
+
+export type GradientOrientation = LinearOrientation | Array<RadialOrientation>;
+
+export type Color =
+ | { type: "hex"; value: string }
+ | { type: "literal"; value: string }
+ | { type: "rgb"; value: Array<string> }
+ | { type: "rgba"; value: Array<string> }
+ | { type: "hsl"; value: [string, string, string] }
+ | { type: "hsla"; value: [string, string, string, string] }
+ | { type: "var"; value: string };
+
+export type ColorStop = Color & { length?: Distance };
+
+export type GradientAst = {
+ type: GradientType;
+ orientation: GradientOrientation | undefined;
+ colorStops: Array<ColorStop>;
+};
+
+type Tokens = {
+ linearGradient: RegExp;
+ repeatingLinearGradient: RegExp;
+ radialGradient: RegExp;
+ repeatingRadialGradient: RegExp;
+ sideOrCorner: RegExp;
+ extentKeywords: RegExp;
+ positionKeywords: RegExp;
+ pixelValue: RegExp;
+ percentageValue: RegExp;
+ emValue: RegExp;
+ angleValue: RegExp;
+ radianValue: RegExp;
+ startCall: RegExp;
+ endCall: RegExp;
+ comma: RegExp;
+ hexColor: RegExp;
+ literalColor: RegExp;
+ rgbColor: RegExp;
+ rgbaColor: RegExp;
+ varColor: RegExp;
+ calcValue: RegExp;
+ variableName: RegExp;
+ number: RegExp;
+ hslColor: RegExp;
+ hslaColor: RegExp;
+};
+
+const tokens: Tokens = {
+ linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i,
+ repeatingLinearGradient:
+ /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i,
+ radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i,
+ repeatingRadialGradient:
+ /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i,
+ sideOrCorner:
+ /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
+ extentKeywords:
+ /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/,
+ positionKeywords: /^(left|center|right|top|bottom)/i,
+ pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
+ percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/,
+ emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
+ angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
+ radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
+ startCall: /^\(/,
+ endCall: /^\)/,
+ comma: /^,/,
+ hexColor: /^#([0-9a-fA-F]+)/,
+ literalColor: /^([a-zA-Z]+)/,
+ rgbColor: /^rgb/i,
+ rgbaColor: /^rgba/i,
+ varColor: /^var/i,
+ calcValue: /^calc/i,
+ variableName: /^(--[a-zA-Z0-9-,\s#]+)/,
+ number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
+ hslColor: /^hsl/i,
+ hslaColor: /^hsla/i,
+};
+
+let input = "";
+
+const error = ({ message }: { message: string }): never => {
+ const err = new Error(`${input}: ${message}`);
+ (err as Error & { source?: string }).source = input;
+ throw err;
+};
+
+const getAst = (): Array<GradientAst> => {
+ const ast = matchListDefinitions();
+
+ if (input.length > 0) {
+ error({ message: "Invalid input not EOF" });
+ }
+
+ return ast;
+};
+
+const matchListDefinitions = (): Array<GradientAst> =>
+ matchListing({ matcher: matchDefinition });
+
+const matchDefinition = (): GradientAst | undefined =>
+ matchGradient({
+ gradientType: "linear-gradient",
+ pattern: tokens.linearGradient,
+ orientationMatcher: matchLinearOrientation,
+ }) ||
+ matchGradient({
+ gradientType: "repeating-linear-gradient",
+ pattern: tokens.repeatingLinearGradient,
+ orientationMatcher: matchLinearOrientation,
+ }) ||
+ matchGradient({
+ gradientType: "radial-gradient",
+ pattern: tokens.radialGradient,
+ orientationMatcher: matchListRadialOrientations,
+ }) ||
+ matchGradient({
+ gradientType: "repeating-radial-gradient",
+ pattern: tokens.repeatingRadialGradient,
+ orientationMatcher: matchListRadialOrientations,
+ });
+
+const matchGradient = ({
+ gradientType,
+ pattern,
+ orientationMatcher,
+}: {
+ gradientType: GradientType;
+ pattern: RegExp;
+ orientationMatcher: () => GradientOrientation | undefined;
+}): GradientAst | undefined =>
+ matchCall({
+ pattern,
+ callback: () => {
+ const orientation = orientationMatcher();
+ if (orientation && !scan({ regexp: tokens.comma })) {
+ error({ message: "Missing comma before color stops" });
+ }
+
+ return {
+ type: gradientType,
+ orientation,
+ colorStops: matchListing({ matcher: matchColorStop }),
+ };
+ },
+ });
+
+const matchCall = <T>({
+ pattern,
+ callback,
+}: {
+ pattern: RegExp;
+ callback: (captures: RegExpExecArray) => T;
+}): T | undefined => {
+ const captures = scan({ regexp: pattern });
+
+ if (!captures) {
+ return undefined;
+ }
+
+ if (!scan({ regexp: tokens.startCall })) {
+ error({ message: "Missing (" });
+ }
+
+ const result = callback(captures);
+
+ if (!scan({ regexp: tokens.endCall })) {
+ error({ message: "Missing )" });
+ }
+
+ return result;
+};
+
+const matchLinearOrientation = (): LinearOrientation | undefined => {
+ const sideOrCorner = matchSideOrCorner();
+ if (sideOrCorner) {
+ return sideOrCorner;
+ }
+
+ const legacyDirection = match({
+ type: "position-keyword",
+ pattern: tokens.positionKeywords,
+ captureIndex: 1,
+ });
+ if (legacyDirection) {
+ return {
+ type: "directional",
+ value: legacyDirection.value,
+ };
+ }
+
+ return matchAngle();
+};
+
+const matchSideOrCorner = (): DirectionalOrientation | undefined =>
+ match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 });
+
+const matchAngle = (): AngularOrientation | undefined =>
+ match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) ||
+ match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 });
+
+const matchListRadialOrientations = ():
+ | Array<RadialOrientation>
+ | undefined => {
+ const radialOrientation = matchRadialOrientation();
+ if (!radialOrientation) {
+ return undefined;
+ }
+
+ const radialOrientations: Array<RadialOrientation> = [radialOrientation];
+ const lookaheadCache = input;
+
+ if (!scan({ regexp: tokens.comma })) {
+ return radialOrientations;
+ }
+
+ const nextRadial = matchRadialOrientation();
+ if (!nextRadial) {
+ input = lookaheadCache;
+ return radialOrientations;
+ }
+
+ radialOrientations.push(nextRadial);
+ return radialOrientations;
+};
+
+const matchRadialOrientation = (): RadialOrientation | undefined => {
+ const radialType = matchCircle() || matchEllipse();
+ if (radialType) {
+ radialType.at = matchAtPosition();
+ return radialType;
+ }
+
+ const extent = matchExtentKeyword();
+ if (extent) {
+ const positionAt = matchAtPosition();
+ if (positionAt) {
+ return { ...extent, at: positionAt };
+ }
+ return extent;
+ }
+
+ const implicitEllipse = matchImplicitEllipse();
+ if (implicitEllipse) {
+ return implicitEllipse;
+ }
+
+ const atPosition = matchAtPosition();
+ if (atPosition) {
+ return { type: "default-radial", at: atPosition };
+ }
+
+ const defaultPosition = matchPositioning();
+ if (defaultPosition) {
+ return { type: "default-radial", at: defaultPosition };
+ }
+
+ return undefined;
+};
+
+const matchImplicitEllipse = (): Shape | undefined => {
+ const lookaheadCache = input;
+
+ const width = matchDistance();
+ if (!width) {
+ return undefined;
+ }
+
+ const height = matchDistance();
+ if (!height) {
+ input = lookaheadCache;
+ return undefined;
+ }
+
+ const atPos = matchAtPosition();
+ if (!atPos) {
+ input = lookaheadCache;
+ return undefined;
+ }
+
+ return {
+ type: "shape",
+ value: "ellipse",
+ style: { type: "position", value: { x: width, y: height } },
+ at: atPos,
+ };
+};
+
+const matchCircle = (): Shape | undefined => {
+ const circle = match({
+ type: "shape",
+ pattern: /^(circle)/i,
+ captureIndex: 0,
+ }) as Shape | undefined;
+
+ if (!circle) {
+ return undefined;
+ }
+
+ circle.style = matchLength() || matchExtentKeyword();
+ circle.value = "circle";
+ return circle;
+};
+
+const matchEllipse = (): Shape | undefined => {
+ const ellipse = match({
+ type: "shape",
+ pattern: /^(ellipse)/i,
+ captureIndex: 0,
+ }) as Shape | undefined;
+
+ if (!ellipse) {
+ return undefined;
+ }
+
+ ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword();
+ ellipse.value = "ellipse";
+ return ellipse;
+};
+
+const matchExtentKeyword = (): ExtentKeyword | undefined =>
+ match({
+ type: "extent-keyword",
+ pattern: tokens.extentKeywords,
+ captureIndex: 1,
+ });
+
+const matchAtPosition = (): Position | undefined => {
+ if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) {
+ return undefined;
+ }
+
+ const positioning = matchPositioning();
+ if (!positioning) {
+ error({ message: "Missing positioning value" });
+ }
+
+ return positioning;
+};
+
+const matchPositioning = (): Position | undefined => {
+ const location = matchCoordinates();
+
+ if (!location.x && !location.y) {
+ return undefined;
+ }
+
+ return {
+ type: "position",
+ value: location,
+ };
+};
+
+const matchCoordinates = (): PositionValue => ({
+ x: matchDistance(),
+ y: matchDistance(),
+});
+
+const matchListing = <T>({
+ matcher,
+}: {
+ matcher: () => T | undefined;
+}): Array<T> => {
+ const captures = matcher();
+ const result: Array<T> = [];
+
+ if (!captures) {
+ return result;
+ }
+
+ result.push(captures);
+ while (scan({ regexp: tokens.comma })) {
+ const nextCapture = matcher() ?? error({ message: "One extra comma" });
+ result.push(nextCapture);
+ }
+
+ return result;
+};
+
+const matchColorStop = (): ColorStop => {
+ const color = matchColor() ?? error({ message: "Expected color definition" });
+ const length = matchDistance();
+ return { ...color, length };
+};
+
+const matchColor = (): Color | undefined =>
+ matchHexColor() ||
+ matchHSLAColor() ||
+ matchHSLColor() ||
+ matchRGBAColor() ||
+ matchRGBColor() ||
+ matchVarColor() ||
+ matchLiteralColor();
+
+const matchLiteralColor = (): Color | undefined =>
+ match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 });
+
+const matchHexColor = (): Color | undefined =>
+ match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 });
+
+const matchRGBColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.rgbColor,
+ callback: () => ({
+ type: "rgb",
+ value: matchListing({ matcher: matchNumber }),
+ }),
+ });
+
+const matchRGBAColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.rgbaColor,
+ callback: () => ({
+ type: "rgba",
+ value: matchListing({ matcher: matchNumber }),
+ }),
+ });
+
+const matchVarColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.varColor,
+ callback: () => ({
+ type: "var",
+ value: matchVariableName(),
+ }),
+ });
+
+const matchHSLColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.hslColor,
+ callback: () => {
+ const lookahead = scan({ regexp: tokens.percentageValue });
+ if (lookahead) {
+ error({
+ message:
+ "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
+ });
+ }
+
+ const hue = matchNumber();
+ scan({ regexp: tokens.comma });
+ let captures = scan({ regexp: tokens.percentageValue });
+ const sat = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ captures = scan({ regexp: tokens.percentageValue });
+ const light = captures ? captures[1] : null;
+ const ensuredSat =
+ sat ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSL",
+ });
+ const ensuredLight =
+ light ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSL",
+ });
+ return {
+ type: "hsl",
+ value: [hue, ensuredSat, ensuredLight],
+ };
+ },
+ });
+
+const matchHSLAColor = (): Color | undefined =>
+ matchCall({
+ pattern: tokens.hslaColor,
+ callback: () => {
+ const hue = matchNumber();
+ scan({ regexp: tokens.comma });
+ let captures = scan({ regexp: tokens.percentageValue });
+ const sat = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ captures = scan({ regexp: tokens.percentageValue });
+ const light = captures ? captures[1] : null;
+ scan({ regexp: tokens.comma });
+ const alpha = matchNumber();
+ const ensuredSat =
+ sat ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSLA",
+ });
+ const ensuredLight =
+ light ??
+ error({
+ message:
+ "Expected percentage value for saturation and lightness in HSLA",
+ });
+ return {
+ type: "hsla",
+ value: [hue, ensuredSat, ensuredLight, alpha],
+ };
+ },
+ });
+
+const matchVariableName = (): string => {
+ const captures =
+ scan({ regexp: tokens.variableName }) ??
+ error({ message: "Expected CSS variable name" });
+ return captures[1];
+};
+
+const matchNumber = (): string => {
+ const captures =
+ scan({ regexp: tokens.number }) ?? error({ message: "Expected number" });
+ return captures[1];
+};
+
+const matchDistance = (): Distance | undefined =>
+ match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) ||
+ matchPositionKeyword() ||
+ matchCalc() ||
+ matchLength();
+
+const matchPositionKeyword = (): Distance | undefined =>
+ match({
+ type: "position-keyword",
+ pattern: tokens.positionKeywords,
+ captureIndex: 1,
+ });
+
+const matchCalc = (): Distance | undefined =>
+ matchCall({
+ pattern: tokens.calcValue,
+ callback: () => {
+ let openParenCount = 1;
+ let index = 0;
+
+ while (openParenCount > 0 && index < input.length) {
+ const char = input.charAt(index);
+ if (char === "(") {
+ openParenCount++;
+ } else if (char === ")") {
+ openParenCount--;
+ }
+ index++;
+ }
+
+ if (openParenCount > 0) {
+ error({ message: "Missing closing parenthesis in calc() expression" });
+ }
+
+ const calcContent = input.slice(0, index - 1);
+ consume({ size: index - 1 });
+
+ return {
+ type: "calc",
+ value: calcContent,
+ };
+ },
+ });
+
+const matchLength = (): Distance | undefined =>
+ match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) ||
+ match({ type: "em", pattern: tokens.emValue, captureIndex: 1 });
+
+const match = <TType extends string>({
+ type,
+ pattern,
+ captureIndex,
+}: {
+ type: TType;
+ pattern: RegExp;
+ captureIndex: number;
+}): { type: TType; value: string } | undefined => {
+ const captures = scan({ regexp: pattern });
+ if (!captures) {
+ return undefined;
+ }
+
+ return {
+ type,
+ value: captures[captureIndex],
+ };
+};
+
+const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => {
+ const blankCaptures = /^[\n\r\t\s]+/.exec(input);
+ if (blankCaptures) {
+ consume({ size: blankCaptures[0].length });
+ }
+
+ const captures = regexp.exec(input);
+ if (captures) {
+ consume({ size: captures[0].length });
+ }
+
+ return captures;
+};
+
+const consume = ({ size }: { size: number }): void => {
+ input = input.slice(size);
+};
+
+export const parseGradient = ({
+ code,
+}: {
+ code: string;
+}): Array<GradientAst> => {
+ input = code.toString().trim();
+ if (input.endsWith(";")) {
+ input = input.slice(0, -1);
+ }
+ return getAst();
+};
+
+export const GradientParser = {
+ parse: parseGradient,
+};
diff --git a/apps/web/src/lib/graphics/definitions/ellipse.ts b/apps/web/src/lib/graphics/definitions/ellipse.ts index 3fd76608..04b93c27 100644 --- a/apps/web/src/lib/graphics/definitions/ellipse.ts +++ b/apps/web/src/lib/graphics/definitions/ellipse.ts @@ -1,73 +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, - }); - } - }, -}; +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,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/index.ts b/apps/web/src/lib/graphics/definitions/index.ts index e791f917..6fd9e446 100644 --- a/apps/web/src/lib/graphics/definitions/index.ts +++ b/apps/web/src/lib/graphics/definitions/index.ts @@ -1,29 +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"; +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";
diff --git a/apps/web/src/lib/graphics/definitions/polygon.ts b/apps/web/src/lib/graphics/definitions/polygon.ts index 780c974c..deecb6ff 100644 --- a/apps/web/src/lib/graphics/definitions/polygon.ts +++ b/apps/web/src/lib/graphics/definitions/polygon.ts @@ -1,211 +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, - }); - } - }, -}; +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,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/rectangle.ts b/apps/web/src/lib/graphics/definitions/rectangle.ts index 4e7f78f6..fb52c03a 100644 --- a/apps/web/src/lib/graphics/definitions/rectangle.ts +++ b/apps/web/src/lib/graphics/definitions/rectangle.ts @@ -1,85 +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, - }); - } - }, -}; +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,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/definitions/shared.ts b/apps/web/src/lib/graphics/definitions/shared.ts index d2833f51..4f56c77c 100644 --- a/apps/web/src/lib/graphics/definitions/shared.ts +++ b/apps/web/src/lib/graphics/definitions/shared.ts @@ -1,16 +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" }, - ], -}; +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" },
+ ],
+};
diff --git a/apps/web/src/lib/graphics/definitions/star.ts b/apps/web/src/lib/graphics/definitions/star.ts index c7f9ef56..2160c125 100644 --- a/apps/web/src/lib/graphics/definitions/star.ts +++ b/apps/web/src/lib/graphics/definitions/star.ts @@ -1,109 +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, - }); - } - }, -}; +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,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/lib/graphics/index.ts b/apps/web/src/lib/graphics/index.ts index 5286e932..9002a151 100644 --- a/apps/web/src/lib/graphics/index.ts +++ b/apps/web/src/lib/graphics/index.ts @@ -1,125 +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"; +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";
diff --git a/apps/web/src/lib/graphics/registry.ts b/apps/web/src/lib/graphics/registry.ts index 7f2ab9fd..2f6a5850 100644 --- a/apps/web/src/lib/graphics/registry.ts +++ b/apps/web/src/lib/graphics/registry.ts @@ -1,10 +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(); +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();
diff --git a/apps/web/src/lib/graphics/stroke.ts b/apps/web/src/lib/graphics/stroke.ts index 98f9f43f..230d9904 100644 --- a/apps/web/src/lib/graphics/stroke.ts +++ b/apps/web/src/lib/graphics/stroke.ts @@ -1,102 +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, - }); -} +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,
+ });
+}
diff --git a/apps/web/src/lib/graphics/types.ts b/apps/web/src/lib/graphics/types.ts index af932d39..22bfc5ac 100644 --- a/apps/web/src/lib/graphics/types.ts +++ b/apps/web/src/lib/graphics/types.ts @@ -1,23 +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; -} +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;
+}
diff --git a/apps/web/src/lib/guides/definitions/custom.tsx b/apps/web/src/lib/guides/definitions/custom.tsx index bc92cb84..fe7944cb 100644 --- a/apps/web/src/lib/guides/definitions/custom.tsx +++ b/apps/web/src/lib/guides/definitions/custom.tsx @@ -1,24 +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; +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;
diff --git a/apps/web/src/lib/guides/definitions/platforms.tsx b/apps/web/src/lib/guides/definitions/platforms.tsx index ffeec1c0..8c403eb1 100644 --- a/apps/web/src/lib/guides/definitions/platforms.tsx +++ b/apps/web/src/lib/guides/definitions/platforms.tsx @@ -1,65 +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", -}); +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",
+});
diff --git a/apps/web/src/lib/guides/definitions/tiktok-layout.tsx b/apps/web/src/lib/guides/definitions/tiktok-layout.tsx index 794d1379..b7513bac 100644 --- a/apps/web/src/lib/guides/definitions/tiktok-layout.tsx +++ b/apps/web/src/lib/guides/definitions/tiktok-layout.tsx @@ -1,335 +1,335 @@ -export function TikTokLayout() { - return ( - <svg - width="100%" - height="100%" - viewBox="0 0 1080 1920" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <title>TikTok Layout</title> - <path - d="M56.2488 1751.28H51.6355C51.458 1750.3 51.1278 1749.43 50.6447 1748.68C50.1617 1747.93 49.5702 1747.3 48.8704 1746.77C48.1704 1746.25 47.3868 1745.85 46.5194 1745.59C45.6617 1745.32 44.7498 1745.19 43.7838 1745.19C42.0391 1745.19 40.4766 1745.63 39.0965 1746.5C37.7264 1747.38 36.642 1748.67 35.8435 1750.36C35.055 1752.06 34.6605 1754.13 34.6605 1756.57C34.6605 1759.04 35.055 1761.12 35.8435 1762.81C36.642 1764.51 37.7313 1765.79 39.1114 1766.65C40.4914 1767.52 42.044 1767.95 43.7689 1767.95C44.7253 1767.95 45.6322 1767.83 46.4897 1767.57C47.3571 1767.3 48.141 1766.91 48.8408 1766.4C49.5408 1765.89 50.1321 1765.26 50.6151 1764.53C51.108 1763.78 51.4481 1762.92 51.6355 1761.95L56.2488 1761.97C56.0023 1763.46 55.5243 1764.83 54.8146 1766.08C54.1145 1767.32 53.2125 1768.39 52.1086 1769.3C51.0143 1770.2 49.7625 1770.89 48.3528 1771.38C46.9431 1771.88 45.4053 1772.12 43.7395 1772.12C41.1173 1772.12 38.7811 1771.5 36.7307 1770.26C34.6803 1769.01 33.0636 1767.22 31.8808 1764.9C30.7077 1762.57 30.1211 1759.79 30.1211 1756.57C30.1211 1753.34 30.7126 1750.57 31.8954 1748.25C33.0785 1745.92 34.6951 1744.14 36.7455 1742.9C38.796 1741.65 41.1272 1741.02 43.7395 1741.02C45.3462 1741.02 46.8447 1741.25 48.2347 1741.72C49.6342 1742.17 50.8912 1742.84 52.005 1743.73C53.1191 1744.6 54.0406 1745.68 54.7701 1746.95C55.4995 1748.21 55.9924 1749.66 56.2488 1751.28ZM61.3612 1771.71V1749H65.6344V1752.61H65.8709C66.285 1751.39 67.0144 1750.43 68.0596 1749.73C69.1143 1749.02 70.307 1748.66 71.6379 1748.66C71.9137 1748.66 72.239 1748.67 72.6138 1748.69C72.9982 1748.71 73.2987 1748.74 73.5155 1748.77V1753C73.3383 1752.95 73.0229 1752.89 72.5693 1752.83C72.1159 1752.76 71.6623 1752.73 71.209 1752.73C70.1641 1752.73 69.2325 1752.95 68.4143 1753.39C67.606 1753.83 66.9653 1754.43 66.4921 1755.21C66.019 1755.98 65.7825 1756.86 65.7825 1757.84V1771.71H61.3612ZM86.317 1772.17C84.0792 1772.17 82.1521 1771.69 80.5355 1770.73C78.9288 1769.77 77.6867 1768.41 76.8094 1766.67C75.9417 1764.91 75.5082 1762.86 75.5082 1760.5C75.5082 1758.18 75.9417 1756.13 76.8094 1754.36C77.6867 1752.58 78.909 1751.2 80.4764 1750.2C82.0535 1749.2 83.8971 1748.71 86.0066 1748.71C87.288 1748.71 88.5301 1748.92 89.7327 1749.34C90.9353 1749.77 92.0148 1750.43 92.9709 1751.34C93.927 1752.25 94.6812 1753.42 95.2332 1754.87C95.7851 1756.31 96.0612 1758.06 96.0612 1760.12V1761.69H78.007V1758.38H91.7288C91.7288 1757.21 91.4922 1756.18 91.0191 1755.29C90.546 1754.38 89.8805 1753.66 89.023 1753.14C88.1752 1752.62 87.1795 1752.36 86.036 1752.36C84.7941 1752.36 83.7097 1752.67 82.783 1753.28C81.8664 1753.88 81.1565 1754.67 80.6537 1755.64C80.1608 1756.61 79.9146 1757.66 79.9146 1758.79V1761.38C79.9146 1762.89 80.1805 1764.19 80.713 1765.25C81.2551 1766.31 82.0093 1767.13 82.9753 1767.69C83.9413 1768.24 85.07 1768.52 86.3613 1768.52C87.1993 1768.52 87.9633 1768.4 88.6532 1768.16C89.3434 1767.92 89.9396 1767.55 90.4424 1767.07C90.9452 1766.58 91.3295 1765.99 91.5958 1765.28L95.7802 1766.03C95.4453 1767.27 94.8438 1768.34 93.9765 1769.27C93.1187 1770.19 92.0392 1770.9 90.738 1771.41C89.4467 1771.92 87.973 1772.17 86.317 1772.17ZM107.576 1772.21C106.137 1772.21 104.836 1771.95 103.673 1771.41C102.509 1770.87 101.588 1770.09 100.907 1769.06C100.237 1768.04 99.9021 1766.78 99.9021 1765.29C99.9021 1764.01 100.148 1762.96 100.641 1762.13C101.134 1761.3 101.8 1760.65 102.638 1760.16C103.476 1759.68 104.412 1759.32 105.447 1759.07C106.482 1758.82 107.537 1758.64 108.611 1758.51C109.972 1758.35 111.076 1758.22 111.923 1758.12C112.771 1758.02 113.387 1757.84 113.772 1757.61C114.156 1757.37 114.348 1756.99 114.348 1756.45V1756.35C114.348 1755.06 113.984 1754.06 113.254 1753.35C112.535 1752.64 111.46 1752.29 110.031 1752.29C108.542 1752.29 107.369 1752.62 106.511 1753.28C105.664 1753.93 105.077 1754.65 104.752 1755.45L100.597 1754.5C101.09 1753.12 101.809 1752.01 102.756 1751.16C103.712 1750.31 104.811 1749.68 106.053 1749.3C107.295 1748.91 108.601 1748.71 109.972 1748.71C110.879 1748.71 111.84 1748.82 112.855 1749.03C113.88 1749.24 114.836 1749.62 115.724 1750.19C116.621 1750.75 117.355 1751.55 117.927 1752.59C118.498 1753.63 118.784 1754.98 118.784 1756.63V1771.71H114.467V1768.61H114.289C114.003 1769.18 113.574 1769.74 113.003 1770.29C112.431 1770.84 111.697 1771.3 110.8 1771.66C109.903 1772.03 108.828 1772.21 107.576 1772.21ZM108.537 1768.67C109.76 1768.67 110.804 1768.42 111.672 1767.94C112.549 1767.46 113.215 1766.83 113.668 1766.05C114.132 1765.26 114.363 1764.42 114.363 1763.52V1760.59C114.206 1760.75 113.9 1760.9 113.446 1761.04C113.003 1761.16 112.495 1761.28 111.923 1761.38C111.352 1761.46 110.795 1761.55 110.252 1761.63C109.71 1761.7 109.257 1761.76 108.892 1761.8C108.035 1761.91 107.251 1762.1 106.541 1762.35C105.841 1762.61 105.279 1762.98 104.856 1763.46C104.442 1763.93 104.234 1764.57 104.234 1765.35C104.234 1766.45 104.639 1767.27 105.447 1767.84C106.255 1768.39 107.285 1768.67 108.537 1768.67ZM135.275 1749V1752.55H122.869V1749H135.275ZM126.196 1743.56H130.617V1765.04C130.617 1765.9 130.745 1766.54 131.001 1766.98C131.258 1767.4 131.588 1767.69 131.992 1767.85C132.406 1768 132.855 1768.07 133.338 1768.07C133.693 1768.07 134.003 1768.05 134.269 1768C134.536 1767.95 134.742 1767.91 134.891 1767.88L135.689 1771.53C135.433 1771.63 135.068 1771.73 134.595 1771.83C134.121 1771.94 133.53 1772 132.82 1772.01C131.657 1772.02 130.573 1771.82 129.567 1771.38C128.562 1770.95 127.748 1770.28 127.128 1769.37C126.507 1768.47 126.196 1767.33 126.196 1765.96V1743.56ZM149.488 1772.17C147.359 1772.17 145.501 1771.68 143.914 1770.7C142.327 1769.73 141.094 1768.36 140.217 1766.61C139.34 1764.86 138.901 1762.81 138.901 1760.46C138.901 1758.11 139.34 1756.04 140.217 1754.28C141.094 1752.52 142.327 1751.15 143.914 1750.17C145.501 1749.2 147.359 1748.71 149.488 1748.71C151.618 1748.71 153.476 1749.2 155.063 1750.17C156.65 1751.15 157.882 1752.52 158.759 1754.28C159.637 1756.04 160.075 1758.11 160.075 1760.46C160.075 1762.81 159.637 1764.86 158.759 1766.61C157.882 1768.36 156.65 1769.73 155.063 1770.7C153.476 1771.68 151.618 1772.17 149.488 1772.17ZM149.503 1768.46C150.883 1768.46 152.027 1768.09 152.934 1767.36C153.841 1766.63 154.511 1765.66 154.944 1764.45C155.388 1763.24 155.61 1761.9 155.61 1760.44C155.61 1759 155.388 1757.67 154.944 1756.45C154.511 1755.23 153.841 1754.25 152.934 1753.51C152.027 1752.77 150.883 1752.41 149.503 1752.41C148.113 1752.41 146.96 1752.77 146.043 1753.51C145.136 1754.25 144.461 1755.23 144.017 1756.45C143.584 1757.67 143.367 1759 143.367 1760.44C143.367 1761.9 143.584 1763.24 144.017 1764.45C144.461 1765.66 145.136 1766.63 146.043 1767.36C146.96 1768.09 148.113 1768.46 149.503 1768.46ZM165.01 1771.71V1749H169.284V1752.61H169.52C169.934 1751.39 170.664 1750.43 171.709 1749.73C172.763 1749.02 173.956 1748.66 175.287 1748.66C175.563 1748.66 175.888 1748.67 176.263 1748.69C176.647 1748.71 176.948 1748.74 177.165 1748.77V1753C176.987 1752.95 176.672 1752.89 176.218 1752.83C175.765 1752.76 175.311 1752.73 174.858 1752.73C173.813 1752.73 172.882 1752.95 172.063 1753.39C171.255 1753.83 170.614 1754.43 170.141 1755.21C169.668 1755.98 169.432 1756.86 169.432 1757.84V1771.71H165.01Z" - fill="white" - /> - <path - d="M43.4438 1811.44V1814.4H31.6738V1811.44H43.4438ZM35.1043 1806H38.5939V1827.64C38.5939 1828.63 38.7368 1829.37 39.0228 1829.86C39.3184 1830.34 39.6932 1830.67 40.1465 1830.84C40.6097 1830.99 41.0977 1831.07 41.6104 1831.07C41.9947 1831.07 42.3101 1831.05 42.5566 1831.01C42.803 1830.97 43.0003 1830.92 43.1481 1830.9L43.8578 1834.03C43.6212 1834.12 43.291 1834.21 42.8673 1834.3C42.4434 1834.39 41.906 1834.44 41.2554 1834.44C40.2699 1834.44 39.3036 1834.23 38.3573 1833.81C37.421 1833.38 36.6421 1832.74 36.0212 1831.87C35.4099 1831 35.1043 1829.91 35.1043 1828.59V1806ZM55.1775 1834.62C53.1271 1834.62 51.328 1834.13 49.7804 1833.16C48.2426 1832.18 47.04 1830.82 46.1726 1829.06C45.3149 1827.31 44.8863 1825.26 44.8863 1822.91C44.8863 1820.55 45.3149 1818.48 46.1726 1816.72C47.04 1814.96 48.2426 1813.59 49.7804 1812.61C51.328 1811.64 53.1271 1811.15 55.1775 1811.15C57.2279 1811.15 59.022 1811.64 60.5598 1812.61C62.1075 1813.59 63.31 1814.96 64.1675 1816.72C65.0352 1818.48 65.4687 1820.55 65.4687 1822.91C65.4687 1825.26 65.0352 1827.31 64.1675 1829.06C63.31 1830.82 62.1075 1832.18 60.5598 1833.16C59.022 1834.13 57.2279 1834.62 55.1775 1834.62ZM55.1775 1831.49C56.735 1831.49 58.0165 1831.09 59.022 1830.29C60.0274 1829.49 60.7716 1828.44 61.2547 1827.14C61.7377 1825.84 61.9792 1824.43 61.9792 1822.91C61.9792 1821.39 61.7377 1819.98 61.2547 1818.67C60.7716 1817.36 60.0274 1816.3 59.022 1815.49C58.0165 1814.68 56.735 1814.28 55.1775 1814.28C53.62 1814.28 52.3385 1814.68 51.333 1815.49C50.3277 1816.3 49.5834 1817.36 49.1004 1818.67C48.6173 1819.98 48.3758 1821.39 48.3758 1822.91C48.3758 1824.43 48.6173 1825.84 49.1004 1827.14C49.5834 1828.44 50.3277 1829.49 51.333 1830.29C52.3385 1831.09 53.62 1831.49 55.1775 1831.49ZM71.7869 1803.87V1834.15H68.2973V1803.87H71.7869ZM84.2561 1834.62C82.3636 1834.62 80.6926 1834.14 79.2436 1833.19C77.7945 1832.22 76.6609 1830.86 75.8427 1829.11C75.0245 1827.34 74.6154 1825.26 74.6154 1822.85C74.6154 1820.47 75.0245 1818.4 75.8427 1816.65C76.6609 1814.89 77.7995 1813.54 79.2584 1812.58C80.7173 1811.62 82.4029 1811.15 84.3154 1811.15C85.7941 1811.15 86.9621 1811.39 87.8198 1811.89C88.6872 1812.37 89.3477 1812.92 89.801 1813.54C90.2645 1814.15 90.6242 1814.66 90.8805 1815.05H91.1761V1803.87H94.6657V1834.15H91.2945V1830.66H90.8805C90.6242 1831.07 90.2596 1831.59 89.7862 1832.22C89.3131 1832.85 88.638 1833.4 87.7605 1833.89C86.8832 1834.38 85.7152 1834.62 84.2561 1834.62ZM84.7294 1831.49C86.129 1831.49 87.3121 1831.12 88.2781 1830.39C89.2441 1829.65 89.9785 1828.63 90.4813 1827.33C90.9841 1826.02 91.2355 1824.51 91.2355 1822.79C91.2355 1821.1 90.989 1819.62 90.4961 1818.34C90.0032 1817.06 89.2738 1816.07 88.3078 1815.36C87.3418 1814.64 86.1488 1814.28 84.7294 1814.28C83.2508 1814.28 82.0185 1814.66 81.0327 1815.42C80.0568 1816.17 79.3224 1817.19 78.8295 1818.48C78.3465 1819.76 78.105 1821.2 78.105 1822.79C78.105 1824.41 78.3515 1825.88 78.8444 1827.2C79.3471 1828.51 80.0865 1829.56 81.0624 1830.33C82.0482 1831.1 83.2705 1831.49 84.7294 1831.49ZM108.256 1834.15V1811.44H111.627V1814.99H111.923C112.396 1813.78 113.16 1812.84 114.215 1812.17C115.27 1811.49 116.536 1811.15 118.015 1811.15C119.513 1811.15 120.76 1811.49 121.756 1812.17C122.761 1812.84 123.545 1813.78 124.107 1814.99H124.343C124.925 1813.82 125.797 1812.89 126.961 1812.19C128.124 1811.5 129.519 1811.15 131.145 1811.15C133.176 1811.15 134.837 1811.78 136.128 1813.05C137.419 1814.32 138.065 1816.28 138.065 1818.95V1834.15H134.576V1818.95C134.576 1817.28 134.117 1816.08 133.2 1815.36C132.284 1814.64 131.204 1814.28 129.962 1814.28C128.365 1814.28 127.128 1814.76 126.251 1815.73C125.373 1816.68 124.935 1817.9 124.935 1819.36V1834.15H121.386V1818.6C121.386 1817.31 120.967 1816.27 120.129 1815.48C119.291 1814.68 118.212 1814.28 116.891 1814.28C115.984 1814.28 115.136 1814.52 114.348 1815C113.569 1815.49 112.938 1816.16 112.455 1817.02C111.982 1817.86 111.745 1818.84 111.745 1819.96V1834.15H108.256ZM143.382 1842.66C142.79 1842.66 142.263 1842.61 141.799 1842.51C141.336 1842.43 141.016 1842.34 140.838 1842.25L141.725 1839.17C142.573 1839.39 143.322 1839.47 143.973 1839.41C144.624 1839.35 145.2 1839.06 145.703 1838.54C146.216 1838.03 146.684 1837.19 147.108 1836.04L147.758 1834.26L139.36 1811.44H143.145L149.414 1829.53H149.651L155.92 1811.44H159.706L150.065 1837.46C149.631 1838.63 149.094 1839.6 148.453 1840.37C147.812 1841.15 147.068 1841.73 146.221 1842.1C145.383 1842.47 144.436 1842.66 143.382 1842.66ZM177.901 1816.53L174.766 1817.41C174.569 1816.89 174.278 1816.38 173.894 1815.89C173.519 1815.39 173.007 1814.98 172.356 1814.65C171.705 1814.33 170.873 1814.16 169.857 1814.16C168.467 1814.16 167.309 1814.48 166.382 1815.12C165.466 1815.75 165.007 1816.56 165.007 1817.53C165.007 1818.4 165.323 1819.08 165.954 1819.59C166.584 1820.09 167.57 1820.51 168.911 1820.84L172.282 1821.67C174.313 1822.16 175.826 1822.92 176.821 1823.93C177.817 1824.94 178.315 1826.23 178.315 1827.82C178.315 1829.12 177.941 1830.28 177.191 1831.31C176.452 1832.33 175.417 1833.14 174.086 1833.73C172.755 1834.32 171.208 1834.62 169.443 1834.62C167.127 1834.62 165.209 1834.12 163.691 1833.11C162.173 1832.11 161.212 1830.64 160.808 1828.71L164.12 1827.88C164.436 1829.1 165.032 1830.02 165.909 1830.63C166.796 1831.24 167.955 1831.55 169.384 1831.55C171.011 1831.55 172.302 1831.2 173.258 1830.51C174.224 1829.81 174.707 1828.97 174.707 1828C174.707 1827.21 174.431 1826.55 173.879 1826.02C173.327 1825.48 172.479 1825.07 171.336 1824.8L167.55 1823.92C165.471 1823.43 163.943 1822.66 162.967 1821.63C162.001 1820.58 161.518 1819.28 161.518 1817.71C161.518 1816.43 161.877 1815.3 162.597 1814.31C163.326 1813.32 164.317 1812.55 165.569 1811.99C166.831 1811.43 168.26 1811.15 169.857 1811.15C172.105 1811.15 173.869 1811.64 175.151 1812.62C176.442 1813.61 177.359 1814.91 177.901 1816.53ZM190.592 1834.62C188.404 1834.62 186.516 1834.14 184.929 1833.17C183.352 1832.2 182.134 1830.84 181.277 1829.09C180.429 1827.34 180.005 1825.3 180.005 1822.97C180.005 1820.65 180.429 1818.6 181.277 1816.82C182.134 1815.04 183.327 1813.65 184.855 1812.66C186.393 1811.65 188.187 1811.15 190.237 1811.15C191.42 1811.15 192.588 1811.34 193.742 1811.74C194.895 1812.13 195.945 1812.77 196.891 1813.66C197.838 1814.54 198.591 1815.7 199.153 1817.15C199.715 1818.6 199.996 1820.38 199.996 1822.5V1823.98H182.489V1820.96H196.448C196.448 1819.68 196.191 1818.54 195.679 1817.53C195.176 1816.53 194.456 1815.73 193.52 1815.15C192.593 1814.57 191.499 1814.28 190.237 1814.28C188.847 1814.28 187.645 1814.62 186.629 1815.31C185.624 1816 184.85 1816.88 184.308 1817.98C183.766 1819.07 183.495 1820.24 183.495 1821.49V1823.5C183.495 1825.22 183.79 1826.67 184.382 1827.86C184.983 1829.05 185.816 1829.95 186.881 1830.57C187.945 1831.18 189.182 1831.49 190.592 1831.49C191.509 1831.49 192.337 1831.36 193.076 1831.1C193.825 1830.84 194.471 1830.44 195.013 1829.92C195.555 1829.39 195.974 1828.73 196.27 1827.94L199.641 1828.88C199.287 1830.03 198.69 1831.03 197.852 1831.9C197.014 1832.76 195.979 1833.43 194.747 1833.91C193.515 1834.38 192.13 1834.62 190.592 1834.62ZM206.296 1803.87V1834.15H202.806V1803.87H206.296ZM220.303 1811.44V1814.4H208.06V1811.44H220.303ZM211.727 1834.15V1808.31C211.727 1807.01 212.032 1805.92 212.643 1805.06C213.255 1804.19 214.048 1803.54 215.024 1803.1C216 1802.67 217.03 1802.45 218.114 1802.45C218.972 1802.45 219.672 1802.52 220.214 1802.66C220.756 1802.8 221.161 1802.93 221.427 1803.04L220.421 1806.06C220.244 1806 219.997 1805.93 219.682 1805.84C219.376 1805.75 218.972 1805.71 218.469 1805.71C217.316 1805.71 216.483 1806 215.97 1806.58C215.468 1807.16 215.216 1808.01 215.216 1809.14V1834.15H211.727ZM241.134 1834.62C239.084 1834.62 237.285 1834.13 235.737 1833.16C234.2 1832.18 232.997 1830.82 232.129 1829.06C231.272 1827.31 230.843 1825.26 230.843 1822.91C230.843 1820.55 231.272 1818.48 232.129 1816.72C232.997 1814.96 234.2 1813.59 235.737 1812.61C237.285 1811.64 239.084 1811.15 241.134 1811.15C243.185 1811.15 244.979 1811.64 246.517 1812.61C248.064 1813.59 249.267 1814.96 250.125 1816.72C250.992 1818.48 251.426 1820.55 251.426 1822.91C251.426 1825.26 250.992 1827.31 250.125 1829.06C249.267 1830.82 248.064 1832.18 246.517 1833.16C244.979 1834.13 243.185 1834.62 241.134 1834.62ZM241.134 1831.49C242.692 1831.49 243.973 1831.09 244.979 1830.29C245.984 1829.49 246.729 1828.44 247.212 1827.14C247.695 1825.84 247.936 1824.43 247.936 1822.91C247.936 1821.39 247.695 1819.98 247.212 1818.67C246.729 1817.36 245.984 1816.3 244.979 1815.49C243.973 1814.68 242.692 1814.28 241.134 1814.28C239.577 1814.28 238.295 1814.68 237.29 1815.49C236.285 1816.3 235.54 1817.36 235.057 1818.67C234.574 1819.98 234.333 1821.39 234.333 1822.91C234.333 1824.43 234.574 1825.84 235.057 1827.14C235.54 1828.44 236.285 1829.49 237.29 1830.29C238.295 1831.09 239.577 1831.49 241.134 1831.49ZM257.744 1820.49V1834.15H254.254V1811.44H257.626V1814.99H257.921C258.454 1813.84 259.262 1812.91 260.345 1812.21C261.43 1811.5 262.83 1811.15 264.545 1811.15C266.083 1811.15 267.429 1811.46 268.582 1812.09C269.735 1812.71 270.632 1813.66 271.273 1814.93C271.915 1816.19 272.236 1817.79 272.236 1819.72V1834.15H268.746V1819.96C268.746 1818.17 268.282 1816.78 267.356 1815.79C266.43 1814.78 265.157 1814.28 263.541 1814.28C262.427 1814.28 261.43 1814.52 260.553 1815C259.686 1815.49 259.001 1816.19 258.498 1817.12C257.995 1818.05 257.744 1819.17 257.744 1820.49ZM285.635 1834.62C283.447 1834.62 281.56 1834.14 279.972 1833.17C278.395 1832.2 277.177 1830.84 276.319 1829.09C275.473 1827.34 275.049 1825.3 275.049 1822.97C275.049 1820.65 275.473 1818.6 276.319 1816.82C277.177 1815.04 278.369 1813.65 279.897 1812.66C281.435 1811.65 283.231 1811.15 285.281 1811.15C286.463 1811.15 287.631 1811.34 288.784 1811.74C289.937 1812.13 290.988 1812.77 291.933 1813.66C292.88 1814.54 293.635 1815.7 294.197 1817.15C294.759 1818.6 295.04 1820.38 295.04 1822.5V1823.98H277.531V1820.96H291.491C291.491 1819.68 291.236 1818.54 290.723 1817.53C290.218 1816.53 289.5 1815.73 288.563 1815.15C287.637 1814.57 286.541 1814.28 285.281 1814.28C283.892 1814.28 282.687 1814.62 281.672 1815.31C280.667 1816 279.894 1816.88 279.351 1817.98C278.809 1819.07 278.539 1820.24 278.539 1821.49V1823.5C278.539 1825.22 278.833 1826.67 279.426 1827.86C280.027 1829.05 280.86 1829.95 281.924 1830.57C282.989 1831.18 284.225 1831.49 285.635 1831.49C286.551 1831.49 287.379 1831.36 288.121 1831.1C288.868 1830.84 289.516 1830.44 290.057 1829.92C290.598 1829.39 291.017 1828.73 291.314 1827.94L294.684 1828.88C294.33 1830.03 293.734 1831.03 292.896 1831.9C292.058 1832.76 291.022 1833.43 289.791 1833.91C288.558 1834.38 287.173 1834.62 285.635 1834.62ZM316.586 1834.62C314.397 1834.62 312.508 1834.14 310.92 1833.17C309.343 1832.2 308.128 1830.84 307.269 1829.09C306.421 1827.34 305.997 1825.3 305.997 1822.97C305.997 1820.65 306.421 1818.6 307.269 1816.82C308.128 1815.04 309.32 1813.65 310.847 1812.66C312.386 1811.65 314.179 1811.15 316.229 1811.15C317.413 1811.15 318.582 1811.34 319.735 1811.74C320.888 1812.13 321.936 1812.77 322.884 1813.66C323.831 1814.54 324.583 1815.7 325.145 1817.15C325.707 1818.6 325.988 1820.38 325.988 1822.5V1823.98H308.482V1820.96H322.441C322.441 1819.68 322.184 1818.54 321.671 1817.53C321.169 1816.53 320.448 1815.73 319.514 1815.15C318.584 1814.57 317.491 1814.28 316.229 1814.28C314.84 1814.28 313.637 1814.62 312.622 1815.31C311.615 1816 310.842 1816.88 310.301 1817.98C309.757 1819.07 309.486 1820.24 309.486 1821.49V1823.5C309.486 1825.22 309.783 1826.67 310.374 1827.86C310.975 1829.05 311.808 1829.95 312.872 1830.57C313.937 1831.18 315.175 1831.49 316.586 1831.49C317.502 1831.49 318.329 1831.36 319.069 1831.1C319.818 1830.84 320.463 1830.44 321.005 1829.92C321.549 1829.39 321.968 1828.73 322.262 1827.94L325.634 1828.88C325.278 1830.03 324.682 1831.03 323.844 1831.9C323.006 1832.76 321.973 1833.43 320.739 1833.91C319.508 1834.38 318.121 1834.62 316.586 1834.62ZM328.799 1842.66V1811.44H332.169V1815.05H332.583C332.84 1814.66 333.194 1814.15 333.65 1813.54C334.113 1812.92 334.771 1812.37 335.63 1811.89C336.497 1811.39 337.671 1811.15 339.149 1811.15C341.061 1811.15 342.748 1811.62 344.205 1812.58C345.665 1813.54 346.802 1814.89 347.622 1816.65C348.439 1818.4 348.848 1820.47 348.848 1822.85C348.848 1825.26 348.439 1827.34 347.622 1829.11C346.802 1830.86 345.67 1832.22 344.221 1833.19C342.771 1834.14 341.1 1834.62 339.209 1834.62C337.749 1834.62 336.58 1834.38 335.703 1833.89C334.826 1833.4 334.152 1832.85 333.678 1832.22C333.205 1831.59 332.84 1831.07 332.583 1830.66H332.289V1842.66H328.799ZM332.229 1822.79C332.229 1824.51 332.481 1826.02 332.984 1827.33C333.486 1828.63 334.22 1829.65 335.185 1830.39C336.153 1831.12 337.335 1831.49 338.735 1831.49C340.195 1831.49 341.413 1831.1 342.389 1830.33C343.372 1829.56 344.112 1828.51 344.606 1827.2C345.108 1825.88 345.361 1824.41 345.361 1822.79C345.361 1821.2 345.113 1819.76 344.619 1818.48C344.138 1817.19 343.404 1816.17 342.417 1815.42C341.441 1814.66 340.213 1814.28 338.735 1814.28C337.317 1814.28 336.122 1814.64 335.157 1815.36C334.191 1816.07 333.462 1817.06 332.968 1818.34C332.476 1819.62 332.229 1821.1 332.229 1822.79ZM351.674 1834.15V1811.44H355.164V1834.15H351.674ZM353.449 1807.66C352.767 1807.66 352.182 1807.43 351.69 1806.96C351.206 1806.5 350.964 1805.94 350.964 1805.29C350.964 1804.64 351.206 1804.09 351.69 1803.62C352.182 1803.16 352.767 1802.93 353.449 1802.93C354.128 1802.93 354.711 1803.16 355.193 1803.62C355.687 1804.09 355.932 1804.64 355.932 1805.29C355.932 1805.94 355.687 1806.5 355.193 1806.96C354.711 1807.43 354.128 1807.66 353.449 1807.66ZM375.145 1816.53L372.009 1817.41C371.812 1816.89 371.523 1816.38 371.138 1815.89C370.763 1815.39 370.25 1814.98 369.599 1814.65C368.949 1814.33 368.116 1814.16 367.101 1814.16C365.711 1814.16 364.553 1814.48 363.624 1815.12C362.708 1815.75 362.25 1816.56 362.25 1817.53C362.25 1818.4 362.565 1819.08 363.198 1819.59C363.827 1820.09 364.814 1820.51 366.154 1820.84L369.527 1821.67C371.557 1822.16 373.069 1822.92 374.065 1823.93C375.059 1824.94 375.559 1826.23 375.559 1827.82C375.559 1829.12 375.184 1830.28 374.435 1831.31C373.696 1832.33 372.66 1833.14 371.33 1833.73C369.998 1834.32 368.452 1834.62 366.687 1834.62C364.369 1834.62 362.453 1834.12 360.933 1833.11C359.416 1832.11 358.456 1830.64 358.05 1828.71L361.363 1827.88C361.678 1829.1 362.276 1830.02 363.153 1830.63C364.041 1831.24 365.199 1831.55 366.628 1831.55C368.254 1831.55 369.545 1831.2 370.503 1830.51C371.468 1829.81 371.949 1828.97 371.949 1828C371.949 1827.21 371.674 1826.55 371.122 1826.02C370.57 1825.48 369.722 1825.07 368.579 1824.8L364.793 1823.92C362.714 1823.43 361.186 1822.66 360.21 1821.63C359.245 1820.58 358.76 1819.28 358.76 1817.71C358.76 1816.43 359.12 1815.3 359.84 1814.31C360.569 1813.32 361.561 1812.55 362.812 1811.99C364.075 1811.43 365.503 1811.15 367.101 1811.15C369.347 1811.15 371.111 1811.64 372.394 1812.62C373.685 1813.61 374.601 1814.91 375.145 1816.53ZM387.541 1834.62C385.49 1834.62 383.689 1834.13 382.143 1833.16C380.605 1832.18 379.403 1830.82 378.534 1829.06C377.677 1827.31 377.248 1825.26 377.248 1822.91C377.248 1820.55 377.677 1818.48 378.534 1816.72C379.403 1814.96 380.605 1813.59 382.143 1812.61C383.689 1811.64 385.49 1811.15 387.541 1811.15C389.591 1811.15 391.384 1811.64 392.922 1812.61C394.471 1813.59 395.673 1814.96 396.529 1816.72C397.398 1818.48 397.83 1820.55 397.83 1822.91C397.83 1825.26 397.398 1827.31 396.529 1829.06C395.673 1830.82 394.471 1832.18 392.922 1833.16C391.384 1834.13 389.591 1834.62 387.541 1834.62ZM387.541 1831.49C389.097 1831.49 390.38 1831.09 391.384 1830.29C392.389 1829.49 393.133 1828.44 393.617 1827.14C394.099 1825.84 394.341 1824.43 394.341 1822.91C394.341 1821.39 394.099 1819.98 393.617 1818.67C393.133 1817.36 392.389 1816.3 391.384 1815.49C390.38 1814.68 389.097 1814.28 387.541 1814.28C385.982 1814.28 384.701 1814.68 383.694 1815.49C382.69 1816.3 381.945 1817.36 381.461 1818.67C380.98 1819.98 380.738 1821.39 380.738 1822.91C380.738 1824.43 380.98 1825.84 381.461 1827.14C381.945 1828.44 382.69 1829.49 383.694 1830.29C384.701 1831.09 385.982 1831.49 387.541 1831.49ZM409.234 1834.62C407.342 1834.62 405.672 1834.14 404.222 1833.19C402.775 1832.22 401.64 1830.86 400.823 1829.11C400.003 1827.34 399.595 1825.26 399.595 1822.85C399.595 1820.47 400.003 1818.4 400.823 1816.65C401.64 1814.89 402.778 1813.54 404.238 1812.58C405.698 1811.62 407.381 1811.15 409.294 1811.15C410.772 1811.15 411.941 1811.39 412.8 1811.89C413.666 1812.37 414.327 1812.92 414.78 1813.54C415.243 1814.15 415.602 1814.66 415.86 1815.05H416.157V1803.87H419.644V1834.15H416.274V1830.66H415.86C415.602 1831.07 415.238 1831.59 414.764 1832.22C414.293 1832.85 413.617 1833.4 412.74 1833.89C411.863 1834.38 410.694 1834.62 409.234 1834.62ZM409.708 1831.49C411.108 1831.49 412.292 1831.12 413.258 1830.39C414.223 1829.65 414.957 1828.63 415.462 1827.33C415.964 1826.02 416.214 1824.51 416.214 1822.79C416.214 1821.1 415.969 1819.62 415.475 1818.34C414.983 1817.06 414.254 1816.07 413.286 1815.36C412.321 1814.64 411.129 1814.28 409.708 1814.28C408.23 1814.28 406.999 1814.66 406.012 1815.42C405.037 1816.17 404.303 1817.19 403.808 1818.48C403.327 1819.76 403.085 1821.2 403.085 1822.79C403.085 1824.41 403.332 1825.88 403.824 1827.2C404.326 1828.51 405.065 1829.56 406.041 1830.33C407.027 1831.1 408.251 1831.49 409.708 1831.49ZM433.546 1834.62C431.357 1834.62 429.468 1834.14 427.883 1833.17C426.303 1832.2 425.088 1830.84 424.229 1829.09C423.381 1827.34 422.957 1825.3 422.957 1822.97C422.957 1820.65 423.381 1818.6 424.229 1816.82C425.088 1815.04 426.28 1813.65 427.808 1812.66C429.346 1811.65 431.139 1811.15 433.189 1811.15C434.374 1811.15 435.542 1811.34 436.695 1811.74C437.848 1812.13 438.897 1812.77 439.844 1813.66C440.791 1814.54 441.546 1815.7 442.105 1817.15C442.667 1818.6 442.948 1820.38 442.948 1822.5V1823.98H425.442V1820.96H439.401C439.401 1819.68 439.144 1818.54 438.631 1817.53C438.129 1816.53 437.408 1815.73 436.474 1815.15C435.547 1814.57 434.452 1814.28 433.189 1814.28C431.8 1814.28 430.597 1814.62 429.582 1815.31C428.578 1816 427.802 1816.88 427.261 1817.98C426.72 1819.07 426.447 1820.24 426.447 1821.49V1823.5C426.447 1825.22 426.743 1826.67 427.334 1827.86C427.935 1829.05 428.768 1829.95 429.835 1830.57C430.899 1831.18 432.135 1831.49 433.546 1831.49C434.462 1831.49 435.29 1831.36 436.029 1831.1C436.778 1830.84 437.424 1830.44 437.967 1829.92C438.509 1829.39 438.928 1828.73 439.222 1827.94L442.595 1828.88C442.241 1830.03 441.642 1831.03 440.804 1831.9C439.966 1832.76 438.933 1833.43 437.699 1833.91C436.468 1834.38 435.084 1834.62 433.546 1834.62Z" - fill="#E6E6E6" - /> - <path - d="M42.9097 1883.58L47.2568 1857.09H50.362L46.0149 1883.58H42.9097ZM30.916 1876.49L31.4206 1873.39H51.5005L50.9959 1876.49H30.916ZM33.5942 1883.58L37.9415 1857.09H41.0467L36.6993 1883.58H33.5942ZM32.4556 1867.28L32.9602 1864.18H53.0401L52.5358 1867.28H32.4556ZM66.1656 1883.94C64.3112 1883.94 62.6724 1883.62 61.2492 1882.98C59.8348 1882.35 58.7264 1881.47 57.9241 1880.35C57.122 1879.23 56.7251 1877.95 56.7337 1876.52C56.7251 1875.4 56.9539 1874.37 57.4195 1873.43C57.8939 1872.48 58.5364 1871.69 59.3473 1871.06C60.1582 1870.42 61.0639 1870.02 62.0642 1869.84V1869.69C60.7446 1869.37 59.688 1868.66 58.8945 1867.57C58.101 1866.47 57.7086 1865.21 57.7172 1863.79C57.7086 1862.44 58.0664 1861.23 58.7909 1860.17C59.524 1859.1 60.5291 1858.26 61.8056 1857.65C63.082 1857.04 64.5355 1856.73 66.1656 1856.73C67.7786 1856.73 69.219 1857.04 70.4869 1857.66C71.7637 1858.27 72.7685 1859.11 73.5016 1860.18C74.2347 1861.24 74.6058 1862.44 74.6144 1863.79C74.6058 1865.21 74.2003 1866.47 73.398 1867.57C72.5959 1868.66 71.5524 1869.37 70.267 1869.69V1869.84C71.2591 1870.02 72.1517 1870.42 72.9452 1871.06C73.7475 1871.69 74.3856 1872.48 74.86 1873.43C75.343 1874.37 75.589 1875.4 75.5975 1876.52C75.589 1877.95 75.1835 1879.23 74.3814 1880.35C73.5791 1881.47 72.4666 1882.35 71.0433 1882.98C69.6289 1883.62 68.0029 1883.94 66.1656 1883.94ZM66.1656 1880.67C67.2613 1880.67 68.2098 1880.49 69.0122 1880.13C69.8142 1879.76 70.4354 1879.24 70.8752 1878.59C71.315 1877.92 71.5394 1877.15 71.5479 1876.26C71.5394 1875.34 71.2978 1874.52 70.8234 1873.81C70.3576 1873.11 69.7237 1872.55 68.9216 1872.15C68.1193 1871.74 67.2009 1871.54 66.1656 1871.54C65.1221 1871.54 64.1948 1871.74 63.3839 1872.15C62.5733 1872.55 61.9349 1873.11 61.4691 1873.81C61.0035 1874.52 60.7747 1875.34 60.7833 1876.26C60.7747 1877.15 60.9861 1877.92 61.4175 1878.59C61.8574 1879.24 62.4827 1879.76 63.2934 1880.13C64.1043 1880.49 65.0617 1880.67 66.1656 1880.67ZM66.1656 1868.34C67.0455 1868.34 67.826 1868.17 68.5075 1867.81C69.1889 1867.46 69.7237 1866.97 70.1119 1866.34C70.5085 1865.71 70.7113 1864.97 70.7199 1864.13C70.7113 1863.3 70.513 1862.57 70.1247 1861.96C69.7453 1861.33 69.2149 1860.86 68.5333 1860.52C67.852 1860.17 67.0627 1860 66.1656 1860C65.2514 1860 64.4493 1860.17 63.7592 1860.52C63.0779 1860.86 62.5472 1861.33 62.1678 1861.96C61.7884 1862.57 61.6028 1863.3 61.6114 1864.13C61.6028 1864.97 61.7926 1865.71 62.1808 1866.34C62.5689 1866.97 63.1036 1867.46 63.7852 1867.81C64.4751 1868.17 65.2686 1868.34 66.1656 1868.34ZM85.246 1871.78V1883.58H81.3775V1857.09H85.1942V1866.95H85.4401C85.9057 1865.88 86.6174 1865.03 87.5749 1864.4C88.5323 1863.77 89.783 1863.45 91.3268 1863.45C92.6896 1863.45 93.88 1863.74 94.8978 1864.29C95.9242 1864.86 96.7177 1865.69 97.2785 1866.81C97.8476 1867.91 98.1323 1869.29 98.1323 1870.94C98.1323 1872.6 98.1323 1883.58 98.1323 1883.58H94.2638V1871.41C94.2638 1869.95 93.8886 1868.82 93.1383 1868.02C92.3878 1867.21 91.3442 1866.81 90.0071 1866.81C89.0929 1866.81 88.2734 1867 87.5488 1867.39C86.8329 1867.78 86.2679 1868.34 85.8539 1869.09C85.4487 1869.84 85.246 1870.73 85.246 1871.78ZM112.759 1883.98C110.896 1883.98 109.27 1883.55 107.881 1882.7C106.492 1881.85 105.414 1880.65 104.646 1879.12C103.879 1877.58 103.495 1875.79 103.495 1873.74C103.495 1871.68 103.879 1869.87 104.646 1868.33C105.414 1866.79 106.492 1865.59 107.881 1864.73C109.27 1863.88 110.896 1863.45 112.759 1863.45C114.622 1863.45 116.247 1863.88 117.636 1864.73C119.025 1865.59 120.103 1866.79 120.871 1868.33C121.638 1869.87 122.022 1871.68 122.022 1873.74C122.022 1875.79 121.638 1877.58 120.871 1879.12C120.103 1880.65 119.025 1881.85 117.636 1882.7C116.247 1883.55 114.622 1883.98 112.759 1883.98ZM112.771 1880.73C113.979 1880.73 114.98 1880.42 115.773 1879.78C116.567 1879.14 117.153 1878.29 117.533 1877.23C117.921 1876.17 118.115 1875 118.115 1873.73C118.115 1872.46 117.921 1871.29 117.533 1870.23C117.153 1869.16 116.567 1868.31 115.773 1867.66C114.98 1867.01 113.979 1866.69 112.771 1866.69C111.555 1866.69 110.546 1867.01 109.744 1867.66C108.951 1868.31 108.36 1869.16 107.972 1870.23C107.592 1871.29 107.402 1872.46 107.402 1873.73C107.402 1875 107.592 1876.17 107.972 1877.23C108.36 1878.29 108.951 1879.14 109.744 1879.78C110.546 1880.42 111.555 1880.73 112.771 1880.73ZM140.022 1875.34V1863.71H143.903V1883.58H140.1V1880.14H139.893C139.436 1881.2 138.703 1882.08 137.693 1882.79C136.693 1883.49 135.446 1883.84 133.954 1883.84C132.677 1883.84 131.548 1883.56 130.564 1883C129.59 1882.43 128.822 1881.59 128.261 1880.48C127.709 1879.36 127.433 1877.99 127.433 1876.35V1863.71H131.302V1875.88C131.302 1877.24 131.677 1878.32 132.427 1879.12C133.178 1879.92 134.153 1880.32 135.351 1880.32C136.076 1880.32 136.796 1880.14 137.512 1879.78C138.237 1879.42 138.836 1878.87 139.31 1878.14C139.793 1877.4 140.031 1876.47 140.022 1875.34ZM150.195 1883.58V1863.71H153.934V1866.87H154.141C154.503 1865.8 155.141 1864.96 156.055 1864.35C156.978 1863.73 158.022 1863.42 159.186 1863.42C159.428 1863.42 159.713 1863.43 160.04 1863.44C160.377 1863.46 160.64 1863.48 160.83 1863.51V1867.21C160.674 1867.16 160.398 1867.11 160.001 1867.06C159.605 1867 159.208 1866.97 158.811 1866.97C157.897 1866.97 157.082 1867.17 156.366 1867.55C155.659 1867.93 155.098 1868.46 154.684 1869.15C154.27 1869.82 154.063 1870.59 154.063 1871.45V1883.58H150.195ZM180.162 1868.56L176.656 1869.19C176.509 1868.74 176.276 1868.31 175.957 1867.9C175.647 1867.5 175.224 1867.17 174.689 1866.91C174.154 1866.65 173.486 1866.52 172.684 1866.52C171.588 1866.52 170.674 1866.77 169.941 1867.26C169.208 1867.74 168.841 1868.37 168.841 1869.13C168.841 1869.8 169.087 1870.33 169.579 1870.74C170.07 1871.14 170.864 1871.47 171.959 1871.73L175.116 1872.46C176.945 1872.88 178.308 1873.53 179.205 1874.41C180.102 1875.29 180.55 1876.43 180.55 1877.84C180.55 1879.03 180.205 1880.09 179.515 1881.02C178.834 1881.94 177.881 1882.67 176.656 1883.19C175.44 1883.72 174.029 1883.98 172.425 1883.98C170.2 1883.98 168.384 1883.51 166.978 1882.56C165.572 1881.6 164.71 1880.24 164.39 1878.48L168.13 1877.91C168.362 1878.89 168.841 1879.63 169.566 1880.13C170.29 1880.62 171.235 1880.86 172.399 1880.86C173.667 1880.86 174.681 1880.6 175.44 1880.08C176.199 1879.54 176.578 1878.89 176.578 1878.12C176.578 1877.5 176.345 1876.98 175.88 1876.56C175.423 1876.13 174.719 1875.81 173.771 1875.6L170.407 1874.86C168.552 1874.44 167.181 1873.77 166.293 1872.85C165.413 1871.92 164.973 1870.75 164.973 1869.34C164.973 1868.17 165.3 1867.14 165.956 1866.26C166.612 1865.38 167.517 1864.7 168.673 1864.21C169.829 1863.7 171.153 1863.45 172.645 1863.45C174.793 1863.45 176.483 1863.92 177.717 1864.85C178.95 1865.77 179.765 1867.01 180.162 1868.56ZM189.765 1857.09V1883.58H185.897V1857.09H189.765ZM201.846 1884.02C200.587 1884.02 199.448 1883.79 198.43 1883.32C197.412 1882.85 196.606 1882.16 196.011 1881.26C195.424 1880.37 195.131 1879.27 195.131 1877.97C195.131 1876.84 195.347 1875.92 195.778 1875.2C196.209 1874.47 196.791 1873.9 197.525 1873.48C198.258 1873.06 199.077 1872.74 199.983 1872.52C200.888 1872.3 201.811 1872.14 202.751 1872.03C203.942 1871.89 204.908 1871.78 205.65 1871.69C206.391 1871.6 206.93 1871.45 207.267 1871.24C207.603 1871.04 207.772 1870.7 207.772 1870.23V1870.14C207.772 1869.01 207.452 1868.14 206.814 1867.52C206.184 1866.9 205.244 1866.58 203.994 1866.58C202.691 1866.58 201.665 1866.87 200.914 1867.45C200.172 1868.02 199.659 1868.65 199.375 1869.35L195.739 1868.52C196.17 1867.32 196.8 1866.34 197.628 1865.6C198.465 1864.85 199.426 1864.31 200.513 1863.97C201.6 1863.63 202.743 1863.45 203.942 1863.45C204.735 1863.45 205.576 1863.55 206.465 1863.74C207.362 1863.92 208.199 1864.26 208.975 1864.75C209.76 1865.24 210.402 1865.94 210.902 1866.86C211.403 1867.76 211.653 1868.94 211.653 1870.39V1883.58H207.875V1880.86H207.72C207.47 1881.36 207.094 1881.86 206.594 1882.34C206.094 1882.82 205.451 1883.22 204.666 1883.54C203.881 1883.86 202.941 1884.02 201.846 1884.02ZM202.687 1880.92C203.756 1880.92 204.671 1880.71 205.43 1880.28C206.197 1879.86 206.779 1879.31 207.176 1878.63C207.582 1877.94 207.785 1877.2 207.785 1876.42V1873.85C207.646 1873.99 207.379 1874.12 206.982 1874.24C206.594 1874.36 206.15 1874.45 205.65 1874.54C205.149 1874.62 204.662 1874.69 204.188 1874.76C203.713 1874.82 203.316 1874.87 202.997 1874.91C202.247 1875.01 201.561 1875.17 200.94 1875.39C200.328 1875.62 199.836 1875.94 199.465 1876.36C199.103 1876.78 198.922 1877.33 198.922 1878.02C198.922 1878.98 199.275 1879.7 199.983 1880.19C200.69 1880.67 201.591 1880.92 202.687 1880.92ZM226.464 1883.98C224.506 1883.98 222.819 1883.56 221.405 1882.73C219.999 1881.88 218.912 1880.7 218.144 1879.17C217.385 1877.64 217.006 1875.84 217.006 1873.78C217.006 1871.74 217.385 1869.95 218.144 1868.39C218.912 1866.84 219.982 1865.63 221.353 1864.76C222.733 1863.89 224.346 1863.45 226.192 1863.45C227.313 1863.45 228.4 1863.64 229.452 1864.01C230.505 1864.38 231.449 1864.96 232.286 1865.76C233.122 1866.55 233.782 1867.58 234.265 1868.85C234.748 1870.11 234.99 1871.64 234.99 1873.44V1874.81H219.192V1871.91H231.199C231.199 1870.9 230.992 1869.99 230.578 1869.21C230.164 1868.42 229.582 1867.79 228.831 1867.34C228.09 1866.88 227.218 1866.65 226.218 1866.65C225.131 1866.65 224.182 1866.92 223.371 1867.45C222.569 1867.98 221.948 1868.67 221.508 1869.52C221.077 1870.37 220.861 1871.29 220.861 1872.28V1874.54C220.861 1875.87 221.094 1877 221.56 1877.93C222.035 1878.86 222.694 1879.57 223.54 1880.06C224.385 1880.54 225.372 1880.79 226.502 1880.79C227.235 1880.79 227.904 1880.68 228.508 1880.48C229.112 1880.26 229.633 1879.94 230.073 1879.52C230.513 1879.1 230.85 1878.57 231.083 1877.95L234.744 1878.61C234.451 1879.69 233.924 1880.64 233.166 1881.45C232.415 1882.25 231.471 1882.87 230.332 1883.32C229.202 1883.76 227.913 1883.98 226.464 1883.98ZM240.375 1883.58V1863.71H244.114V1866.87H244.321C244.683 1865.8 245.322 1864.96 246.236 1864.35C247.159 1863.73 248.203 1863.42 249.367 1863.42C249.609 1863.42 249.893 1863.43 250.221 1863.44C250.557 1863.46 250.82 1863.48 251.01 1863.51V1867.21C250.855 1867.16 250.579 1867.11 250.182 1867.06C249.785 1867 249.388 1866.97 248.992 1866.97C248.078 1866.97 247.262 1867.17 246.546 1867.55C245.839 1867.93 245.278 1868.46 244.864 1869.15C244.451 1869.82 244.243 1870.59 244.243 1871.45V1883.58H240.375ZM276.123 1883.58L280.469 1857.09H283.574L279.228 1883.58H276.123ZM264.129 1876.49L264.634 1873.39H284.714L284.209 1876.49H264.129ZM266.807 1883.58L271.155 1857.09H274.26L269.911 1883.58H266.807ZM265.669 1867.28L266.174 1864.18H286.252L285.75 1867.28H265.669ZM294.345 1871.78V1883.58H290.478V1863.71H294.189V1866.95H294.436C294.894 1865.89 295.61 1865.05 296.583 1864.41C297.567 1863.77 298.806 1863.45 300.297 1863.45C301.65 1863.45 302.837 1863.74 303.855 1864.31C304.872 1864.87 305.663 1865.7 306.223 1866.82C306.782 1867.93 307.063 1869.31 307.063 1870.94V1883.58H303.196V1871.41C303.196 1869.97 302.819 1868.84 302.069 1868.03C301.32 1867.21 300.289 1866.81 298.978 1866.81C298.08 1866.81 297.283 1867 296.583 1867.39C295.894 1867.78 295.347 1868.34 294.941 1869.09C294.543 1869.84 294.345 1870.73 294.345 1871.78ZM321.91 1883.98C319.953 1883.98 318.267 1883.56 316.851 1882.73C315.446 1881.88 314.358 1880.7 313.59 1879.17C312.833 1877.64 312.453 1875.84 312.453 1873.78C312.453 1871.74 312.833 1869.95 313.59 1868.39C314.358 1866.84 315.427 1865.63 316.799 1864.76C318.178 1863.89 319.792 1863.45 321.639 1863.45C322.758 1863.45 323.846 1863.64 324.898 1864.01C325.952 1864.38 326.896 1864.96 327.732 1865.76C328.57 1866.55 329.228 1867.58 329.712 1868.85C330.194 1870.11 330.436 1871.64 330.436 1873.44V1874.81H314.639V1871.91H326.646C326.646 1870.9 326.438 1869.99 326.024 1869.21C325.611 1868.42 325.028 1867.79 324.278 1867.34C323.537 1866.88 322.665 1866.65 321.665 1866.65C320.578 1866.65 319.628 1866.92 318.818 1867.45C318.017 1867.98 317.395 1868.67 316.955 1869.52C316.523 1870.37 316.307 1871.29 316.307 1872.28V1874.54C316.307 1875.87 316.541 1877 317.007 1877.93C317.481 1878.86 318.142 1879.57 318.985 1880.06C319.831 1880.54 320.82 1880.79 321.949 1880.79C322.683 1880.79 323.349 1880.68 323.953 1880.48C324.557 1880.26 325.08 1879.94 325.52 1879.52C325.959 1879.1 326.295 1878.57 326.529 1877.95L330.191 1878.61C329.897 1879.69 329.371 1880.64 328.611 1881.45C327.862 1882.25 326.917 1882.87 325.777 1883.32C324.648 1883.76 323.36 1883.98 321.91 1883.98ZM345.097 1863.71V1866.82H334.243V1863.71H345.097ZM337.155 1858.95H341.022V1877.75C341.022 1878.5 341.134 1879.06 341.358 1879.44C341.584 1879.81 341.873 1880.07 342.227 1880.2C342.589 1880.33 342.982 1880.4 343.403 1880.4C343.713 1880.4 343.986 1880.38 344.218 1880.33C344.452 1880.29 344.632 1880.26 344.762 1880.23L345.459 1883.42C345.235 1883.51 344.918 1883.6 344.504 1883.68C344.088 1883.78 343.572 1883.83 342.951 1883.84C341.933 1883.86 340.983 1883.67 340.103 1883.3C339.224 1882.92 338.513 1882.33 337.969 1881.54C337.426 1880.74 337.155 1879.75 337.155 1878.55V1858.95ZM359.929 1863.71V1866.82H348.697V1863.71H359.929ZM351.778 1883.58V1861.41C351.778 1860.17 352.049 1859.14 352.592 1858.32C353.136 1857.49 353.855 1856.87 354.752 1856.47C355.65 1856.06 356.624 1855.85 357.678 1855.85C358.453 1855.85 359.117 1855.91 359.668 1856.04C360.22 1856.16 360.631 1856.28 360.899 1856.38L359.994 1859.51C359.812 1859.46 359.58 1859.4 359.294 1859.33C359.01 1859.25 358.664 1859.21 358.258 1859.21C357.318 1859.21 356.647 1859.44 356.241 1859.91C355.843 1860.38 355.645 1861.05 355.645 1861.93V1883.58H351.778ZM368.881 1857.09V1883.58H365.014V1857.09H368.881ZM375.179 1883.58V1863.71H379.046V1883.58H375.179ZM377.133 1860.65C376.459 1860.65 375.881 1860.42 375.397 1859.98C374.924 1859.52 374.687 1858.97 374.687 1858.34C374.687 1857.71 374.924 1857.16 375.397 1856.72C375.881 1856.26 376.459 1856.03 377.133 1856.03C377.805 1856.03 378.377 1856.26 378.853 1856.72C379.335 1857.16 379.577 1857.71 379.577 1858.34C379.577 1858.97 379.335 1859.52 378.853 1859.98C378.377 1860.42 377.805 1860.65 377.133 1860.65ZM388.396 1863.71L392.784 1871.45L397.208 1863.71H401.44L395.241 1873.65L401.492 1883.58H397.26L392.784 1876.16L388.321 1883.58H384.076L390.26 1873.65L384.154 1863.71H388.396ZM427.175 1883.58L431.523 1857.09H434.628L430.282 1883.58H427.175ZM415.183 1876.49L415.688 1873.39H435.768L435.263 1876.49H415.183ZM417.861 1883.58L422.207 1857.09H425.311L420.965 1883.58H417.861ZM416.721 1867.28L417.226 1864.18H437.306L436.801 1867.28H416.721ZM445.4 1857.09V1883.58H441.53V1857.09H445.4ZM451.697 1883.58V1863.71H455.564V1883.58H451.697ZM453.649 1860.65C452.978 1860.65 452.4 1860.42 451.916 1859.98C451.442 1859.52 451.205 1858.97 451.205 1858.34C451.205 1857.71 451.442 1857.16 451.916 1856.72C452.4 1856.26 452.978 1856.03 453.649 1856.03C454.323 1856.03 454.896 1856.26 455.372 1856.72C455.853 1857.16 456.095 1857.71 456.095 1858.34C456.095 1858.97 455.853 1859.52 455.372 1859.98C454.896 1860.42 454.323 1860.65 453.649 1860.65ZM470.427 1883.98C468.47 1883.98 466.784 1883.56 465.368 1882.73C463.962 1881.88 462.875 1880.7 462.107 1879.17C461.35 1877.64 460.97 1875.84 460.97 1873.78C460.97 1871.74 461.35 1869.95 462.107 1868.39C462.875 1866.84 463.944 1865.63 465.316 1864.76C466.698 1863.89 468.309 1863.45 470.156 1863.45C471.278 1863.45 472.363 1863.64 473.417 1864.01C474.468 1864.38 475.413 1864.96 476.248 1865.76C477.086 1866.55 477.745 1867.58 478.229 1868.85C478.713 1870.11 478.952 1871.64 478.952 1873.44V1874.81H463.156V1871.91H475.163C475.163 1870.9 474.955 1869.99 474.541 1869.21C474.127 1868.42 473.545 1867.79 472.795 1867.34C472.053 1866.88 471.182 1866.65 470.182 1866.65C469.094 1866.65 468.145 1866.92 467.335 1867.45C466.534 1867.98 465.912 1868.67 465.472 1869.52C465.04 1870.37 464.824 1871.29 464.824 1872.28V1874.54C464.824 1875.87 465.058 1877 465.524 1877.93C465.998 1878.86 466.659 1879.57 467.504 1880.06C468.348 1880.54 469.336 1880.79 470.466 1880.79C471.2 1880.79 471.869 1880.68 472.472 1880.48C473.076 1880.26 473.597 1879.94 474.036 1879.52C474.476 1879.1 474.812 1878.57 475.046 1877.95L478.708 1878.61C478.414 1879.69 477.888 1880.64 477.128 1881.45C476.379 1882.25 475.434 1882.87 474.297 1883.32C473.165 1883.76 471.876 1883.98 470.427 1883.98ZM499.23 1868.56L495.725 1869.19C495.577 1868.74 495.345 1868.31 495.025 1867.9C494.715 1867.5 494.294 1867.17 493.757 1866.91C493.224 1866.65 492.555 1866.52 491.751 1866.52C490.658 1866.52 489.742 1866.77 489.008 1867.26C488.277 1867.74 487.91 1868.37 487.91 1869.13C487.91 1869.8 488.154 1870.33 488.646 1870.74C489.138 1871.14 489.932 1871.47 491.028 1871.73L494.184 1872.46C496.014 1872.88 497.375 1873.53 498.273 1874.41C499.17 1875.29 499.618 1876.43 499.618 1877.84C499.618 1879.03 499.275 1880.09 498.582 1881.02C497.903 1881.94 496.948 1882.67 495.725 1883.19C494.507 1883.72 493.096 1883.98 491.493 1883.98C489.268 1883.98 487.452 1883.51 486.047 1882.56C484.641 1881.6 483.777 1880.24 483.46 1878.48L487.197 1877.91C487.431 1878.89 487.91 1879.63 488.633 1880.13C489.359 1880.62 490.304 1880.86 491.467 1880.86C492.735 1880.86 493.75 1880.6 494.507 1880.08C495.267 1879.54 495.647 1878.89 495.647 1878.12C495.647 1877.5 495.413 1876.98 494.947 1876.56C494.491 1876.13 493.789 1875.81 492.839 1875.6L489.474 1874.86C487.621 1874.44 486.249 1873.77 485.359 1872.85C484.48 1871.92 484.04 1870.75 484.04 1869.34C484.04 1868.17 484.368 1867.14 485.024 1866.26C485.68 1865.38 486.585 1864.7 487.741 1864.21C488.896 1863.7 490.221 1863.45 491.715 1863.45C493.862 1863.45 495.551 1863.92 496.784 1864.85C498.018 1865.77 498.835 1867.01 499.23 1868.56Z" - fill="#BFBFBF" - /> - <path - d="M994.565 1876.06C1023.31 1876.06 1046.61 1852.77 1046.61 1824.03C1046.61 1795.3 1023.31 1772 994.565 1772C965.819 1772 942.517 1795.3 942.517 1824.03C942.517 1852.77 965.819 1876.06 994.565 1876.06Z" - fill="#9D9D9D" - /> - <path - d="M993.253 1599C997.49 1599.08 997.139 1599.29 997.139 1599.29L1006.1 1608.39L1011.97 1614.36C1020.55 1623.53 1022.53 1625.45 1029.01 1633.37C1026.99 1640.66 1022.2 1644.73 1016.89 1649.79L1014.55 1651.94L1010.41 1655.85L1006.99 1659.15L1002.3 1663.47C1002.3 1663.47 998.357 1667.12 995.939 1669.04C995.939 1669.04 993.43 1669.56 992.056 1668.18C991.957 1665.4 991.762 1660.81 991.793 1658.04C991.806 1656.52 991.817 1655 991.83 1653.43C991.845 1652.26 991.861 1651.08 991.879 1649.87C976.944 1649.3 973.915 1650.07 961.179 1658.94C958.909 1658.79 958.084 1656.73 958.084 1656.73C956.677 1644.97 961.9 1637.63 968.499 1627.87C975.089 1620.72 982.393 1618.15 991.879 1616.87C991.861 1615.7 991.845 1614.52 991.83 1613.31C991.817 1611.79 991.806 1610.28 991.793 1608.71C991.778 1607.2 991.76 1605.68 991.744 1604.12C991.879 1600.37 991.879 1600.37 993.253 1599Z" - fill="white" - fillOpacity="0.9" - /> - <g filter="url(#filter0_d_5_2)"> - <path - d="M965.181 1685.31V1708.02H962.43V1688.19H962.298L956.752 1691.87V1689.08L962.43 1685.31H965.181ZM974.318 1704.91L974.141 1706.11C974.016 1706.95 973.824 1707.85 973.566 1708.82C973.314 1709.78 973.051 1710.68 972.778 1711.53C972.504 1712.38 972.278 1713.06 972.101 1713.56H970.105C970.201 1713.09 970.326 1712.46 970.482 1711.68C970.638 1710.91 970.792 1710.04 970.948 1709.08C971.109 1708.13 971.242 1707.15 971.346 1706.15L971.479 1704.91H974.318ZM985.764 1708.33C984.462 1708.33 983.291 1708.07 982.248 1707.55C981.207 1707.03 980.371 1706.32 979.742 1705.42C979.114 1704.52 978.771 1703.49 978.711 1702.34H981.373C981.475 1703.37 981.941 1704.22 982.768 1704.89C983.604 1705.56 984.603 1705.89 985.764 1705.89C986.695 1705.89 987.523 1705.67 988.249 1705.23C988.98 1704.8 989.553 1704.2 989.966 1703.44C990.388 1702.67 990.599 1701.8 990.599 1700.83C990.599 1699.84 990.38 1698.96 989.946 1698.18C989.516 1697.4 988.926 1696.78 988.171 1696.33C987.416 1695.88 986.555 1695.65 985.587 1695.64C984.892 1695.64 984.179 1695.74 983.445 1695.97C982.714 1696.18 982.112 1696.46 981.639 1696.8L979.065 1696.49L980.442 1685.31H992.241V1687.75H982.747L981.948 1694.45H982.081C982.547 1694.08 983.133 1693.77 983.835 1693.53C984.535 1693.28 985.269 1693.16 986.029 1693.16C987.419 1693.16 988.657 1693.49 989.745 1694.16C990.838 1694.82 991.697 1695.72 992.319 1696.86C992.946 1698.01 993.261 1699.32 993.261 1700.79C993.261 1702.24 992.936 1703.53 992.285 1704.67C991.642 1705.8 990.755 1706.69 989.623 1707.35C988.491 1708 987.205 1708.33 985.764 1708.33ZM1005.21 1708.33C1004.27 1708.31 1003.34 1708.13 1002.41 1707.8C1001.48 1707.45 1000.63 1706.88 999.861 1706.08C999.093 1705.26 998.476 1704.17 998.011 1702.78C997.545 1701.39 997.311 1699.65 997.311 1697.55C997.311 1695.54 997.5 1693.76 997.878 1692.21C998.253 1690.65 998.802 1689.34 999.517 1688.27C1000.24 1687.2 1001.1 1686.39 1002.11 1685.83C1003.13 1685.28 1004.28 1685 1005.56 1685C1006.83 1685 1007.96 1685.25 1008.96 1685.76C1009.95 1686.27 1010.77 1686.97 1011.39 1687.87C1012.02 1688.77 1012.43 1689.81 1012.62 1690.99H1009.91C1009.66 1689.97 1009.17 1689.12 1008.45 1688.45C1007.72 1687.78 1006.76 1687.44 1005.56 1687.44C1003.8 1687.44 1002.42 1688.21 1001.4 1689.73C1000.4 1691.26 999.892 1693.41 999.884 1696.18H1000.06C1000.48 1695.55 1000.97 1695.01 1001.54 1694.57C1002.11 1694.12 1002.75 1693.77 1003.44 1693.53C1004.14 1693.28 1004.87 1693.16 1005.65 1693.16C1006.95 1693.16 1008.14 1693.49 1009.22 1694.14C1010.3 1694.78 1011.17 1695.67 1011.82 1696.81C1012.47 1697.94 1012.79 1699.24 1012.79 1700.7C1012.79 1702.1 1012.48 1703.39 1011.85 1704.56C1011.22 1705.72 1010.34 1706.64 1009.2 1707.33C1008.07 1708.01 1006.74 1708.34 1005.21 1708.33ZM1005.21 1705.89C1006.14 1705.89 1006.97 1705.65 1007.71 1705.19C1008.46 1704.72 1009.05 1704.1 1009.48 1703.32C1009.91 1702.53 1010.13 1701.66 1010.13 1700.7C1010.13 1699.76 1009.92 1698.91 1009.5 1698.14C1009.08 1697.36 1008.51 1696.75 1007.78 1696.29C1007.06 1695.83 1006.23 1695.6 1005.29 1695.6C1004.59 1695.6 1003.94 1695.74 1003.33 1696.02C1002.73 1696.29 1002.2 1696.67 1001.73 1697.15C1001.28 1697.63 1000.93 1698.18 1000.67 1698.8C1000.41 1699.42 1000.28 1700.06 1000.28 1700.74C1000.28 1701.65 1000.49 1702.49 1000.91 1703.27C1001.34 1704.05 1001.93 1704.69 1002.67 1705.17C1003.41 1705.65 1004.26 1705.89 1005.21 1705.89ZM1017.86 1708.02L1028.02 1687.93V1687.75H1016.31V1685.31H1030.86V1687.88L1020.75 1708.02H1017.86Z" - fill="white" - /> - </g> - <g filter="url(#filter1_d_5_2)"> - <path - d="M962.448 1531.36V1529.1L972.429 1513.31H974.071V1516.81H972.962L965.42 1528.74V1528.92H978.862V1531.36H962.448ZM973.139 1536.02V1530.67V1529.62V1513.31H975.757V1536.02H973.139ZM983.903 1536.19C983.353 1536.19 982.885 1536 982.492 1535.61C982.102 1535.22 981.906 1534.74 981.906 1534.2C981.906 1533.65 982.102 1533.18 982.492 1532.79C982.885 1532.4 983.353 1532.2 983.903 1532.2C984.449 1532.2 984.917 1532.4 985.31 1532.79C985.701 1533.18 985.899 1533.65 985.899 1534.2C985.899 1534.56 985.805 1534.89 985.62 1535.2C985.443 1535.5 985.204 1535.74 984.899 1535.93C984.603 1536.11 984.272 1536.19 983.903 1536.19ZM990.549 1536.02V1534.02L998.047 1525.82C998.926 1524.86 999.65 1524.02 1000.22 1523.31C1000.79 1522.59 1001.21 1521.92 1001.48 1521.29C1001.77 1520.66 1001.91 1519.99 1001.91 1519.3C1001.91 1518.5 1001.71 1517.81 1001.33 1517.22C1000.95 1516.64 1000.44 1516.19 999.777 1515.87C999.119 1515.55 998.38 1515.4 997.557 1515.4C996.686 1515.4 995.926 1515.58 995.275 1515.94C994.63 1516.29 994.133 1516.79 993.776 1517.44C993.43 1518.08 993.256 1518.83 993.256 1519.7H990.638C990.638 1518.37 990.945 1517.2 991.559 1516.19C992.173 1515.19 993.008 1514.4 994.065 1513.84C995.129 1513.28 996.324 1513 997.648 1513C998.978 1513 1000.16 1513.28 1001.19 1513.84C1002.21 1514.4 1003.02 1515.16 1003.6 1516.12C1004.19 1517.07 1004.48 1518.13 1004.48 1519.3C1004.48 1520.13 1004.33 1520.95 1004.02 1521.75C1003.73 1522.54 1003.21 1523.42 1002.47 1524.4C1001.74 1525.37 1000.72 1526.55 999.421 1527.95L994.32 1533.4V1533.58H1004.88V1536.02H990.549ZM1009.86 1536.02V1513.31H1012.61V1524.57H1012.87L1023.08 1513.31H1026.67L1017.13 1523.55L1026.67 1536.02H1023.34L1015.45 1525.46L1012.61 1528.65V1536.02H1009.86Z" - fill="white" - /> - </g> - <path - d="M981.641 1429.12C982.123 1429.11 982.123 1429.11 984.551 1429.1C986.581 1429.09 988.611 1429.09 990.638 1429.1C993.74 1429.11 996.837 1429.08 999.936 1429.04C1001.91 1429.04 1003.88 1429.04 1005.86 1429.04C1006.78 1429.03 1007.71 1429.02 1008.66 1429C1012.71 1429.05 1015.52 1429.46 1019.05 1431.36C1022.18 1436.32 1021.68 1441.07 1021.55 1446.73C1021.54 1447.91 1021.54 1449.09 1021.53 1450.32C1021.5 1454.09 1021.43 1457.86 1021.37 1461.64C1021.34 1464.2 1021.32 1466.76 1021.3 1469.32C1021.24 1475.6 1021.92 1482.55 1021.81 1488.83C1021.81 1488.83 1021.68 1493.31 1015.86 1490.58C1014.61 1489.71 1013.36 1488.85 1012.08 1487.96C1010.42 1486.83 1008.77 1485.69 1007.12 1484.56C1006.29 1483.99 1005.47 1483.42 1004.63 1482.83C1003.82 1482.29 1003.02 1481.74 1002.2 1481.18C1001.46 1480.67 1000.73 1480.17 999.975 1479.65C997.428 1478.23 995.489 1477.5 992.54 1477.18C989.516 1479.06 986.87 1480.93 984.116 1483.12C975.26 1490.05 972.994 1492.06 967.443 1491.21C967.443 1491.21 967.591 1486.92 967.565 1482.89C967.56 1482.35 967.56 1482.35 967.529 1479.6C967.529 1478.42 967.529 1477.25 967.531 1476.04C967.526 1474.83 967.521 1473.63 967.513 1472.38C967.508 1469.83 967.505 1467.28 967.51 1464.73C967.516 1460.82 967.484 1456.91 967.453 1453C967.45 1450.52 967.448 1448.05 967.451 1445.57C967.438 1444.4 967.424 1443.23 967.414 1442.02C967.422 1440.93 967.43 1439.84 967.437 1438.71C967.437 1437.75 967.437 1436.8 967.435 1435.81C968.827 1428.62 975.185 1429.09 981.641 1429.12Z" - fill="white" - fillOpacity="0.9" - /> - <g filter="url(#filter2_d_5_2)"> - <path - d="M963.428 1365.33C962.126 1365.33 960.955 1365.07 959.912 1364.55C958.871 1364.03 958.035 1363.32 957.406 1362.42C956.778 1361.52 956.435 1360.49 956.375 1359.34H959.037C959.141 1360.37 959.607 1361.22 960.435 1361.89C961.27 1362.56 962.267 1362.89 963.428 1362.89C964.359 1362.89 965.187 1362.67 965.913 1362.23C966.644 1361.8 967.217 1361.2 967.63 1360.44C968.052 1359.67 968.263 1358.8 968.263 1357.83C968.263 1356.84 968.044 1355.96 967.61 1355.18C967.18 1354.4 966.589 1353.78 965.835 1353.33C965.08 1352.88 964.219 1352.65 963.251 1352.64C962.556 1352.64 961.843 1352.74 961.111 1352.97C960.378 1353.18 959.776 1353.46 959.303 1353.8L956.729 1353.49L958.106 1342.31H969.905V1344.75H960.411L959.612 1351.45H959.748C960.211 1351.08 960.796 1350.77 961.499 1350.53C962.202 1350.28 962.933 1350.16 963.696 1350.16C965.085 1350.16 966.321 1350.49 967.409 1351.16C968.505 1351.82 969.361 1352.72 969.983 1353.86C970.61 1355.01 970.925 1356.32 970.925 1357.79C970.925 1359.24 970.6 1360.53 969.949 1361.67C969.306 1362.8 968.419 1363.69 967.287 1364.35C966.157 1365 964.869 1365.33 963.428 1365.33ZM978.425 1361.91L978.248 1363.11C978.121 1363.95 977.928 1364.85 977.67 1365.82C977.418 1366.78 977.155 1367.68 976.882 1368.53C976.609 1369.38 976.385 1370.06 976.205 1370.56H974.209C974.306 1370.09 974.433 1369.46 974.587 1368.68C974.743 1367.91 974.899 1367.04 975.052 1366.08C975.216 1365.13 975.349 1364.15 975.453 1363.15L975.586 1361.91H978.425ZM988.499 1342.31V1365.02H985.748V1345.19H985.616L980.073 1348.87V1346.08L985.748 1342.31H988.499ZM994.5 1360.36V1358.1L1004.48 1342.31H1006.12V1345.81H1005.01L997.472 1357.74V1357.92H1010.91V1360.36H994.5ZM1005.19 1365.02V1359.67V1358.62V1342.31H1007.81V1365.02H1005.19ZM1022.34 1365.33C1020.82 1365.33 1019.47 1365.06 1018.31 1364.52C1017.15 1363.97 1016.24 1363.22 1015.59 1362.27C1014.94 1361.31 1014.62 1360.21 1014.62 1358.99C1014.62 1358.02 1014.8 1357.14 1015.19 1356.32C1015.57 1355.51 1016.1 1354.82 1016.76 1354.27C1017.44 1353.72 1018.19 1353.37 1019.02 1353.22V1353.09C1017.93 1352.81 1017.06 1352.2 1016.42 1351.26C1015.78 1350.31 1015.46 1349.24 1015.47 1348.03C1015.46 1346.88 1015.75 1345.85 1016.34 1344.94C1016.94 1344.03 1017.75 1343.31 1018.78 1342.79C1019.82 1342.26 1021.01 1342 1022.34 1342C1023.66 1342 1024.83 1342.26 1025.87 1342.79C1026.91 1343.31 1027.72 1344.03 1028.31 1344.94C1028.91 1345.85 1029.21 1346.88 1029.22 1348.03C1029.21 1349.24 1028.88 1350.31 1028.23 1351.26C1027.59 1352.2 1026.73 1352.81 1025.67 1353.09V1353.22C1026.49 1353.37 1027.23 1353.72 1027.89 1354.27C1028.55 1354.82 1029.07 1355.51 1029.46 1356.32C1029.85 1357.14 1030.05 1358.02 1030.06 1358.99C1030.05 1360.21 1029.72 1361.31 1029.06 1362.27C1028.41 1363.22 1027.51 1363.97 1026.35 1364.52C1025.19 1365.06 1023.86 1365.33 1022.34 1365.33ZM1022.34 1362.89C1023.37 1362.89 1024.26 1362.72 1025 1362.39C1025.75 1362.06 1026.33 1361.59 1026.73 1360.98C1027.14 1360.38 1027.35 1359.67 1027.36 1358.85C1027.35 1357.99 1027.13 1357.24 1026.69 1356.58C1026.25 1355.92 1025.66 1355.4 1024.9 1355.03C1024.16 1354.65 1023.3 1354.46 1022.34 1354.46C1021.38 1354.46 1020.51 1354.65 1019.75 1355.03C1018.99 1355.4 1018.4 1355.92 1017.96 1356.58C1017.53 1357.24 1017.32 1357.99 1017.33 1358.85C1017.32 1359.67 1017.52 1360.38 1017.92 1360.98C1018.33 1361.59 1018.91 1362.06 1019.66 1362.39C1020.41 1362.72 1021.31 1362.89 1022.34 1362.89ZM1022.34 1352.11C1023.16 1352.11 1023.88 1351.95 1024.51 1351.62C1025.14 1351.3 1025.64 1350.84 1026 1350.26C1026.36 1349.68 1026.55 1348.99 1026.56 1348.21C1026.55 1347.44 1026.37 1346.77 1026.01 1346.2C1025.66 1345.63 1025.17 1345.18 1024.54 1344.87C1023.91 1344.55 1023.18 1344.4 1022.34 1344.4C1021.49 1344.4 1020.75 1344.55 1020.11 1344.87C1019.48 1345.18 1018.99 1345.63 1018.64 1346.2C1018.29 1346.77 1018.12 1347.44 1018.13 1348.21C1018.12 1348.99 1018.29 1349.68 1018.65 1350.26C1019.01 1350.84 1019.51 1351.3 1020.15 1351.62C1020.78 1351.95 1021.52 1352.11 1022.34 1352.11Z" - fill="white" - /> - </g> - <path - fillRule="evenodd" - clipRule="evenodd" - d="M977.188 1256.62C988.956 1252.25 1004.57 1253.5 1015.53 1259.22C1022.37 1263.77 1028.69 1269.31 1031.15 1277.23C1032.49 1287.77 1032.28 1297.64 1025.47 1306.32C1020.61 1311.36 1015.45 1315.75 1009.85 1320C1008.42 1321.11 1006.98 1322.22 1005.5 1323.36C1001.33 1325.71 1001.27 1325.82 998.234 1326.81C997.737 1326.63 998.414 1326.92 995.895 1326.04C995.66 1324.9 995.673 1323.02 995.452 1317.87C993.813 1317.75 989.532 1317.17 987.84 1317.05C976.496 1315.59 969.272 1310.69 962.152 1302.07C956.676 1294.74 956.213 1287.47 957.309 1278.61C960.494 1268.07 967.95 1262.26 977.188 1256.62ZM976.374 1282.47C973.498 1282.47 971.169 1284.8 971.169 1287.68C971.169 1290.55 973.498 1292.88 976.374 1292.88C979.247 1292.88 981.579 1290.55 981.579 1287.68C981.579 1284.8 979.25 1282.47 976.374 1282.47ZM994.552 1282.47C991.679 1282.47 989.347 1284.8 989.347 1287.68C989.347 1290.55 991.679 1292.88 994.552 1292.88C997.427 1292.88 999.756 1290.55 999.756 1287.68C999.756 1284.8 997.427 1282.47 994.552 1282.47ZM1012.73 1282.47C1009.85 1282.47 1007.52 1284.8 1007.52 1287.68C1007.52 1290.55 1009.85 1292.88 1012.73 1292.88C1015.6 1292.88 1017.93 1290.55 1017.93 1287.68C1017.93 1284.8 1015.6 1282.47 1012.73 1282.47Z" - fill="white" - fillOpacity="0.9" - /> - <g filter="url(#filter3_d_5_2)"> - <path - d="M947.525 1193.06V1190.68L954.89 1183.05C955.676 1182.22 956.324 1181.49 956.834 1180.87C957.352 1180.24 957.737 1179.64 957.992 1179.07C958.247 1178.51 958.375 1177.9 958.375 1177.27C958.375 1176.54 958.206 1175.92 957.865 1175.4C957.524 1174.87 957.061 1174.46 956.472 1174.18C955.884 1173.89 955.223 1173.74 954.484 1173.74C953.706 1173.74 953.027 1173.9 952.444 1174.22C951.864 1174.54 951.419 1174.99 951.106 1175.57C950.794 1176.15 950.638 1176.83 950.638 1177.61H947.502C947.502 1176.28 947.806 1175.12 948.418 1174.13C949.027 1173.14 949.862 1172.37 950.924 1171.83C951.989 1171.28 953.196 1171 954.549 1171C955.916 1171 957.12 1171.27 958.161 1171.82C959.21 1172.36 960.03 1173.09 960.618 1174.03C961.206 1174.95 961.5 1176 961.5 1177.17C961.5 1177.98 961.347 1178.77 961.042 1179.54C960.746 1180.31 960.225 1181.17 959.481 1182.12C958.737 1183.06 957.701 1184.21 956.376 1185.55L952.051 1190.08V1190.24H961.852V1193.06H947.525ZM973.063 1193.35C971.73 1193.35 970.533 1193.1 969.471 1192.59C968.415 1192.07 967.572 1191.36 966.942 1190.46C966.31 1189.56 965.974 1188.54 965.932 1187.38H969.12C969.198 1188.32 969.612 1189.09 970.364 1189.69C971.114 1190.29 972.014 1190.59 973.063 1190.59C973.898 1190.59 974.64 1190.4 975.283 1190.02C975.936 1189.63 976.446 1189.09 976.815 1188.41C977.19 1187.73 977.378 1186.96 977.378 1186.09C977.378 1185.2 977.185 1184.41 976.802 1183.72C976.42 1183.02 975.894 1182.48 975.22 1182.08C974.554 1181.68 973.789 1181.48 972.925 1181.47C972.266 1181.47 971.603 1181.59 970.937 1181.81C970.27 1182.04 969.732 1182.34 969.321 1182.71L966.315 1182.26L967.535 1171.3H979.504V1174.11H970.268L969.576 1180.2H969.703C970.13 1179.79 970.692 1179.44 971.395 1179.17C972.103 1178.89 972.86 1178.76 973.669 1178.76C974.994 1178.76 976.173 1179.07 977.208 1179.7C978.249 1180.33 979.067 1181.19 979.663 1182.28C980.264 1183.37 980.563 1184.61 980.555 1186.02C980.563 1187.43 980.243 1188.69 979.597 1189.79C978.96 1190.9 978.075 1191.77 976.943 1192.41C975.816 1193.04 974.523 1193.35 973.063 1193.35ZM991.792 1193.35C990.267 1193.35 988.922 1193.09 987.753 1192.57C986.59 1192.04 985.679 1191.32 985.021 1190.4C984.362 1189.48 984.037 1188.43 984.045 1187.25C984.037 1186.33 984.225 1185.49 984.607 1184.72C984.997 1183.94 985.526 1183.29 986.189 1182.77C986.856 1182.25 987.6 1181.91 988.422 1181.77V1181.65C987.34 1181.38 986.47 1180.8 985.82 1179.9C985.167 1179 984.844 1177.97 984.852 1176.8C984.844 1175.69 985.138 1174.7 985.734 1173.83C986.335 1172.95 987.16 1172.26 988.209 1171.75C989.258 1171.25 990.452 1171 991.792 1171C993.117 1171 994.298 1171.25 995.342 1171.76C996.391 1172.27 997.216 1172.96 997.817 1173.84C998.421 1174.71 998.725 1175.7 998.73 1176.8C998.725 1177.97 998.392 1179 997.731 1179.9C997.073 1180.8 996.216 1181.38 995.16 1181.65V1181.77C995.974 1181.91 996.708 1182.25 997.361 1182.77C998.02 1183.29 998.543 1183.94 998.933 1184.72C999.329 1185.49 999.532 1186.33 999.54 1187.25C999.532 1188.43 999.199 1189.48 998.54 1190.4C997.882 1191.32 996.966 1192.04 995.797 1192.57C994.637 1193.09 993.302 1193.35 991.792 1193.35ZM991.792 1190.67C992.69 1190.67 993.471 1190.52 994.129 1190.22C994.788 1189.91 995.298 1189.49 995.66 1188.95C996.021 1188.41 996.206 1187.77 996.211 1187.04C996.206 1186.28 996.008 1185.62 995.618 1185.03C995.235 1184.45 994.715 1184 994.054 1183.66C993.395 1183.33 992.641 1183.17 991.792 1183.17C990.933 1183.17 990.174 1183.33 989.507 1183.66C988.841 1184 988.315 1184.45 987.933 1185.03C987.55 1185.62 987.363 1186.28 987.371 1187.04C987.363 1187.77 987.537 1188.41 987.891 1188.95C988.253 1189.49 988.766 1189.91 989.432 1190.22C990.098 1190.52 990.884 1190.67 991.792 1190.67ZM991.792 1180.54C992.513 1180.54 993.156 1180.39 993.715 1180.1C994.275 1179.81 994.715 1179.41 995.032 1178.89C995.358 1178.38 995.524 1177.77 995.532 1177.08C995.524 1176.4 995.363 1175.8 995.043 1175.29C994.73 1174.78 994.296 1174.39 993.736 1174.11C993.177 1173.83 992.529 1173.69 991.792 1173.69C991.04 1173.69 990.382 1173.83 989.815 1174.11C989.255 1174.39 988.82 1174.78 988.508 1175.29C988.196 1175.8 988.042 1176.4 988.05 1177.08C988.042 1177.77 988.198 1178.38 988.518 1178.89C988.836 1179.41 989.276 1179.81 989.835 1180.1C990.403 1180.39 991.053 1180.54 991.792 1180.54ZM1004.84 1193.26C1004.26 1193.26 1003.76 1193.05 1003.34 1192.64C1002.92 1192.22 1002.71 1191.72 1002.71 1191.13C1002.71 1190.55 1002.92 1190.06 1003.34 1189.65C1003.76 1189.23 1004.26 1189.02 1004.84 1189.02C1005.42 1189.02 1005.92 1189.23 1006.34 1189.65C1006.76 1190.06 1006.96 1190.55 1006.96 1191.13C1006.96 1191.52 1006.87 1191.88 1006.67 1192.21C1006.48 1192.52 1006.22 1192.78 1005.9 1192.97C1005.58 1193.16 1005.23 1193.26 1004.84 1193.26ZM1016.81 1171.3V1193.06H1013.51V1174.59H1013.38L1008.18 1177.99V1174.85L1013.61 1171.3H1016.81ZM1022.99 1193.06V1171.3H1026.28V1181.69H1026.54L1035.67 1171.3H1039.8L1031.03 1181.09L1039.84 1193.06H1035.88L1028.85 1183.33L1026.28 1186.29V1193.06H1022.99Z" - fill="white" - /> - </g> - <path - d="M988.553 1150.16C977.873 1141.93 955.578 1127.87 956.619 1105.96C956.619 1094.76 965.067 1085.02 976.051 1085.02C981.746 1085.02 987.12 1085 994.708 1092.81C1001.48 1084.91 1007.48 1085 1013.17 1085C1024.16 1085 1032.52 1094.76 1032.52 1105.96C1033.34 1127.56 1011.24 1143.14 1000.59 1150.16C996.579 1152.24 991.838 1151.72 988.553 1150.16Z" - fill="white" - fillOpacity="0.9" - /> - <path - d="M994.631 876.5C1026.17 876.5 1051.73 902.056 1051.73 933.58C1051.73 965.105 1026.17 990.661 994.631 990.661C963.097 990.661 937.533 965.105 937.533 933.58C937.533 902.056 963.097 876.5 994.631 876.5Z" - fill="#9D9D9D" - stroke="white" - /> - <path - d="M993.947 1019.18C1009.74 1019.18 1022.55 1006.38 1022.55 990.592C1022.55 974.801 1009.74 962 993.947 962C978.152 962 965.347 974.801 965.347 990.592C965.347 1006.38 978.152 1019.18 993.947 1019.18Z" - fill="#FE2C55" - /> - <path - d="M1008.87 988.577H980.264V993.747H1008.87V988.577Z" - fill="white" - /> - <path - d="M991.979 976.864V1005.46H997.151V976.864H991.979Z" - fill="white" - /> - <path - d="M998.114 252.67C1009.71 252.67 1019.12 243.27 1019.12 231.675C1019.12 220.08 1009.71 210.68 998.114 210.68C986.516 210.68 977.113 220.08 977.113 231.675C977.113 243.27 986.516 252.67 998.114 252.67Z" - stroke="white" - strokeWidth="4" - /> - <path - d="M1015.72 246.31C1014.9 245.494 1013.58 245.494 1012.76 246.31C1011.94 247.126 1011.94 248.449 1012.76 249.265L1024.78 261.282C1025.6 262.098 1026.92 262.098 1027.74 261.282C1028.55 260.466 1028.55 259.143 1027.74 258.327L1015.72 246.31Z" - fill="white" - /> - <path - d="M791.47 268H734.394C732.965 268 731.807 269.158 731.807 270.587V270.587C731.807 272.015 732.965 273.174 734.394 273.174H791.47C792.899 273.174 794.058 272.015 794.058 270.587C794.058 269.158 792.899 268 791.47 268Z" - fill="white" - /> - <path - d="M686.65 236.274V206H706.051V210.597H692.136V218.816H704.722V223.413H692.136V236.274H686.65ZM720.094 236.717C717.874 236.717 715.954 236.229 714.327 235.254C712.701 234.278 711.438 232.913 710.541 231.159C709.653 229.405 709.211 227.355 709.211 225.01C709.211 222.664 709.653 220.61 710.541 218.846C711.438 217.082 712.701 215.712 714.327 214.736C715.954 213.761 717.874 213.273 720.094 213.273C722.311 213.273 724.234 213.761 725.861 214.736C727.487 215.712 728.744 217.082 729.629 218.846C730.527 220.61 730.975 222.664 730.975 225.01C730.975 227.355 730.527 229.405 729.629 231.159C728.744 232.913 727.487 234.278 725.861 235.254C724.234 236.229 722.311 236.717 720.094 236.717ZM720.123 232.43C721.325 232.43 722.332 232.1 723.139 231.44C723.948 230.77 724.549 229.873 724.942 228.75C725.348 227.626 725.549 226.375 725.549 224.995C725.549 223.605 725.348 222.349 724.942 221.226C724.549 220.092 723.948 219.191 723.139 218.52C722.332 217.85 721.325 217.515 720.123 217.515C718.892 217.515 717.866 217.85 717.047 218.52C716.24 219.191 715.633 220.092 715.228 221.226C714.835 222.349 714.637 223.605 714.637 224.995C714.637 226.375 714.835 227.626 715.228 228.75C715.633 229.873 716.24 230.77 717.047 231.44C717.866 232.1 718.892 232.43 720.123 232.43ZM736.559 236.274V213.568H741.751V217.353H741.985C742.402 216.042 743.11 215.032 744.117 214.322C745.132 213.603 746.29 213.243 747.591 213.243C747.885 213.243 748.216 213.258 748.583 213.288C748.955 213.307 749.267 213.342 749.512 213.391V218.314C749.285 218.235 748.926 218.166 748.434 218.106C747.95 218.037 747.482 218.003 747.029 218.003C746.053 218.003 745.176 218.215 744.398 218.639C743.628 219.053 743.021 219.629 742.579 220.368C742.134 221.107 741.913 221.96 741.913 222.925V236.274H736.559ZM763.645 206H769.842L777.248 219.393H777.545L784.954 206H791.148L780.132 224.98V236.274H774.661V224.98L763.645 206ZM801.516 236.717C799.298 236.717 797.375 236.229 795.749 235.254C794.122 234.278 792.86 232.913 791.965 231.159C791.077 229.405 790.632 227.355 790.632 225.01C790.632 222.664 791.077 220.61 791.965 218.846C792.86 217.082 794.122 215.712 795.749 214.736C797.375 213.761 799.298 213.273 801.516 213.273C803.733 213.273 805.656 213.761 807.283 214.736C808.909 215.712 810.166 217.082 811.053 218.846C811.951 220.61 812.399 222.664 812.399 225.01C812.399 227.355 811.951 229.405 811.053 231.159C810.166 232.913 808.909 234.278 807.283 235.254C805.656 236.229 803.733 236.717 801.516 236.717ZM801.547 232.43C802.749 232.43 803.754 232.1 804.563 231.44C805.37 230.77 805.971 229.873 806.367 228.75C806.77 227.626 806.973 226.375 806.973 224.995C806.973 223.605 806.77 222.349 806.367 221.226C805.971 220.092 805.37 219.191 804.563 218.52C803.754 217.85 802.749 217.515 801.547 217.515C800.313 217.515 799.288 217.85 798.471 218.52C797.661 219.191 797.055 220.092 796.652 221.226C796.256 222.349 796.061 223.605 796.061 224.995C796.061 226.375 796.256 227.626 796.652 228.75C797.055 229.873 797.661 230.77 798.471 231.44C799.288 232.1 800.313 232.43 801.547 232.43ZM832.458 226.724V213.568H837.812V236.274H832.622V232.238H832.385C831.873 233.509 831.03 234.549 829.856 235.357C828.693 236.165 827.259 236.569 825.554 236.569C824.066 236.569 822.749 236.239 821.606 235.579C820.472 234.909 819.584 233.938 818.944 232.667C818.304 231.386 817.984 229.838 817.984 228.025V213.568H823.337V227.198C823.337 228.636 823.73 229.779 824.518 230.627C825.307 231.474 826.343 231.898 827.623 231.898C828.412 231.898 829.177 231.706 829.916 231.322C830.655 230.937 831.261 230.366 831.735 229.607C832.216 228.838 832.458 227.877 832.458 226.724Z" - fill="white" - /> - <path - d="M439.279 236.486V206.212H458.058V210.144H443.847V219.368H456.71V223.285H443.847V236.486H439.279ZM472.262 236.944C470.133 236.944 468.275 236.456 466.688 235.48C465.1 234.505 463.867 233.14 462.99 231.386C462.113 229.632 461.676 227.582 461.676 225.236C461.676 222.881 462.113 220.822 462.99 219.058C463.867 217.294 465.1 215.924 466.688 214.948C468.275 213.973 470.133 213.485 472.262 213.485C474.391 213.485 476.249 213.973 477.837 214.948C479.424 215.924 480.655 217.294 481.532 219.058C482.409 220.822 482.849 222.881 482.849 225.236C482.849 227.582 482.409 229.632 481.532 231.386C480.655 233.14 479.424 234.505 477.837 235.48C476.249 236.456 474.391 236.944 472.262 236.944ZM472.275 233.234C473.657 233.234 474.8 232.869 475.708 232.14C476.613 231.41 477.285 230.44 477.717 229.228C478.162 228.016 478.383 226.68 478.383 225.222C478.383 223.773 478.162 222.443 477.717 221.231C477.285 220.009 476.613 219.028 475.708 218.289C474.8 217.55 473.657 217.18 472.275 217.18C470.886 217.18 469.733 217.55 468.817 218.289C467.908 219.028 467.234 220.009 466.792 221.231C466.357 222.443 466.141 223.773 466.141 225.222C466.141 226.68 466.357 228.016 466.792 229.228C467.234 230.44 467.908 231.41 468.817 232.14C469.733 232.869 470.886 233.234 472.275 233.234ZM493.245 206.212V236.486H488.824V206.212H493.245ZM504.657 206.212V236.486H500.236V206.212H504.657ZM521.211 236.944C519.082 236.944 517.224 236.456 515.637 235.48C514.049 234.505 512.818 233.14 511.941 231.386C511.064 229.632 510.624 227.582 510.624 225.236C510.624 222.881 511.064 220.822 511.941 219.058C512.818 217.294 514.049 215.924 515.637 214.948C517.224 213.973 519.082 213.485 521.211 213.485C523.34 213.485 525.198 213.973 526.785 214.948C528.373 215.924 529.606 217.294 530.483 219.058C531.36 220.822 531.798 222.881 531.798 225.236C531.798 227.582 531.36 229.632 530.483 231.386C529.606 233.14 528.373 234.505 526.785 235.48C525.198 236.456 523.34 236.944 521.211 236.944ZM521.227 233.234C522.606 233.234 523.751 232.869 524.657 232.14C525.562 231.41 526.234 230.44 526.668 229.228C527.111 228.016 527.332 226.68 527.332 225.222C527.332 223.773 527.111 222.443 526.668 221.231C526.234 220.009 525.562 219.028 524.657 218.289C523.751 217.55 522.606 217.18 521.227 217.18C519.837 217.18 518.684 217.55 517.765 218.289C516.86 219.028 516.183 220.009 515.741 221.231C515.306 222.443 515.09 223.773 515.09 225.222C515.09 226.68 515.306 228.016 515.741 229.228C516.183 230.44 516.86 231.41 517.765 232.14C518.684 232.869 519.837 233.234 521.227 233.234ZM542.007 236.486L535.324 213.78H539.894L544.344 230.455H544.565L549.031 213.78H553.601L558.035 230.381H558.256L562.678 213.78H567.248L560.578 236.486H556.068L551.456 220.093H551.115L546.501 236.486H542.007ZM572.484 236.486V213.78H576.905V236.486H572.484ZM574.716 210.277C573.949 210.277 573.288 210.021 572.736 209.509C572.192 208.986 571.922 208.365 571.922 207.646C571.922 206.917 572.192 206.296 572.736 205.784C573.288 205.261 573.949 205 574.716 205C575.487 205 576.14 205.261 576.684 205.784C577.236 206.296 577.511 206.917 577.511 207.646C577.511 208.365 577.236 208.986 576.684 209.509C576.14 210.021 575.487 210.277 574.716 210.277ZM588.314 223.005V236.486H583.895V213.78H588.137V217.476H588.418C588.941 216.274 589.758 215.308 590.872 214.579C591.996 213.849 593.412 213.485 595.117 213.485C596.665 213.485 598.021 213.81 599.184 214.461C600.345 215.101 601.248 216.057 601.888 217.328C602.528 218.599 602.851 220.171 602.851 222.044V236.486H598.43V222.576C598.43 220.93 598 219.644 597.141 218.718C596.285 217.782 595.106 217.313 593.607 217.313C592.585 217.313 591.671 217.535 590.872 217.979C590.084 218.422 589.459 219.072 588.996 219.93C588.54 220.777 588.314 221.802 588.314 223.005ZM619.343 245.473C617.539 245.473 615.985 245.236 614.684 244.763C613.393 244.291 612.339 243.665 611.52 242.886C610.703 242.108 610.091 241.255 609.688 240.329L613.487 238.762C613.753 239.196 614.109 239.654 614.552 240.137C615.004 240.63 615.616 241.048 616.386 241.393C617.164 241.738 618.164 241.911 619.387 241.911C621.063 241.911 622.447 241.502 623.543 240.684C624.636 239.876 625.182 238.585 625.182 236.811V232.347H624.901C624.636 232.83 624.251 233.367 623.748 233.958C623.257 234.549 622.577 235.062 621.708 235.495C620.842 235.929 619.712 236.146 618.322 236.146C616.529 236.146 614.911 235.727 613.472 234.889C612.043 234.042 610.911 232.795 610.073 231.149C609.243 229.494 608.829 227.459 608.829 225.044C608.829 222.63 609.24 220.561 610.057 218.836C610.885 217.111 612.019 215.791 613.459 214.874C614.898 213.948 616.529 213.485 618.351 213.485C619.762 213.485 620.901 213.721 621.768 214.194C622.635 214.657 623.311 215.2 623.793 215.82C624.287 216.441 624.667 216.988 624.933 217.461H625.258V213.78H629.591V236.988C629.591 238.939 629.135 240.541 628.23 241.792C627.322 243.044 626.096 243.97 624.547 244.571C623.009 245.172 621.276 245.473 619.343 245.473ZM619.298 232.48C620.571 232.48 621.646 232.184 622.523 231.593C623.41 230.992 624.079 230.134 624.532 229.021C624.995 227.897 625.227 226.552 625.227 224.985C625.227 223.458 625 222.113 624.547 220.95C624.095 219.787 623.428 218.88 622.551 218.23C621.674 217.57 620.589 217.24 619.298 217.24C617.969 217.24 616.86 217.584 615.972 218.274C615.085 218.954 614.414 219.881 613.961 221.053C613.516 222.226 613.295 223.537 613.295 224.985C613.295 226.473 613.521 227.779 613.976 228.902C614.429 230.026 615.098 230.903 615.985 231.534C616.883 232.164 617.987 232.48 619.298 232.48Z" - fill="#C0C0C0" - /> - <path - d="M234.87 236.274V206H253.856V209.932H239.439V219.156H252.865V223.073H239.439V232.341H254.033V236.274H234.87ZM264.134 213.568L269.147 222.408L274.203 213.568H279.039L271.957 224.921L279.098 236.274H274.263L269.147 227.789L264.046 236.274H259.196L266.263 224.921L259.285 213.568H264.134ZM284.621 244.788V213.568H288.938V217.249H289.308C289.563 216.776 289.932 216.229 290.416 215.608C290.898 214.988 291.569 214.445 292.428 213.982C293.284 213.509 294.419 213.273 295.827 213.273C297.661 213.273 299.298 213.736 300.737 214.662C302.177 215.589 303.306 216.924 304.123 218.668C304.951 220.412 305.364 222.511 305.364 224.965C305.364 227.419 304.956 229.523 304.139 231.277C303.319 233.022 302.195 234.367 300.766 235.313C299.337 236.249 297.706 236.717 295.871 236.717C294.492 236.717 293.362 236.485 292.485 236.022C291.619 235.559 290.939 235.017 290.445 234.396C289.953 233.775 289.573 233.223 289.308 232.741H289.04V244.788H284.621ZM288.951 224.921C288.951 226.517 289.183 227.917 289.646 229.119C290.109 230.321 290.781 231.263 291.658 231.942C292.535 232.613 293.609 232.948 294.882 232.948C296.201 232.948 297.307 232.598 298.195 231.898C299.08 231.189 299.751 230.228 300.204 229.016C300.667 227.803 300.899 226.439 300.899 224.921C300.899 223.423 300.672 222.078 300.22 220.885C299.775 219.693 299.106 218.752 298.208 218.062C297.32 217.372 296.212 217.028 294.882 217.028C293.599 217.028 292.516 217.358 291.629 218.018C290.752 218.678 290.086 219.599 289.633 220.782C289.18 221.965 288.951 223.344 288.951 224.921ZM315.779 206V236.274H311.36V206H315.779ZM332.336 236.732C330.207 236.732 328.349 236.244 326.762 235.268C325.174 234.293 323.943 232.928 323.066 231.174C322.189 229.42 321.749 227.37 321.749 225.024C321.749 222.669 322.189 220.61 323.066 218.846C323.943 217.082 325.174 215.712 326.762 214.736C328.349 213.761 330.207 213.273 332.336 213.273C334.465 213.273 336.323 213.761 337.91 214.736C339.498 215.712 340.731 217.082 341.608 218.846C342.485 220.61 342.922 222.669 342.922 225.024C342.922 227.37 342.485 229.42 341.608 231.174C340.731 232.928 339.498 234.293 337.91 235.268C336.323 236.244 334.465 236.732 332.336 236.732ZM332.352 233.022C333.731 233.022 334.876 232.657 335.781 231.928C336.687 231.198 337.359 230.228 337.793 229.016C338.236 227.803 338.457 226.468 338.457 225.01C338.457 223.561 338.236 222.231 337.793 221.019C337.359 219.797 336.687 218.816 335.781 218.077C334.876 217.338 333.731 216.968 332.352 216.968C330.962 216.968 329.809 217.338 328.89 218.077C327.985 218.816 327.308 219.797 326.866 221.019C326.431 222.231 326.215 223.561 326.215 225.01C326.215 226.468 326.431 227.803 326.866 229.016C327.308 230.228 327.985 231.198 328.89 231.928C329.809 232.657 330.962 233.022 332.352 233.022ZM348.9 236.274V213.568H353.173V217.175H353.41C353.824 215.953 354.553 214.993 355.596 214.293C356.653 213.583 357.845 213.228 359.175 213.228C359.453 213.228 359.778 213.238 360.15 213.258C360.536 213.278 360.838 213.302 361.054 213.332V217.56C360.877 217.51 360.562 217.456 360.106 217.397C359.653 217.328 359.201 217.293 358.748 217.293C357.702 217.293 356.77 217.515 355.953 217.959C355.143 218.392 354.503 218.998 354.03 219.777C353.556 220.545 353.319 221.423 353.319 222.408V236.274H348.9ZM374.896 236.732C372.658 236.732 370.732 236.254 369.116 235.298C367.507 234.332 366.266 232.977 365.389 231.233C364.52 229.479 364.088 227.424 364.088 225.069C364.088 222.743 364.52 220.693 365.389 218.919C366.266 217.146 367.489 215.761 369.056 214.766C370.633 213.771 372.475 213.273 374.586 213.273C375.866 213.273 377.108 213.485 378.313 213.908C379.515 214.332 380.592 214.997 381.55 215.904C382.505 216.811 383.26 217.988 383.812 219.437C384.363 220.876 384.639 222.625 384.639 224.685V226.251H366.586V222.94H380.309C380.309 221.777 380.072 220.748 379.598 219.851C379.125 218.944 378.458 218.23 377.602 217.707C376.754 217.185 375.76 216.924 374.615 216.924C373.373 216.924 372.288 217.229 371.362 217.841C370.446 218.441 369.735 219.23 369.233 220.206C368.741 221.171 368.494 222.221 368.494 223.354V225.941C368.494 227.458 368.759 228.75 369.293 229.814C369.834 230.878 370.589 231.691 371.554 232.253C372.52 232.805 373.649 233.081 374.94 233.081C375.778 233.081 376.543 232.962 377.233 232.726C377.922 232.48 378.518 232.115 379.021 231.632C379.525 231.149 379.908 230.553 380.176 229.843L384.358 230.597C384.025 231.829 383.424 232.908 382.555 233.835C381.698 234.751 380.618 235.465 379.317 235.978C378.026 236.48 376.551 236.732 374.896 236.732Z" - fill="#C0C0C0" - /> - <path - d="M96.4754 244.508C96.4754 247.382 94.1452 249.712 91.2705 249.712H47.4236C44.5489 249.712 42.2188 247.382 42.2188 244.508V239.399H96.4754V244.508ZM47.3676 239.45V244.328H91.4358V239.45H47.3676ZM91.2705 199.109C94.1452 199.109 96.4754 201.439 96.4754 204.313V209.173H42.2188V204.313C42.2188 201.439 44.5489 199.11 47.4236 199.109H91.2705ZM47.3676 204.262V209.14H91.4358V204.262H47.3676Z" - fill="white" - /> - <path - d="M39.3633 235.03V214.217H44.3953V230.945H53.0564V235.03H39.3633ZM60.6934 214.217V235.03H55.6614V214.217H60.6934ZM68.5795 214.217L73.2352 229.461H73.408L78.0639 214.217H83.6956L76.6711 235.03H69.972L62.9476 214.217H68.5795ZM85.9373 235.03V214.217H100.454V218.303H90.9693V222.571H99.7116V226.666H90.9693V230.945H100.454V235.03H85.9373Z" - fill="white" - /> - <path - d="M59.0617 189.84L58.7406 190.161C57.7243 191.177 57.7243 192.824 58.7406 193.84L67.204 202.301C68.2203 203.317 69.868 203.317 70.8843 202.301L71.2054 201.98C72.2217 200.964 72.2217 199.317 71.2054 198.301L62.742 189.84C61.7257 188.824 60.078 188.824 59.0617 189.84Z" - fill="white" - /> - <path - d="M80.4113 190.161L80.0902 189.84C79.0739 188.824 77.4261 188.824 76.4098 189.84L67.9464 198.3C66.9301 199.316 66.9301 200.964 67.9464 201.98L68.2675 202.301C69.2839 203.317 70.9316 203.317 71.9479 202.301L80.4113 193.84C81.4276 192.824 81.4276 191.177 80.4113 190.161Z" - fill="white" - /> - <path - d="M79.951 41.7658V65.0386H76.4283V45.2886H76.2919L70.7237 48.9249V45.5613L76.5305 41.7658H79.951ZM95.716 41.7658V65.0386H92.1933V45.2886H92.0569L86.4887 48.9249V45.5613L92.2955 41.7658H95.716ZM104.936 62.1522C104.314 62.1522 103.78 61.9325 103.333 61.4931C102.886 61.0461 102.663 60.5083 102.663 59.8795C102.663 59.2583 102.886 58.728 103.333 58.2886C103.78 57.8416 104.314 57.6181 104.936 57.6181C105.557 57.6181 106.091 57.8416 106.538 58.2886C106.985 58.728 107.208 59.2583 107.208 59.8795C107.208 60.2961 107.102 60.6787 106.89 61.0272C106.686 61.3681 106.413 61.6408 106.072 61.8454C105.731 62.0499 105.352 62.1522 104.936 62.1522ZM104.936 50.4931C104.314 50.4931 103.78 50.2734 103.333 49.834C102.886 49.3871 102.663 48.8492 102.663 48.2204C102.663 47.5992 102.886 47.0689 103.333 46.6295C103.78 46.1825 104.314 45.959 104.936 45.959C105.557 45.959 106.091 46.1825 106.538 46.6295C106.985 47.0689 107.208 47.5992 107.208 48.2204C107.208 48.6371 107.102 49.0196 106.89 49.3681C106.686 49.709 106.413 49.9818 106.072 50.1863C105.731 50.3908 105.352 50.4931 104.936 50.4931ZM120.036 65.3568C118.612 65.3568 117.331 65.084 116.195 64.5386C115.066 63.9855 114.165 63.228 113.49 62.2658C112.816 61.3037 112.456 60.2052 112.411 58.9704H115.82C115.903 59.9704 116.346 60.7924 117.149 61.4363C117.952 62.0802 118.915 62.4022 120.036 62.4022C120.93 62.4022 121.721 62.1977 122.411 61.7886C123.108 61.3719 123.653 60.7999 124.047 60.0727C124.449 59.3454 124.649 58.5158 124.649 57.584C124.649 56.6371 124.445 55.7924 124.036 55.0499C123.627 54.3075 123.062 53.7242 122.343 53.2999C121.63 52.8757 120.812 52.6598 119.888 52.6522C119.183 52.6522 118.475 52.7734 117.763 53.0158C117.051 53.2583 116.475 53.5765 116.036 53.9704L112.82 53.4931L114.127 41.7658H126.922V44.7772H117.047L116.308 51.2886H116.445C116.899 50.8492 117.502 50.4818 118.252 50.1863C119.009 49.8908 119.82 49.7431 120.683 49.7431C122.1 49.7431 123.362 50.0802 124.468 50.7545C125.581 51.4287 126.456 52.3492 127.093 53.5158C127.737 54.6749 128.055 56.0083 128.047 57.5158C128.055 59.0234 127.714 60.3681 127.024 61.5499C126.343 62.7318 125.396 63.6636 124.183 64.3454C122.979 65.0196 121.596 65.3568 120.036 65.3568ZM132.838 65.0386V62.4931L140.713 54.334C141.554 53.4477 142.247 52.6711 142.792 52.0045C143.345 51.3302 143.758 50.6901 144.031 50.084C144.304 49.478 144.44 48.834 144.44 48.1522C144.44 47.3795 144.258 46.7128 143.895 46.1522C143.531 45.584 143.035 45.1484 142.406 44.8454C141.777 44.5348 141.069 44.3795 140.281 44.3795C139.448 44.3795 138.72 44.5499 138.099 44.8908C137.478 45.2318 137.001 45.7128 136.667 46.334C136.334 46.9552 136.167 47.6825 136.167 48.5158H132.815C132.815 47.0992 133.141 45.8605 133.792 44.7999C134.444 43.7393 135.338 42.9174 136.474 42.334C137.61 41.7431 138.902 41.4477 140.349 41.4477C141.811 41.4477 143.099 41.7393 144.213 42.3227C145.334 42.8984 146.209 43.6863 146.838 44.6863C147.466 45.6787 147.781 46.7999 147.781 48.0499C147.781 48.9136 147.618 49.7583 147.292 50.584C146.974 51.4098 146.417 52.3302 145.622 53.3454C144.826 54.353 143.72 55.5765 142.304 57.0158L137.679 61.8568V62.0272H148.156V65.0386H132.838Z" - fill="white" - /> - <rect - x="906.516" - y="40.8203" - width="7.56738" - height="24.4583" - rx="2" - fill="white" - /> - <rect - x="895.677" - y="46.0703" - width="7.56738" - height="19.2083" - rx="2" - fill="white" - /> - <rect - x="884.838" - y="51.4316" - width="7.56738" - height="13.8469" - rx="2" - fill="white" - /> - <rect - x="874" - y="55.7026" - width="7.56738" - height="9.57568" - rx="2" - fill="white" - /> - <path - d="M941.078 40.9033C934.711 40.9033 928.344 43.4627 923.774 47.6293C923.516 47.8578 923.508 48.2463 923.75 48.4977L925.836 50.6229C926.078 50.8742 926.485 50.8818 926.742 50.6457C928.563 49.0004 930.641 47.6902 932.938 46.7609C935.516 45.7098 938.258 45.1842 941.086 45.1842C943.914 45.1842 946.656 45.7174 949.235 46.7609C951.531 47.6979 953.61 49.0004 955.43 50.6457C955.688 50.8818 956.094 50.8742 956.336 50.6229L958.422 48.4977C958.664 48.2539 958.656 47.8654 958.399 47.6293C953.813 43.4627 947.445 40.9033 941.078 40.9033Z" - fill="white" - /> - <path - d="M929.922 54.7285L932.156 56.8842C932.399 57.1127 932.781 57.1279 933.031 56.907C935.242 54.9951 938.078 53.9439 941.07 53.9439C944.063 53.9439 946.899 54.9875 949.11 56.907C949.36 57.1279 949.742 57.1127 949.985 56.8842L952.219 54.7285C952.477 54.4771 952.469 54.0734 952.195 53.8373C949.266 51.2551 945.352 49.6783 941.063 49.6783C936.774 49.6783 932.86 51.2551 929.93 53.8373C929.672 54.0734 929.664 54.4771 929.922 54.7285ZM941.078 58.2857C939.25 58.2857 937.594 59.0322 936.438 60.2281C936.203 60.4719 936.211 60.8451 936.453 61.0812L940.625 65.0955C940.875 65.3393 941.281 65.3393 941.531 65.0955L945.703 61.0812C945.945 60.8451 945.953 60.4719 945.719 60.2281C944.563 59.0398 942.906 58.2857 941.078 58.2857Z" - fill="white" - /> - <rect - x="968.492" - y="38.5908" - width="51.4128" - height="29" - rx="8" - fill="#34C759" - /> - <path - d="M980.991 44.1363V61.5908H977.301V47.6391H977.198L973.201 50.1448V46.8721L977.522 44.1363H980.991ZM991.743 61.9743C990.277 61.9687 989.016 61.6079 987.959 60.892C986.908 60.176 986.098 59.1391 985.53 57.7812C984.968 56.4232 984.689 54.7897 984.695 52.8806C984.695 50.9772 984.976 49.355 985.539 48.0141C986.107 46.6732 986.916 45.6533 987.968 44.9545C989.024 44.2499 990.283 43.8976 991.743 43.8976C993.203 43.8976 994.459 44.2499 995.51 44.9545C996.567 45.659 997.379 46.6817 997.948 48.0226C998.516 49.3579 998.797 50.9772 998.791 52.8806C998.791 54.7954 998.507 56.4317 997.939 57.7897C997.377 59.1476 996.57 60.1846 995.519 60.9005C994.468 61.6164 993.209 61.9743 991.743 61.9743ZM991.743 58.9147C992.743 58.9147 993.541 58.4118 994.138 57.4062C994.735 56.4005 995.03 54.892 995.024 52.8806C995.024 51.5567 994.888 50.4545 994.615 49.5738C994.348 48.6931 993.968 48.0312 993.473 47.588C992.985 47.1448 992.408 46.9232 991.743 46.9232C990.749 46.9232 989.953 47.4204 989.357 48.4147C988.76 49.409 988.459 50.8976 988.453 52.8806C988.453 54.2215 988.587 55.3408 988.854 56.2385C989.127 57.1306 989.51 57.801 990.004 58.2499C990.499 58.6931 991.078 58.9147 991.743 58.9147ZM1008.03 61.9743C1006.56 61.9687 1005.3 61.6079 1004.24 60.892C1003.19 60.176 1002.38 59.1391 1001.81 57.7812C1001.25 56.4232 1000.97 54.7897 1000.98 52.8806C1000.98 50.9772 1001.26 49.355 1001.82 48.0141C1002.39 46.6732 1003.2 45.6533 1004.25 44.9545C1005.31 44.2499 1006.57 43.8976 1008.03 43.8976C1009.49 43.8976 1010.74 44.2499 1011.79 44.9545C1012.85 45.659 1013.66 46.6817 1014.23 48.0226C1014.8 49.3579 1015.08 50.9772 1015.07 52.8806C1015.07 54.7954 1014.79 56.4317 1014.22 57.7897C1013.66 59.1476 1012.85 60.1846 1011.8 60.9005C1010.75 61.6164 1009.49 61.9743 1008.03 61.9743ZM1008.03 58.9147C1009.03 58.9147 1009.82 58.4118 1010.42 57.4062C1011.02 56.4005 1011.31 54.892 1011.31 52.8806C1011.31 51.5567 1011.17 50.4545 1010.9 49.5738C1010.63 48.6931 1010.25 48.0312 1009.76 47.588C1009.27 47.1448 1008.69 46.9232 1008.03 46.9232C1007.03 46.9232 1006.24 47.4204 1005.64 48.4147C1005.04 49.409 1004.74 50.8976 1004.74 52.8806C1004.74 54.2215 1004.87 55.3408 1005.14 56.2385C1005.41 57.1306 1005.79 57.801 1006.29 58.2499C1006.78 58.6931 1007.36 58.9147 1008.03 58.9147Z" - fill="white" - /> - <path - d="M1022.16 48.2734V48.2734C1023.94 48.2734 1025.38 49.7175 1025.38 51.4988V54.6167C1025.38 56.398 1023.94 57.842 1022.16 57.842V57.842V48.2734Z" - fill="#34C759" - /> - <defs> - <filter - id="filter0_d_5_2" - x="954.752" - y="1684" - width="78.1084" - height="32.5605" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feColorMatrix - in="SourceAlpha" - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" - result="hardAlpha" - /> - <feOffset dy="1" /> - <feGaussianBlur stdDeviation="1" /> - <feComposite in2="hardAlpha" operator="out" /> - <feColorMatrix - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" - /> - <feBlend - mode="normal" - in2="BackgroundImageFix" - result="effect1_dropShadow_5_2" - /> - <feBlend - mode="normal" - in="SourceGraphic" - in2="effect1_dropShadow_5_2" - result="shape" - /> - </filter> - <filter - id="filter1_d_5_2" - x="960.448" - y="1512" - width="68.2227" - height="27.1934" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feColorMatrix - in="SourceAlpha" - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" - result="hardAlpha" - /> - <feOffset dy="1" /> - <feGaussianBlur stdDeviation="1" /> - <feComposite in2="hardAlpha" operator="out" /> - <feColorMatrix - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" - /> - <feBlend - mode="normal" - in2="BackgroundImageFix" - result="effect1_dropShadow_5_2" - /> - <feBlend - mode="normal" - in="SourceGraphic" - in2="effect1_dropShadow_5_2" - result="shape" - /> - </filter> - <filter - id="filter2_d_5_2" - x="954.375" - y="1341" - width="77.6875" - height="32.5605" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feColorMatrix - in="SourceAlpha" - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" - result="hardAlpha" - /> - <feOffset dy="1" /> - <feGaussianBlur stdDeviation="1" /> - <feComposite in2="hardAlpha" operator="out" /> - <feColorMatrix - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" - /> - <feBlend - mode="normal" - in2="BackgroundImageFix" - result="effect1_dropShadow_5_2" - /> - <feBlend - mode="normal" - in="SourceGraphic" - in2="effect1_dropShadow_5_2" - result="shape" - /> - </filter> - <filter - id="filter3_d_5_2" - x="945.502" - y="1170" - width="96.333" - height="26.353" - filterUnits="userSpaceOnUse" - colorInterpolationFilters="sRGB" - > - <feFlood floodOpacity="0" result="BackgroundImageFix" /> - <feColorMatrix - in="SourceAlpha" - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" - result="hardAlpha" - /> - <feOffset dy="1" /> - <feGaussianBlur stdDeviation="1" /> - <feComposite in2="hardAlpha" operator="out" /> - <feColorMatrix - type="matrix" - values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" - /> - <feBlend - mode="normal" - in2="BackgroundImageFix" - result="effect1_dropShadow_5_2" - /> - <feBlend - mode="normal" - in="SourceGraphic" - in2="effect1_dropShadow_5_2" - result="shape" - /> - </filter> - </defs> - </svg> - ); -} +export function TikTokLayout() {
+ return (
+ <svg
+ width="100%"
+ height="100%"
+ viewBox="0 0 1080 1920"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>TikTok Layout</title>
+ <path
+ d="M56.2488 1751.28H51.6355C51.458 1750.3 51.1278 1749.43 50.6447 1748.68C50.1617 1747.93 49.5702 1747.3 48.8704 1746.77C48.1704 1746.25 47.3868 1745.85 46.5194 1745.59C45.6617 1745.32 44.7498 1745.19 43.7838 1745.19C42.0391 1745.19 40.4766 1745.63 39.0965 1746.5C37.7264 1747.38 36.642 1748.67 35.8435 1750.36C35.055 1752.06 34.6605 1754.13 34.6605 1756.57C34.6605 1759.04 35.055 1761.12 35.8435 1762.81C36.642 1764.51 37.7313 1765.79 39.1114 1766.65C40.4914 1767.52 42.044 1767.95 43.7689 1767.95C44.7253 1767.95 45.6322 1767.83 46.4897 1767.57C47.3571 1767.3 48.141 1766.91 48.8408 1766.4C49.5408 1765.89 50.1321 1765.26 50.6151 1764.53C51.108 1763.78 51.4481 1762.92 51.6355 1761.95L56.2488 1761.97C56.0023 1763.46 55.5243 1764.83 54.8146 1766.08C54.1145 1767.32 53.2125 1768.39 52.1086 1769.3C51.0143 1770.2 49.7625 1770.89 48.3528 1771.38C46.9431 1771.88 45.4053 1772.12 43.7395 1772.12C41.1173 1772.12 38.7811 1771.5 36.7307 1770.26C34.6803 1769.01 33.0636 1767.22 31.8808 1764.9C30.7077 1762.57 30.1211 1759.79 30.1211 1756.57C30.1211 1753.34 30.7126 1750.57 31.8954 1748.25C33.0785 1745.92 34.6951 1744.14 36.7455 1742.9C38.796 1741.65 41.1272 1741.02 43.7395 1741.02C45.3462 1741.02 46.8447 1741.25 48.2347 1741.72C49.6342 1742.17 50.8912 1742.84 52.005 1743.73C53.1191 1744.6 54.0406 1745.68 54.7701 1746.95C55.4995 1748.21 55.9924 1749.66 56.2488 1751.28ZM61.3612 1771.71V1749H65.6344V1752.61H65.8709C66.285 1751.39 67.0144 1750.43 68.0596 1749.73C69.1143 1749.02 70.307 1748.66 71.6379 1748.66C71.9137 1748.66 72.239 1748.67 72.6138 1748.69C72.9982 1748.71 73.2987 1748.74 73.5155 1748.77V1753C73.3383 1752.95 73.0229 1752.89 72.5693 1752.83C72.1159 1752.76 71.6623 1752.73 71.209 1752.73C70.1641 1752.73 69.2325 1752.95 68.4143 1753.39C67.606 1753.83 66.9653 1754.43 66.4921 1755.21C66.019 1755.98 65.7825 1756.86 65.7825 1757.84V1771.71H61.3612ZM86.317 1772.17C84.0792 1772.17 82.1521 1771.69 80.5355 1770.73C78.9288 1769.77 77.6867 1768.41 76.8094 1766.67C75.9417 1764.91 75.5082 1762.86 75.5082 1760.5C75.5082 1758.18 75.9417 1756.13 76.8094 1754.36C77.6867 1752.58 78.909 1751.2 80.4764 1750.2C82.0535 1749.2 83.8971 1748.71 86.0066 1748.71C87.288 1748.71 88.5301 1748.92 89.7327 1749.34C90.9353 1749.77 92.0148 1750.43 92.9709 1751.34C93.927 1752.25 94.6812 1753.42 95.2332 1754.87C95.7851 1756.31 96.0612 1758.06 96.0612 1760.12V1761.69H78.007V1758.38H91.7288C91.7288 1757.21 91.4922 1756.18 91.0191 1755.29C90.546 1754.38 89.8805 1753.66 89.023 1753.14C88.1752 1752.62 87.1795 1752.36 86.036 1752.36C84.7941 1752.36 83.7097 1752.67 82.783 1753.28C81.8664 1753.88 81.1565 1754.67 80.6537 1755.64C80.1608 1756.61 79.9146 1757.66 79.9146 1758.79V1761.38C79.9146 1762.89 80.1805 1764.19 80.713 1765.25C81.2551 1766.31 82.0093 1767.13 82.9753 1767.69C83.9413 1768.24 85.07 1768.52 86.3613 1768.52C87.1993 1768.52 87.9633 1768.4 88.6532 1768.16C89.3434 1767.92 89.9396 1767.55 90.4424 1767.07C90.9452 1766.58 91.3295 1765.99 91.5958 1765.28L95.7802 1766.03C95.4453 1767.27 94.8438 1768.34 93.9765 1769.27C93.1187 1770.19 92.0392 1770.9 90.738 1771.41C89.4467 1771.92 87.973 1772.17 86.317 1772.17ZM107.576 1772.21C106.137 1772.21 104.836 1771.95 103.673 1771.41C102.509 1770.87 101.588 1770.09 100.907 1769.06C100.237 1768.04 99.9021 1766.78 99.9021 1765.29C99.9021 1764.01 100.148 1762.96 100.641 1762.13C101.134 1761.3 101.8 1760.65 102.638 1760.16C103.476 1759.68 104.412 1759.32 105.447 1759.07C106.482 1758.82 107.537 1758.64 108.611 1758.51C109.972 1758.35 111.076 1758.22 111.923 1758.12C112.771 1758.02 113.387 1757.84 113.772 1757.61C114.156 1757.37 114.348 1756.99 114.348 1756.45V1756.35C114.348 1755.06 113.984 1754.06 113.254 1753.35C112.535 1752.64 111.46 1752.29 110.031 1752.29C108.542 1752.29 107.369 1752.62 106.511 1753.28C105.664 1753.93 105.077 1754.65 104.752 1755.45L100.597 1754.5C101.09 1753.12 101.809 1752.01 102.756 1751.16C103.712 1750.31 104.811 1749.68 106.053 1749.3C107.295 1748.91 108.601 1748.71 109.972 1748.71C110.879 1748.71 111.84 1748.82 112.855 1749.03C113.88 1749.24 114.836 1749.62 115.724 1750.19C116.621 1750.75 117.355 1751.55 117.927 1752.59C118.498 1753.63 118.784 1754.98 118.784 1756.63V1771.71H114.467V1768.61H114.289C114.003 1769.18 113.574 1769.74 113.003 1770.29C112.431 1770.84 111.697 1771.3 110.8 1771.66C109.903 1772.03 108.828 1772.21 107.576 1772.21ZM108.537 1768.67C109.76 1768.67 110.804 1768.42 111.672 1767.94C112.549 1767.46 113.215 1766.83 113.668 1766.05C114.132 1765.26 114.363 1764.42 114.363 1763.52V1760.59C114.206 1760.75 113.9 1760.9 113.446 1761.04C113.003 1761.16 112.495 1761.28 111.923 1761.38C111.352 1761.46 110.795 1761.55 110.252 1761.63C109.71 1761.7 109.257 1761.76 108.892 1761.8C108.035 1761.91 107.251 1762.1 106.541 1762.35C105.841 1762.61 105.279 1762.98 104.856 1763.46C104.442 1763.93 104.234 1764.57 104.234 1765.35C104.234 1766.45 104.639 1767.27 105.447 1767.84C106.255 1768.39 107.285 1768.67 108.537 1768.67ZM135.275 1749V1752.55H122.869V1749H135.275ZM126.196 1743.56H130.617V1765.04C130.617 1765.9 130.745 1766.54 131.001 1766.98C131.258 1767.4 131.588 1767.69 131.992 1767.85C132.406 1768 132.855 1768.07 133.338 1768.07C133.693 1768.07 134.003 1768.05 134.269 1768C134.536 1767.95 134.742 1767.91 134.891 1767.88L135.689 1771.53C135.433 1771.63 135.068 1771.73 134.595 1771.83C134.121 1771.94 133.53 1772 132.82 1772.01C131.657 1772.02 130.573 1771.82 129.567 1771.38C128.562 1770.95 127.748 1770.28 127.128 1769.37C126.507 1768.47 126.196 1767.33 126.196 1765.96V1743.56ZM149.488 1772.17C147.359 1772.17 145.501 1771.68 143.914 1770.7C142.327 1769.73 141.094 1768.36 140.217 1766.61C139.34 1764.86 138.901 1762.81 138.901 1760.46C138.901 1758.11 139.34 1756.04 140.217 1754.28C141.094 1752.52 142.327 1751.15 143.914 1750.17C145.501 1749.2 147.359 1748.71 149.488 1748.71C151.618 1748.71 153.476 1749.2 155.063 1750.17C156.65 1751.15 157.882 1752.52 158.759 1754.28C159.637 1756.04 160.075 1758.11 160.075 1760.46C160.075 1762.81 159.637 1764.86 158.759 1766.61C157.882 1768.36 156.65 1769.73 155.063 1770.7C153.476 1771.68 151.618 1772.17 149.488 1772.17ZM149.503 1768.46C150.883 1768.46 152.027 1768.09 152.934 1767.36C153.841 1766.63 154.511 1765.66 154.944 1764.45C155.388 1763.24 155.61 1761.9 155.61 1760.44C155.61 1759 155.388 1757.67 154.944 1756.45C154.511 1755.23 153.841 1754.25 152.934 1753.51C152.027 1752.77 150.883 1752.41 149.503 1752.41C148.113 1752.41 146.96 1752.77 146.043 1753.51C145.136 1754.25 144.461 1755.23 144.017 1756.45C143.584 1757.67 143.367 1759 143.367 1760.44C143.367 1761.9 143.584 1763.24 144.017 1764.45C144.461 1765.66 145.136 1766.63 146.043 1767.36C146.96 1768.09 148.113 1768.46 149.503 1768.46ZM165.01 1771.71V1749H169.284V1752.61H169.52C169.934 1751.39 170.664 1750.43 171.709 1749.73C172.763 1749.02 173.956 1748.66 175.287 1748.66C175.563 1748.66 175.888 1748.67 176.263 1748.69C176.647 1748.71 176.948 1748.74 177.165 1748.77V1753C176.987 1752.95 176.672 1752.89 176.218 1752.83C175.765 1752.76 175.311 1752.73 174.858 1752.73C173.813 1752.73 172.882 1752.95 172.063 1753.39C171.255 1753.83 170.614 1754.43 170.141 1755.21C169.668 1755.98 169.432 1756.86 169.432 1757.84V1771.71H165.01Z"
+ fill="white"
+ />
+ <path
+ d="M43.4438 1811.44V1814.4H31.6738V1811.44H43.4438ZM35.1043 1806H38.5939V1827.64C38.5939 1828.63 38.7368 1829.37 39.0228 1829.86C39.3184 1830.34 39.6932 1830.67 40.1465 1830.84C40.6097 1830.99 41.0977 1831.07 41.6104 1831.07C41.9947 1831.07 42.3101 1831.05 42.5566 1831.01C42.803 1830.97 43.0003 1830.92 43.1481 1830.9L43.8578 1834.03C43.6212 1834.12 43.291 1834.21 42.8673 1834.3C42.4434 1834.39 41.906 1834.44 41.2554 1834.44C40.2699 1834.44 39.3036 1834.23 38.3573 1833.81C37.421 1833.38 36.6421 1832.74 36.0212 1831.87C35.4099 1831 35.1043 1829.91 35.1043 1828.59V1806ZM55.1775 1834.62C53.1271 1834.62 51.328 1834.13 49.7804 1833.16C48.2426 1832.18 47.04 1830.82 46.1726 1829.06C45.3149 1827.31 44.8863 1825.26 44.8863 1822.91C44.8863 1820.55 45.3149 1818.48 46.1726 1816.72C47.04 1814.96 48.2426 1813.59 49.7804 1812.61C51.328 1811.64 53.1271 1811.15 55.1775 1811.15C57.2279 1811.15 59.022 1811.64 60.5598 1812.61C62.1075 1813.59 63.31 1814.96 64.1675 1816.72C65.0352 1818.48 65.4687 1820.55 65.4687 1822.91C65.4687 1825.26 65.0352 1827.31 64.1675 1829.06C63.31 1830.82 62.1075 1832.18 60.5598 1833.16C59.022 1834.13 57.2279 1834.62 55.1775 1834.62ZM55.1775 1831.49C56.735 1831.49 58.0165 1831.09 59.022 1830.29C60.0274 1829.49 60.7716 1828.44 61.2547 1827.14C61.7377 1825.84 61.9792 1824.43 61.9792 1822.91C61.9792 1821.39 61.7377 1819.98 61.2547 1818.67C60.7716 1817.36 60.0274 1816.3 59.022 1815.49C58.0165 1814.68 56.735 1814.28 55.1775 1814.28C53.62 1814.28 52.3385 1814.68 51.333 1815.49C50.3277 1816.3 49.5834 1817.36 49.1004 1818.67C48.6173 1819.98 48.3758 1821.39 48.3758 1822.91C48.3758 1824.43 48.6173 1825.84 49.1004 1827.14C49.5834 1828.44 50.3277 1829.49 51.333 1830.29C52.3385 1831.09 53.62 1831.49 55.1775 1831.49ZM71.7869 1803.87V1834.15H68.2973V1803.87H71.7869ZM84.2561 1834.62C82.3636 1834.62 80.6926 1834.14 79.2436 1833.19C77.7945 1832.22 76.6609 1830.86 75.8427 1829.11C75.0245 1827.34 74.6154 1825.26 74.6154 1822.85C74.6154 1820.47 75.0245 1818.4 75.8427 1816.65C76.6609 1814.89 77.7995 1813.54 79.2584 1812.58C80.7173 1811.62 82.4029 1811.15 84.3154 1811.15C85.7941 1811.15 86.9621 1811.39 87.8198 1811.89C88.6872 1812.37 89.3477 1812.92 89.801 1813.54C90.2645 1814.15 90.6242 1814.66 90.8805 1815.05H91.1761V1803.87H94.6657V1834.15H91.2945V1830.66H90.8805C90.6242 1831.07 90.2596 1831.59 89.7862 1832.22C89.3131 1832.85 88.638 1833.4 87.7605 1833.89C86.8832 1834.38 85.7152 1834.62 84.2561 1834.62ZM84.7294 1831.49C86.129 1831.49 87.3121 1831.12 88.2781 1830.39C89.2441 1829.65 89.9785 1828.63 90.4813 1827.33C90.9841 1826.02 91.2355 1824.51 91.2355 1822.79C91.2355 1821.1 90.989 1819.62 90.4961 1818.34C90.0032 1817.06 89.2738 1816.07 88.3078 1815.36C87.3418 1814.64 86.1488 1814.28 84.7294 1814.28C83.2508 1814.28 82.0185 1814.66 81.0327 1815.42C80.0568 1816.17 79.3224 1817.19 78.8295 1818.48C78.3465 1819.76 78.105 1821.2 78.105 1822.79C78.105 1824.41 78.3515 1825.88 78.8444 1827.2C79.3471 1828.51 80.0865 1829.56 81.0624 1830.33C82.0482 1831.1 83.2705 1831.49 84.7294 1831.49ZM108.256 1834.15V1811.44H111.627V1814.99H111.923C112.396 1813.78 113.16 1812.84 114.215 1812.17C115.27 1811.49 116.536 1811.15 118.015 1811.15C119.513 1811.15 120.76 1811.49 121.756 1812.17C122.761 1812.84 123.545 1813.78 124.107 1814.99H124.343C124.925 1813.82 125.797 1812.89 126.961 1812.19C128.124 1811.5 129.519 1811.15 131.145 1811.15C133.176 1811.15 134.837 1811.78 136.128 1813.05C137.419 1814.32 138.065 1816.28 138.065 1818.95V1834.15H134.576V1818.95C134.576 1817.28 134.117 1816.08 133.2 1815.36C132.284 1814.64 131.204 1814.28 129.962 1814.28C128.365 1814.28 127.128 1814.76 126.251 1815.73C125.373 1816.68 124.935 1817.9 124.935 1819.36V1834.15H121.386V1818.6C121.386 1817.31 120.967 1816.27 120.129 1815.48C119.291 1814.68 118.212 1814.28 116.891 1814.28C115.984 1814.28 115.136 1814.52 114.348 1815C113.569 1815.49 112.938 1816.16 112.455 1817.02C111.982 1817.86 111.745 1818.84 111.745 1819.96V1834.15H108.256ZM143.382 1842.66C142.79 1842.66 142.263 1842.61 141.799 1842.51C141.336 1842.43 141.016 1842.34 140.838 1842.25L141.725 1839.17C142.573 1839.39 143.322 1839.47 143.973 1839.41C144.624 1839.35 145.2 1839.06 145.703 1838.54C146.216 1838.03 146.684 1837.19 147.108 1836.04L147.758 1834.26L139.36 1811.44H143.145L149.414 1829.53H149.651L155.92 1811.44H159.706L150.065 1837.46C149.631 1838.63 149.094 1839.6 148.453 1840.37C147.812 1841.15 147.068 1841.73 146.221 1842.1C145.383 1842.47 144.436 1842.66 143.382 1842.66ZM177.901 1816.53L174.766 1817.41C174.569 1816.89 174.278 1816.38 173.894 1815.89C173.519 1815.39 173.007 1814.98 172.356 1814.65C171.705 1814.33 170.873 1814.16 169.857 1814.16C168.467 1814.16 167.309 1814.48 166.382 1815.12C165.466 1815.75 165.007 1816.56 165.007 1817.53C165.007 1818.4 165.323 1819.08 165.954 1819.59C166.584 1820.09 167.57 1820.51 168.911 1820.84L172.282 1821.67C174.313 1822.16 175.826 1822.92 176.821 1823.93C177.817 1824.94 178.315 1826.23 178.315 1827.82C178.315 1829.12 177.941 1830.28 177.191 1831.31C176.452 1832.33 175.417 1833.14 174.086 1833.73C172.755 1834.32 171.208 1834.62 169.443 1834.62C167.127 1834.62 165.209 1834.12 163.691 1833.11C162.173 1832.11 161.212 1830.64 160.808 1828.71L164.12 1827.88C164.436 1829.1 165.032 1830.02 165.909 1830.63C166.796 1831.24 167.955 1831.55 169.384 1831.55C171.011 1831.55 172.302 1831.2 173.258 1830.51C174.224 1829.81 174.707 1828.97 174.707 1828C174.707 1827.21 174.431 1826.55 173.879 1826.02C173.327 1825.48 172.479 1825.07 171.336 1824.8L167.55 1823.92C165.471 1823.43 163.943 1822.66 162.967 1821.63C162.001 1820.58 161.518 1819.28 161.518 1817.71C161.518 1816.43 161.877 1815.3 162.597 1814.31C163.326 1813.32 164.317 1812.55 165.569 1811.99C166.831 1811.43 168.26 1811.15 169.857 1811.15C172.105 1811.15 173.869 1811.64 175.151 1812.62C176.442 1813.61 177.359 1814.91 177.901 1816.53ZM190.592 1834.62C188.404 1834.62 186.516 1834.14 184.929 1833.17C183.352 1832.2 182.134 1830.84 181.277 1829.09C180.429 1827.34 180.005 1825.3 180.005 1822.97C180.005 1820.65 180.429 1818.6 181.277 1816.82C182.134 1815.04 183.327 1813.65 184.855 1812.66C186.393 1811.65 188.187 1811.15 190.237 1811.15C191.42 1811.15 192.588 1811.34 193.742 1811.74C194.895 1812.13 195.945 1812.77 196.891 1813.66C197.838 1814.54 198.591 1815.7 199.153 1817.15C199.715 1818.6 199.996 1820.38 199.996 1822.5V1823.98H182.489V1820.96H196.448C196.448 1819.68 196.191 1818.54 195.679 1817.53C195.176 1816.53 194.456 1815.73 193.52 1815.15C192.593 1814.57 191.499 1814.28 190.237 1814.28C188.847 1814.28 187.645 1814.62 186.629 1815.31C185.624 1816 184.85 1816.88 184.308 1817.98C183.766 1819.07 183.495 1820.24 183.495 1821.49V1823.5C183.495 1825.22 183.79 1826.67 184.382 1827.86C184.983 1829.05 185.816 1829.95 186.881 1830.57C187.945 1831.18 189.182 1831.49 190.592 1831.49C191.509 1831.49 192.337 1831.36 193.076 1831.1C193.825 1830.84 194.471 1830.44 195.013 1829.92C195.555 1829.39 195.974 1828.73 196.27 1827.94L199.641 1828.88C199.287 1830.03 198.69 1831.03 197.852 1831.9C197.014 1832.76 195.979 1833.43 194.747 1833.91C193.515 1834.38 192.13 1834.62 190.592 1834.62ZM206.296 1803.87V1834.15H202.806V1803.87H206.296ZM220.303 1811.44V1814.4H208.06V1811.44H220.303ZM211.727 1834.15V1808.31C211.727 1807.01 212.032 1805.92 212.643 1805.06C213.255 1804.19 214.048 1803.54 215.024 1803.1C216 1802.67 217.03 1802.45 218.114 1802.45C218.972 1802.45 219.672 1802.52 220.214 1802.66C220.756 1802.8 221.161 1802.93 221.427 1803.04L220.421 1806.06C220.244 1806 219.997 1805.93 219.682 1805.84C219.376 1805.75 218.972 1805.71 218.469 1805.71C217.316 1805.71 216.483 1806 215.97 1806.58C215.468 1807.16 215.216 1808.01 215.216 1809.14V1834.15H211.727ZM241.134 1834.62C239.084 1834.62 237.285 1834.13 235.737 1833.16C234.2 1832.18 232.997 1830.82 232.129 1829.06C231.272 1827.31 230.843 1825.26 230.843 1822.91C230.843 1820.55 231.272 1818.48 232.129 1816.72C232.997 1814.96 234.2 1813.59 235.737 1812.61C237.285 1811.64 239.084 1811.15 241.134 1811.15C243.185 1811.15 244.979 1811.64 246.517 1812.61C248.064 1813.59 249.267 1814.96 250.125 1816.72C250.992 1818.48 251.426 1820.55 251.426 1822.91C251.426 1825.26 250.992 1827.31 250.125 1829.06C249.267 1830.82 248.064 1832.18 246.517 1833.16C244.979 1834.13 243.185 1834.62 241.134 1834.62ZM241.134 1831.49C242.692 1831.49 243.973 1831.09 244.979 1830.29C245.984 1829.49 246.729 1828.44 247.212 1827.14C247.695 1825.84 247.936 1824.43 247.936 1822.91C247.936 1821.39 247.695 1819.98 247.212 1818.67C246.729 1817.36 245.984 1816.3 244.979 1815.49C243.973 1814.68 242.692 1814.28 241.134 1814.28C239.577 1814.28 238.295 1814.68 237.29 1815.49C236.285 1816.3 235.54 1817.36 235.057 1818.67C234.574 1819.98 234.333 1821.39 234.333 1822.91C234.333 1824.43 234.574 1825.84 235.057 1827.14C235.54 1828.44 236.285 1829.49 237.29 1830.29C238.295 1831.09 239.577 1831.49 241.134 1831.49ZM257.744 1820.49V1834.15H254.254V1811.44H257.626V1814.99H257.921C258.454 1813.84 259.262 1812.91 260.345 1812.21C261.43 1811.5 262.83 1811.15 264.545 1811.15C266.083 1811.15 267.429 1811.46 268.582 1812.09C269.735 1812.71 270.632 1813.66 271.273 1814.93C271.915 1816.19 272.236 1817.79 272.236 1819.72V1834.15H268.746V1819.96C268.746 1818.17 268.282 1816.78 267.356 1815.79C266.43 1814.78 265.157 1814.28 263.541 1814.28C262.427 1814.28 261.43 1814.52 260.553 1815C259.686 1815.49 259.001 1816.19 258.498 1817.12C257.995 1818.05 257.744 1819.17 257.744 1820.49ZM285.635 1834.62C283.447 1834.62 281.56 1834.14 279.972 1833.17C278.395 1832.2 277.177 1830.84 276.319 1829.09C275.473 1827.34 275.049 1825.3 275.049 1822.97C275.049 1820.65 275.473 1818.6 276.319 1816.82C277.177 1815.04 278.369 1813.65 279.897 1812.66C281.435 1811.65 283.231 1811.15 285.281 1811.15C286.463 1811.15 287.631 1811.34 288.784 1811.74C289.937 1812.13 290.988 1812.77 291.933 1813.66C292.88 1814.54 293.635 1815.7 294.197 1817.15C294.759 1818.6 295.04 1820.38 295.04 1822.5V1823.98H277.531V1820.96H291.491C291.491 1819.68 291.236 1818.54 290.723 1817.53C290.218 1816.53 289.5 1815.73 288.563 1815.15C287.637 1814.57 286.541 1814.28 285.281 1814.28C283.892 1814.28 282.687 1814.62 281.672 1815.31C280.667 1816 279.894 1816.88 279.351 1817.98C278.809 1819.07 278.539 1820.24 278.539 1821.49V1823.5C278.539 1825.22 278.833 1826.67 279.426 1827.86C280.027 1829.05 280.86 1829.95 281.924 1830.57C282.989 1831.18 284.225 1831.49 285.635 1831.49C286.551 1831.49 287.379 1831.36 288.121 1831.1C288.868 1830.84 289.516 1830.44 290.057 1829.92C290.598 1829.39 291.017 1828.73 291.314 1827.94L294.684 1828.88C294.33 1830.03 293.734 1831.03 292.896 1831.9C292.058 1832.76 291.022 1833.43 289.791 1833.91C288.558 1834.38 287.173 1834.62 285.635 1834.62ZM316.586 1834.62C314.397 1834.62 312.508 1834.14 310.92 1833.17C309.343 1832.2 308.128 1830.84 307.269 1829.09C306.421 1827.34 305.997 1825.3 305.997 1822.97C305.997 1820.65 306.421 1818.6 307.269 1816.82C308.128 1815.04 309.32 1813.65 310.847 1812.66C312.386 1811.65 314.179 1811.15 316.229 1811.15C317.413 1811.15 318.582 1811.34 319.735 1811.74C320.888 1812.13 321.936 1812.77 322.884 1813.66C323.831 1814.54 324.583 1815.7 325.145 1817.15C325.707 1818.6 325.988 1820.38 325.988 1822.5V1823.98H308.482V1820.96H322.441C322.441 1819.68 322.184 1818.54 321.671 1817.53C321.169 1816.53 320.448 1815.73 319.514 1815.15C318.584 1814.57 317.491 1814.28 316.229 1814.28C314.84 1814.28 313.637 1814.62 312.622 1815.31C311.615 1816 310.842 1816.88 310.301 1817.98C309.757 1819.07 309.486 1820.24 309.486 1821.49V1823.5C309.486 1825.22 309.783 1826.67 310.374 1827.86C310.975 1829.05 311.808 1829.95 312.872 1830.57C313.937 1831.18 315.175 1831.49 316.586 1831.49C317.502 1831.49 318.329 1831.36 319.069 1831.1C319.818 1830.84 320.463 1830.44 321.005 1829.92C321.549 1829.39 321.968 1828.73 322.262 1827.94L325.634 1828.88C325.278 1830.03 324.682 1831.03 323.844 1831.9C323.006 1832.76 321.973 1833.43 320.739 1833.91C319.508 1834.38 318.121 1834.62 316.586 1834.62ZM328.799 1842.66V1811.44H332.169V1815.05H332.583C332.84 1814.66 333.194 1814.15 333.65 1813.54C334.113 1812.92 334.771 1812.37 335.63 1811.89C336.497 1811.39 337.671 1811.15 339.149 1811.15C341.061 1811.15 342.748 1811.62 344.205 1812.58C345.665 1813.54 346.802 1814.89 347.622 1816.65C348.439 1818.4 348.848 1820.47 348.848 1822.85C348.848 1825.26 348.439 1827.34 347.622 1829.11C346.802 1830.86 345.67 1832.22 344.221 1833.19C342.771 1834.14 341.1 1834.62 339.209 1834.62C337.749 1834.62 336.58 1834.38 335.703 1833.89C334.826 1833.4 334.152 1832.85 333.678 1832.22C333.205 1831.59 332.84 1831.07 332.583 1830.66H332.289V1842.66H328.799ZM332.229 1822.79C332.229 1824.51 332.481 1826.02 332.984 1827.33C333.486 1828.63 334.22 1829.65 335.185 1830.39C336.153 1831.12 337.335 1831.49 338.735 1831.49C340.195 1831.49 341.413 1831.1 342.389 1830.33C343.372 1829.56 344.112 1828.51 344.606 1827.2C345.108 1825.88 345.361 1824.41 345.361 1822.79C345.361 1821.2 345.113 1819.76 344.619 1818.48C344.138 1817.19 343.404 1816.17 342.417 1815.42C341.441 1814.66 340.213 1814.28 338.735 1814.28C337.317 1814.28 336.122 1814.64 335.157 1815.36C334.191 1816.07 333.462 1817.06 332.968 1818.34C332.476 1819.62 332.229 1821.1 332.229 1822.79ZM351.674 1834.15V1811.44H355.164V1834.15H351.674ZM353.449 1807.66C352.767 1807.66 352.182 1807.43 351.69 1806.96C351.206 1806.5 350.964 1805.94 350.964 1805.29C350.964 1804.64 351.206 1804.09 351.69 1803.62C352.182 1803.16 352.767 1802.93 353.449 1802.93C354.128 1802.93 354.711 1803.16 355.193 1803.62C355.687 1804.09 355.932 1804.64 355.932 1805.29C355.932 1805.94 355.687 1806.5 355.193 1806.96C354.711 1807.43 354.128 1807.66 353.449 1807.66ZM375.145 1816.53L372.009 1817.41C371.812 1816.89 371.523 1816.38 371.138 1815.89C370.763 1815.39 370.25 1814.98 369.599 1814.65C368.949 1814.33 368.116 1814.16 367.101 1814.16C365.711 1814.16 364.553 1814.48 363.624 1815.12C362.708 1815.75 362.25 1816.56 362.25 1817.53C362.25 1818.4 362.565 1819.08 363.198 1819.59C363.827 1820.09 364.814 1820.51 366.154 1820.84L369.527 1821.67C371.557 1822.16 373.069 1822.92 374.065 1823.93C375.059 1824.94 375.559 1826.23 375.559 1827.82C375.559 1829.12 375.184 1830.28 374.435 1831.31C373.696 1832.33 372.66 1833.14 371.33 1833.73C369.998 1834.32 368.452 1834.62 366.687 1834.62C364.369 1834.62 362.453 1834.12 360.933 1833.11C359.416 1832.11 358.456 1830.64 358.05 1828.71L361.363 1827.88C361.678 1829.1 362.276 1830.02 363.153 1830.63C364.041 1831.24 365.199 1831.55 366.628 1831.55C368.254 1831.55 369.545 1831.2 370.503 1830.51C371.468 1829.81 371.949 1828.97 371.949 1828C371.949 1827.21 371.674 1826.55 371.122 1826.02C370.57 1825.48 369.722 1825.07 368.579 1824.8L364.793 1823.92C362.714 1823.43 361.186 1822.66 360.21 1821.63C359.245 1820.58 358.76 1819.28 358.76 1817.71C358.76 1816.43 359.12 1815.3 359.84 1814.31C360.569 1813.32 361.561 1812.55 362.812 1811.99C364.075 1811.43 365.503 1811.15 367.101 1811.15C369.347 1811.15 371.111 1811.64 372.394 1812.62C373.685 1813.61 374.601 1814.91 375.145 1816.53ZM387.541 1834.62C385.49 1834.62 383.689 1834.13 382.143 1833.16C380.605 1832.18 379.403 1830.82 378.534 1829.06C377.677 1827.31 377.248 1825.26 377.248 1822.91C377.248 1820.55 377.677 1818.48 378.534 1816.72C379.403 1814.96 380.605 1813.59 382.143 1812.61C383.689 1811.64 385.49 1811.15 387.541 1811.15C389.591 1811.15 391.384 1811.64 392.922 1812.61C394.471 1813.59 395.673 1814.96 396.529 1816.72C397.398 1818.48 397.83 1820.55 397.83 1822.91C397.83 1825.26 397.398 1827.31 396.529 1829.06C395.673 1830.82 394.471 1832.18 392.922 1833.16C391.384 1834.13 389.591 1834.62 387.541 1834.62ZM387.541 1831.49C389.097 1831.49 390.38 1831.09 391.384 1830.29C392.389 1829.49 393.133 1828.44 393.617 1827.14C394.099 1825.84 394.341 1824.43 394.341 1822.91C394.341 1821.39 394.099 1819.98 393.617 1818.67C393.133 1817.36 392.389 1816.3 391.384 1815.49C390.38 1814.68 389.097 1814.28 387.541 1814.28C385.982 1814.28 384.701 1814.68 383.694 1815.49C382.69 1816.3 381.945 1817.36 381.461 1818.67C380.98 1819.98 380.738 1821.39 380.738 1822.91C380.738 1824.43 380.98 1825.84 381.461 1827.14C381.945 1828.44 382.69 1829.49 383.694 1830.29C384.701 1831.09 385.982 1831.49 387.541 1831.49ZM409.234 1834.62C407.342 1834.62 405.672 1834.14 404.222 1833.19C402.775 1832.22 401.64 1830.86 400.823 1829.11C400.003 1827.34 399.595 1825.26 399.595 1822.85C399.595 1820.47 400.003 1818.4 400.823 1816.65C401.64 1814.89 402.778 1813.54 404.238 1812.58C405.698 1811.62 407.381 1811.15 409.294 1811.15C410.772 1811.15 411.941 1811.39 412.8 1811.89C413.666 1812.37 414.327 1812.92 414.78 1813.54C415.243 1814.15 415.602 1814.66 415.86 1815.05H416.157V1803.87H419.644V1834.15H416.274V1830.66H415.86C415.602 1831.07 415.238 1831.59 414.764 1832.22C414.293 1832.85 413.617 1833.4 412.74 1833.89C411.863 1834.38 410.694 1834.62 409.234 1834.62ZM409.708 1831.49C411.108 1831.49 412.292 1831.12 413.258 1830.39C414.223 1829.65 414.957 1828.63 415.462 1827.33C415.964 1826.02 416.214 1824.51 416.214 1822.79C416.214 1821.1 415.969 1819.62 415.475 1818.34C414.983 1817.06 414.254 1816.07 413.286 1815.36C412.321 1814.64 411.129 1814.28 409.708 1814.28C408.23 1814.28 406.999 1814.66 406.012 1815.42C405.037 1816.17 404.303 1817.19 403.808 1818.48C403.327 1819.76 403.085 1821.2 403.085 1822.79C403.085 1824.41 403.332 1825.88 403.824 1827.2C404.326 1828.51 405.065 1829.56 406.041 1830.33C407.027 1831.1 408.251 1831.49 409.708 1831.49ZM433.546 1834.62C431.357 1834.62 429.468 1834.14 427.883 1833.17C426.303 1832.2 425.088 1830.84 424.229 1829.09C423.381 1827.34 422.957 1825.3 422.957 1822.97C422.957 1820.65 423.381 1818.6 424.229 1816.82C425.088 1815.04 426.28 1813.65 427.808 1812.66C429.346 1811.65 431.139 1811.15 433.189 1811.15C434.374 1811.15 435.542 1811.34 436.695 1811.74C437.848 1812.13 438.897 1812.77 439.844 1813.66C440.791 1814.54 441.546 1815.7 442.105 1817.15C442.667 1818.6 442.948 1820.38 442.948 1822.5V1823.98H425.442V1820.96H439.401C439.401 1819.68 439.144 1818.54 438.631 1817.53C438.129 1816.53 437.408 1815.73 436.474 1815.15C435.547 1814.57 434.452 1814.28 433.189 1814.28C431.8 1814.28 430.597 1814.62 429.582 1815.31C428.578 1816 427.802 1816.88 427.261 1817.98C426.72 1819.07 426.447 1820.24 426.447 1821.49V1823.5C426.447 1825.22 426.743 1826.67 427.334 1827.86C427.935 1829.05 428.768 1829.95 429.835 1830.57C430.899 1831.18 432.135 1831.49 433.546 1831.49C434.462 1831.49 435.29 1831.36 436.029 1831.1C436.778 1830.84 437.424 1830.44 437.967 1829.92C438.509 1829.39 438.928 1828.73 439.222 1827.94L442.595 1828.88C442.241 1830.03 441.642 1831.03 440.804 1831.9C439.966 1832.76 438.933 1833.43 437.699 1833.91C436.468 1834.38 435.084 1834.62 433.546 1834.62Z"
+ fill="#E6E6E6"
+ />
+ <path
+ d="M42.9097 1883.58L47.2568 1857.09H50.362L46.0149 1883.58H42.9097ZM30.916 1876.49L31.4206 1873.39H51.5005L50.9959 1876.49H30.916ZM33.5942 1883.58L37.9415 1857.09H41.0467L36.6993 1883.58H33.5942ZM32.4556 1867.28L32.9602 1864.18H53.0401L52.5358 1867.28H32.4556ZM66.1656 1883.94C64.3112 1883.94 62.6724 1883.62 61.2492 1882.98C59.8348 1882.35 58.7264 1881.47 57.9241 1880.35C57.122 1879.23 56.7251 1877.95 56.7337 1876.52C56.7251 1875.4 56.9539 1874.37 57.4195 1873.43C57.8939 1872.48 58.5364 1871.69 59.3473 1871.06C60.1582 1870.42 61.0639 1870.02 62.0642 1869.84V1869.69C60.7446 1869.37 59.688 1868.66 58.8945 1867.57C58.101 1866.47 57.7086 1865.21 57.7172 1863.79C57.7086 1862.44 58.0664 1861.23 58.7909 1860.17C59.524 1859.1 60.5291 1858.26 61.8056 1857.65C63.082 1857.04 64.5355 1856.73 66.1656 1856.73C67.7786 1856.73 69.219 1857.04 70.4869 1857.66C71.7637 1858.27 72.7685 1859.11 73.5016 1860.18C74.2347 1861.24 74.6058 1862.44 74.6144 1863.79C74.6058 1865.21 74.2003 1866.47 73.398 1867.57C72.5959 1868.66 71.5524 1869.37 70.267 1869.69V1869.84C71.2591 1870.02 72.1517 1870.42 72.9452 1871.06C73.7475 1871.69 74.3856 1872.48 74.86 1873.43C75.343 1874.37 75.589 1875.4 75.5975 1876.52C75.589 1877.95 75.1835 1879.23 74.3814 1880.35C73.5791 1881.47 72.4666 1882.35 71.0433 1882.98C69.6289 1883.62 68.0029 1883.94 66.1656 1883.94ZM66.1656 1880.67C67.2613 1880.67 68.2098 1880.49 69.0122 1880.13C69.8142 1879.76 70.4354 1879.24 70.8752 1878.59C71.315 1877.92 71.5394 1877.15 71.5479 1876.26C71.5394 1875.34 71.2978 1874.52 70.8234 1873.81C70.3576 1873.11 69.7237 1872.55 68.9216 1872.15C68.1193 1871.74 67.2009 1871.54 66.1656 1871.54C65.1221 1871.54 64.1948 1871.74 63.3839 1872.15C62.5733 1872.55 61.9349 1873.11 61.4691 1873.81C61.0035 1874.52 60.7747 1875.34 60.7833 1876.26C60.7747 1877.15 60.9861 1877.92 61.4175 1878.59C61.8574 1879.24 62.4827 1879.76 63.2934 1880.13C64.1043 1880.49 65.0617 1880.67 66.1656 1880.67ZM66.1656 1868.34C67.0455 1868.34 67.826 1868.17 68.5075 1867.81C69.1889 1867.46 69.7237 1866.97 70.1119 1866.34C70.5085 1865.71 70.7113 1864.97 70.7199 1864.13C70.7113 1863.3 70.513 1862.57 70.1247 1861.96C69.7453 1861.33 69.2149 1860.86 68.5333 1860.52C67.852 1860.17 67.0627 1860 66.1656 1860C65.2514 1860 64.4493 1860.17 63.7592 1860.52C63.0779 1860.86 62.5472 1861.33 62.1678 1861.96C61.7884 1862.57 61.6028 1863.3 61.6114 1864.13C61.6028 1864.97 61.7926 1865.71 62.1808 1866.34C62.5689 1866.97 63.1036 1867.46 63.7852 1867.81C64.4751 1868.17 65.2686 1868.34 66.1656 1868.34ZM85.246 1871.78V1883.58H81.3775V1857.09H85.1942V1866.95H85.4401C85.9057 1865.88 86.6174 1865.03 87.5749 1864.4C88.5323 1863.77 89.783 1863.45 91.3268 1863.45C92.6896 1863.45 93.88 1863.74 94.8978 1864.29C95.9242 1864.86 96.7177 1865.69 97.2785 1866.81C97.8476 1867.91 98.1323 1869.29 98.1323 1870.94C98.1323 1872.6 98.1323 1883.58 98.1323 1883.58H94.2638V1871.41C94.2638 1869.95 93.8886 1868.82 93.1383 1868.02C92.3878 1867.21 91.3442 1866.81 90.0071 1866.81C89.0929 1866.81 88.2734 1867 87.5488 1867.39C86.8329 1867.78 86.2679 1868.34 85.8539 1869.09C85.4487 1869.84 85.246 1870.73 85.246 1871.78ZM112.759 1883.98C110.896 1883.98 109.27 1883.55 107.881 1882.7C106.492 1881.85 105.414 1880.65 104.646 1879.12C103.879 1877.58 103.495 1875.79 103.495 1873.74C103.495 1871.68 103.879 1869.87 104.646 1868.33C105.414 1866.79 106.492 1865.59 107.881 1864.73C109.27 1863.88 110.896 1863.45 112.759 1863.45C114.622 1863.45 116.247 1863.88 117.636 1864.73C119.025 1865.59 120.103 1866.79 120.871 1868.33C121.638 1869.87 122.022 1871.68 122.022 1873.74C122.022 1875.79 121.638 1877.58 120.871 1879.12C120.103 1880.65 119.025 1881.85 117.636 1882.7C116.247 1883.55 114.622 1883.98 112.759 1883.98ZM112.771 1880.73C113.979 1880.73 114.98 1880.42 115.773 1879.78C116.567 1879.14 117.153 1878.29 117.533 1877.23C117.921 1876.17 118.115 1875 118.115 1873.73C118.115 1872.46 117.921 1871.29 117.533 1870.23C117.153 1869.16 116.567 1868.31 115.773 1867.66C114.98 1867.01 113.979 1866.69 112.771 1866.69C111.555 1866.69 110.546 1867.01 109.744 1867.66C108.951 1868.31 108.36 1869.16 107.972 1870.23C107.592 1871.29 107.402 1872.46 107.402 1873.73C107.402 1875 107.592 1876.17 107.972 1877.23C108.36 1878.29 108.951 1879.14 109.744 1879.78C110.546 1880.42 111.555 1880.73 112.771 1880.73ZM140.022 1875.34V1863.71H143.903V1883.58H140.1V1880.14H139.893C139.436 1881.2 138.703 1882.08 137.693 1882.79C136.693 1883.49 135.446 1883.84 133.954 1883.84C132.677 1883.84 131.548 1883.56 130.564 1883C129.59 1882.43 128.822 1881.59 128.261 1880.48C127.709 1879.36 127.433 1877.99 127.433 1876.35V1863.71H131.302V1875.88C131.302 1877.24 131.677 1878.32 132.427 1879.12C133.178 1879.92 134.153 1880.32 135.351 1880.32C136.076 1880.32 136.796 1880.14 137.512 1879.78C138.237 1879.42 138.836 1878.87 139.31 1878.14C139.793 1877.4 140.031 1876.47 140.022 1875.34ZM150.195 1883.58V1863.71H153.934V1866.87H154.141C154.503 1865.8 155.141 1864.96 156.055 1864.35C156.978 1863.73 158.022 1863.42 159.186 1863.42C159.428 1863.42 159.713 1863.43 160.04 1863.44C160.377 1863.46 160.64 1863.48 160.83 1863.51V1867.21C160.674 1867.16 160.398 1867.11 160.001 1867.06C159.605 1867 159.208 1866.97 158.811 1866.97C157.897 1866.97 157.082 1867.17 156.366 1867.55C155.659 1867.93 155.098 1868.46 154.684 1869.15C154.27 1869.82 154.063 1870.59 154.063 1871.45V1883.58H150.195ZM180.162 1868.56L176.656 1869.19C176.509 1868.74 176.276 1868.31 175.957 1867.9C175.647 1867.5 175.224 1867.17 174.689 1866.91C174.154 1866.65 173.486 1866.52 172.684 1866.52C171.588 1866.52 170.674 1866.77 169.941 1867.26C169.208 1867.74 168.841 1868.37 168.841 1869.13C168.841 1869.8 169.087 1870.33 169.579 1870.74C170.07 1871.14 170.864 1871.47 171.959 1871.73L175.116 1872.46C176.945 1872.88 178.308 1873.53 179.205 1874.41C180.102 1875.29 180.55 1876.43 180.55 1877.84C180.55 1879.03 180.205 1880.09 179.515 1881.02C178.834 1881.94 177.881 1882.67 176.656 1883.19C175.44 1883.72 174.029 1883.98 172.425 1883.98C170.2 1883.98 168.384 1883.51 166.978 1882.56C165.572 1881.6 164.71 1880.24 164.39 1878.48L168.13 1877.91C168.362 1878.89 168.841 1879.63 169.566 1880.13C170.29 1880.62 171.235 1880.86 172.399 1880.86C173.667 1880.86 174.681 1880.6 175.44 1880.08C176.199 1879.54 176.578 1878.89 176.578 1878.12C176.578 1877.5 176.345 1876.98 175.88 1876.56C175.423 1876.13 174.719 1875.81 173.771 1875.6L170.407 1874.86C168.552 1874.44 167.181 1873.77 166.293 1872.85C165.413 1871.92 164.973 1870.75 164.973 1869.34C164.973 1868.17 165.3 1867.14 165.956 1866.26C166.612 1865.38 167.517 1864.7 168.673 1864.21C169.829 1863.7 171.153 1863.45 172.645 1863.45C174.793 1863.45 176.483 1863.92 177.717 1864.85C178.95 1865.77 179.765 1867.01 180.162 1868.56ZM189.765 1857.09V1883.58H185.897V1857.09H189.765ZM201.846 1884.02C200.587 1884.02 199.448 1883.79 198.43 1883.32C197.412 1882.85 196.606 1882.16 196.011 1881.26C195.424 1880.37 195.131 1879.27 195.131 1877.97C195.131 1876.84 195.347 1875.92 195.778 1875.2C196.209 1874.47 196.791 1873.9 197.525 1873.48C198.258 1873.06 199.077 1872.74 199.983 1872.52C200.888 1872.3 201.811 1872.14 202.751 1872.03C203.942 1871.89 204.908 1871.78 205.65 1871.69C206.391 1871.6 206.93 1871.45 207.267 1871.24C207.603 1871.04 207.772 1870.7 207.772 1870.23V1870.14C207.772 1869.01 207.452 1868.14 206.814 1867.52C206.184 1866.9 205.244 1866.58 203.994 1866.58C202.691 1866.58 201.665 1866.87 200.914 1867.45C200.172 1868.02 199.659 1868.65 199.375 1869.35L195.739 1868.52C196.17 1867.32 196.8 1866.34 197.628 1865.6C198.465 1864.85 199.426 1864.31 200.513 1863.97C201.6 1863.63 202.743 1863.45 203.942 1863.45C204.735 1863.45 205.576 1863.55 206.465 1863.74C207.362 1863.92 208.199 1864.26 208.975 1864.75C209.76 1865.24 210.402 1865.94 210.902 1866.86C211.403 1867.76 211.653 1868.94 211.653 1870.39V1883.58H207.875V1880.86H207.72C207.47 1881.36 207.094 1881.86 206.594 1882.34C206.094 1882.82 205.451 1883.22 204.666 1883.54C203.881 1883.86 202.941 1884.02 201.846 1884.02ZM202.687 1880.92C203.756 1880.92 204.671 1880.71 205.43 1880.28C206.197 1879.86 206.779 1879.31 207.176 1878.63C207.582 1877.94 207.785 1877.2 207.785 1876.42V1873.85C207.646 1873.99 207.379 1874.12 206.982 1874.24C206.594 1874.36 206.15 1874.45 205.65 1874.54C205.149 1874.62 204.662 1874.69 204.188 1874.76C203.713 1874.82 203.316 1874.87 202.997 1874.91C202.247 1875.01 201.561 1875.17 200.94 1875.39C200.328 1875.62 199.836 1875.94 199.465 1876.36C199.103 1876.78 198.922 1877.33 198.922 1878.02C198.922 1878.98 199.275 1879.7 199.983 1880.19C200.69 1880.67 201.591 1880.92 202.687 1880.92ZM226.464 1883.98C224.506 1883.98 222.819 1883.56 221.405 1882.73C219.999 1881.88 218.912 1880.7 218.144 1879.17C217.385 1877.64 217.006 1875.84 217.006 1873.78C217.006 1871.74 217.385 1869.95 218.144 1868.39C218.912 1866.84 219.982 1865.63 221.353 1864.76C222.733 1863.89 224.346 1863.45 226.192 1863.45C227.313 1863.45 228.4 1863.64 229.452 1864.01C230.505 1864.38 231.449 1864.96 232.286 1865.76C233.122 1866.55 233.782 1867.58 234.265 1868.85C234.748 1870.11 234.99 1871.64 234.99 1873.44V1874.81H219.192V1871.91H231.199C231.199 1870.9 230.992 1869.99 230.578 1869.21C230.164 1868.42 229.582 1867.79 228.831 1867.34C228.09 1866.88 227.218 1866.65 226.218 1866.65C225.131 1866.65 224.182 1866.92 223.371 1867.45C222.569 1867.98 221.948 1868.67 221.508 1869.52C221.077 1870.37 220.861 1871.29 220.861 1872.28V1874.54C220.861 1875.87 221.094 1877 221.56 1877.93C222.035 1878.86 222.694 1879.57 223.54 1880.06C224.385 1880.54 225.372 1880.79 226.502 1880.79C227.235 1880.79 227.904 1880.68 228.508 1880.48C229.112 1880.26 229.633 1879.94 230.073 1879.52C230.513 1879.1 230.85 1878.57 231.083 1877.95L234.744 1878.61C234.451 1879.69 233.924 1880.64 233.166 1881.45C232.415 1882.25 231.471 1882.87 230.332 1883.32C229.202 1883.76 227.913 1883.98 226.464 1883.98ZM240.375 1883.58V1863.71H244.114V1866.87H244.321C244.683 1865.8 245.322 1864.96 246.236 1864.35C247.159 1863.73 248.203 1863.42 249.367 1863.42C249.609 1863.42 249.893 1863.43 250.221 1863.44C250.557 1863.46 250.82 1863.48 251.01 1863.51V1867.21C250.855 1867.16 250.579 1867.11 250.182 1867.06C249.785 1867 249.388 1866.97 248.992 1866.97C248.078 1866.97 247.262 1867.17 246.546 1867.55C245.839 1867.93 245.278 1868.46 244.864 1869.15C244.451 1869.82 244.243 1870.59 244.243 1871.45V1883.58H240.375ZM276.123 1883.58L280.469 1857.09H283.574L279.228 1883.58H276.123ZM264.129 1876.49L264.634 1873.39H284.714L284.209 1876.49H264.129ZM266.807 1883.58L271.155 1857.09H274.26L269.911 1883.58H266.807ZM265.669 1867.28L266.174 1864.18H286.252L285.75 1867.28H265.669ZM294.345 1871.78V1883.58H290.478V1863.71H294.189V1866.95H294.436C294.894 1865.89 295.61 1865.05 296.583 1864.41C297.567 1863.77 298.806 1863.45 300.297 1863.45C301.65 1863.45 302.837 1863.74 303.855 1864.31C304.872 1864.87 305.663 1865.7 306.223 1866.82C306.782 1867.93 307.063 1869.31 307.063 1870.94V1883.58H303.196V1871.41C303.196 1869.97 302.819 1868.84 302.069 1868.03C301.32 1867.21 300.289 1866.81 298.978 1866.81C298.08 1866.81 297.283 1867 296.583 1867.39C295.894 1867.78 295.347 1868.34 294.941 1869.09C294.543 1869.84 294.345 1870.73 294.345 1871.78ZM321.91 1883.98C319.953 1883.98 318.267 1883.56 316.851 1882.73C315.446 1881.88 314.358 1880.7 313.59 1879.17C312.833 1877.64 312.453 1875.84 312.453 1873.78C312.453 1871.74 312.833 1869.95 313.59 1868.39C314.358 1866.84 315.427 1865.63 316.799 1864.76C318.178 1863.89 319.792 1863.45 321.639 1863.45C322.758 1863.45 323.846 1863.64 324.898 1864.01C325.952 1864.38 326.896 1864.96 327.732 1865.76C328.57 1866.55 329.228 1867.58 329.712 1868.85C330.194 1870.11 330.436 1871.64 330.436 1873.44V1874.81H314.639V1871.91H326.646C326.646 1870.9 326.438 1869.99 326.024 1869.21C325.611 1868.42 325.028 1867.79 324.278 1867.34C323.537 1866.88 322.665 1866.65 321.665 1866.65C320.578 1866.65 319.628 1866.92 318.818 1867.45C318.017 1867.98 317.395 1868.67 316.955 1869.52C316.523 1870.37 316.307 1871.29 316.307 1872.28V1874.54C316.307 1875.87 316.541 1877 317.007 1877.93C317.481 1878.86 318.142 1879.57 318.985 1880.06C319.831 1880.54 320.82 1880.79 321.949 1880.79C322.683 1880.79 323.349 1880.68 323.953 1880.48C324.557 1880.26 325.08 1879.94 325.52 1879.52C325.959 1879.1 326.295 1878.57 326.529 1877.95L330.191 1878.61C329.897 1879.69 329.371 1880.64 328.611 1881.45C327.862 1882.25 326.917 1882.87 325.777 1883.32C324.648 1883.76 323.36 1883.98 321.91 1883.98ZM345.097 1863.71V1866.82H334.243V1863.71H345.097ZM337.155 1858.95H341.022V1877.75C341.022 1878.5 341.134 1879.06 341.358 1879.44C341.584 1879.81 341.873 1880.07 342.227 1880.2C342.589 1880.33 342.982 1880.4 343.403 1880.4C343.713 1880.4 343.986 1880.38 344.218 1880.33C344.452 1880.29 344.632 1880.26 344.762 1880.23L345.459 1883.42C345.235 1883.51 344.918 1883.6 344.504 1883.68C344.088 1883.78 343.572 1883.83 342.951 1883.84C341.933 1883.86 340.983 1883.67 340.103 1883.3C339.224 1882.92 338.513 1882.33 337.969 1881.54C337.426 1880.74 337.155 1879.75 337.155 1878.55V1858.95ZM359.929 1863.71V1866.82H348.697V1863.71H359.929ZM351.778 1883.58V1861.41C351.778 1860.17 352.049 1859.14 352.592 1858.32C353.136 1857.49 353.855 1856.87 354.752 1856.47C355.65 1856.06 356.624 1855.85 357.678 1855.85C358.453 1855.85 359.117 1855.91 359.668 1856.04C360.22 1856.16 360.631 1856.28 360.899 1856.38L359.994 1859.51C359.812 1859.46 359.58 1859.4 359.294 1859.33C359.01 1859.25 358.664 1859.21 358.258 1859.21C357.318 1859.21 356.647 1859.44 356.241 1859.91C355.843 1860.38 355.645 1861.05 355.645 1861.93V1883.58H351.778ZM368.881 1857.09V1883.58H365.014V1857.09H368.881ZM375.179 1883.58V1863.71H379.046V1883.58H375.179ZM377.133 1860.65C376.459 1860.65 375.881 1860.42 375.397 1859.98C374.924 1859.52 374.687 1858.97 374.687 1858.34C374.687 1857.71 374.924 1857.16 375.397 1856.72C375.881 1856.26 376.459 1856.03 377.133 1856.03C377.805 1856.03 378.377 1856.26 378.853 1856.72C379.335 1857.16 379.577 1857.71 379.577 1858.34C379.577 1858.97 379.335 1859.52 378.853 1859.98C378.377 1860.42 377.805 1860.65 377.133 1860.65ZM388.396 1863.71L392.784 1871.45L397.208 1863.71H401.44L395.241 1873.65L401.492 1883.58H397.26L392.784 1876.16L388.321 1883.58H384.076L390.26 1873.65L384.154 1863.71H388.396ZM427.175 1883.58L431.523 1857.09H434.628L430.282 1883.58H427.175ZM415.183 1876.49L415.688 1873.39H435.768L435.263 1876.49H415.183ZM417.861 1883.58L422.207 1857.09H425.311L420.965 1883.58H417.861ZM416.721 1867.28L417.226 1864.18H437.306L436.801 1867.28H416.721ZM445.4 1857.09V1883.58H441.53V1857.09H445.4ZM451.697 1883.58V1863.71H455.564V1883.58H451.697ZM453.649 1860.65C452.978 1860.65 452.4 1860.42 451.916 1859.98C451.442 1859.52 451.205 1858.97 451.205 1858.34C451.205 1857.71 451.442 1857.16 451.916 1856.72C452.4 1856.26 452.978 1856.03 453.649 1856.03C454.323 1856.03 454.896 1856.26 455.372 1856.72C455.853 1857.16 456.095 1857.71 456.095 1858.34C456.095 1858.97 455.853 1859.52 455.372 1859.98C454.896 1860.42 454.323 1860.65 453.649 1860.65ZM470.427 1883.98C468.47 1883.98 466.784 1883.56 465.368 1882.73C463.962 1881.88 462.875 1880.7 462.107 1879.17C461.35 1877.64 460.97 1875.84 460.97 1873.78C460.97 1871.74 461.35 1869.95 462.107 1868.39C462.875 1866.84 463.944 1865.63 465.316 1864.76C466.698 1863.89 468.309 1863.45 470.156 1863.45C471.278 1863.45 472.363 1863.64 473.417 1864.01C474.468 1864.38 475.413 1864.96 476.248 1865.76C477.086 1866.55 477.745 1867.58 478.229 1868.85C478.713 1870.11 478.952 1871.64 478.952 1873.44V1874.81H463.156V1871.91H475.163C475.163 1870.9 474.955 1869.99 474.541 1869.21C474.127 1868.42 473.545 1867.79 472.795 1867.34C472.053 1866.88 471.182 1866.65 470.182 1866.65C469.094 1866.65 468.145 1866.92 467.335 1867.45C466.534 1867.98 465.912 1868.67 465.472 1869.52C465.04 1870.37 464.824 1871.29 464.824 1872.28V1874.54C464.824 1875.87 465.058 1877 465.524 1877.93C465.998 1878.86 466.659 1879.57 467.504 1880.06C468.348 1880.54 469.336 1880.79 470.466 1880.79C471.2 1880.79 471.869 1880.68 472.472 1880.48C473.076 1880.26 473.597 1879.94 474.036 1879.52C474.476 1879.1 474.812 1878.57 475.046 1877.95L478.708 1878.61C478.414 1879.69 477.888 1880.64 477.128 1881.45C476.379 1882.25 475.434 1882.87 474.297 1883.32C473.165 1883.76 471.876 1883.98 470.427 1883.98ZM499.23 1868.56L495.725 1869.19C495.577 1868.74 495.345 1868.31 495.025 1867.9C494.715 1867.5 494.294 1867.17 493.757 1866.91C493.224 1866.65 492.555 1866.52 491.751 1866.52C490.658 1866.52 489.742 1866.77 489.008 1867.26C488.277 1867.74 487.91 1868.37 487.91 1869.13C487.91 1869.8 488.154 1870.33 488.646 1870.74C489.138 1871.14 489.932 1871.47 491.028 1871.73L494.184 1872.46C496.014 1872.88 497.375 1873.53 498.273 1874.41C499.17 1875.29 499.618 1876.43 499.618 1877.84C499.618 1879.03 499.275 1880.09 498.582 1881.02C497.903 1881.94 496.948 1882.67 495.725 1883.19C494.507 1883.72 493.096 1883.98 491.493 1883.98C489.268 1883.98 487.452 1883.51 486.047 1882.56C484.641 1881.6 483.777 1880.24 483.46 1878.48L487.197 1877.91C487.431 1878.89 487.91 1879.63 488.633 1880.13C489.359 1880.62 490.304 1880.86 491.467 1880.86C492.735 1880.86 493.75 1880.6 494.507 1880.08C495.267 1879.54 495.647 1878.89 495.647 1878.12C495.647 1877.5 495.413 1876.98 494.947 1876.56C494.491 1876.13 493.789 1875.81 492.839 1875.6L489.474 1874.86C487.621 1874.44 486.249 1873.77 485.359 1872.85C484.48 1871.92 484.04 1870.75 484.04 1869.34C484.04 1868.17 484.368 1867.14 485.024 1866.26C485.68 1865.38 486.585 1864.7 487.741 1864.21C488.896 1863.7 490.221 1863.45 491.715 1863.45C493.862 1863.45 495.551 1863.92 496.784 1864.85C498.018 1865.77 498.835 1867.01 499.23 1868.56Z"
+ fill="#BFBFBF"
+ />
+ <path
+ d="M994.565 1876.06C1023.31 1876.06 1046.61 1852.77 1046.61 1824.03C1046.61 1795.3 1023.31 1772 994.565 1772C965.819 1772 942.517 1795.3 942.517 1824.03C942.517 1852.77 965.819 1876.06 994.565 1876.06Z"
+ fill="#9D9D9D"
+ />
+ <path
+ d="M993.253 1599C997.49 1599.08 997.139 1599.29 997.139 1599.29L1006.1 1608.39L1011.97 1614.36C1020.55 1623.53 1022.53 1625.45 1029.01 1633.37C1026.99 1640.66 1022.2 1644.73 1016.89 1649.79L1014.55 1651.94L1010.41 1655.85L1006.99 1659.15L1002.3 1663.47C1002.3 1663.47 998.357 1667.12 995.939 1669.04C995.939 1669.04 993.43 1669.56 992.056 1668.18C991.957 1665.4 991.762 1660.81 991.793 1658.04C991.806 1656.52 991.817 1655 991.83 1653.43C991.845 1652.26 991.861 1651.08 991.879 1649.87C976.944 1649.3 973.915 1650.07 961.179 1658.94C958.909 1658.79 958.084 1656.73 958.084 1656.73C956.677 1644.97 961.9 1637.63 968.499 1627.87C975.089 1620.72 982.393 1618.15 991.879 1616.87C991.861 1615.7 991.845 1614.52 991.83 1613.31C991.817 1611.79 991.806 1610.28 991.793 1608.71C991.778 1607.2 991.76 1605.68 991.744 1604.12C991.879 1600.37 991.879 1600.37 993.253 1599Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter0_d_5_2)">
+ <path
+ d="M965.181 1685.31V1708.02H962.43V1688.19H962.298L956.752 1691.87V1689.08L962.43 1685.31H965.181ZM974.318 1704.91L974.141 1706.11C974.016 1706.95 973.824 1707.85 973.566 1708.82C973.314 1709.78 973.051 1710.68 972.778 1711.53C972.504 1712.38 972.278 1713.06 972.101 1713.56H970.105C970.201 1713.09 970.326 1712.46 970.482 1711.68C970.638 1710.91 970.792 1710.04 970.948 1709.08C971.109 1708.13 971.242 1707.15 971.346 1706.15L971.479 1704.91H974.318ZM985.764 1708.33C984.462 1708.33 983.291 1708.07 982.248 1707.55C981.207 1707.03 980.371 1706.32 979.742 1705.42C979.114 1704.52 978.771 1703.49 978.711 1702.34H981.373C981.475 1703.37 981.941 1704.22 982.768 1704.89C983.604 1705.56 984.603 1705.89 985.764 1705.89C986.695 1705.89 987.523 1705.67 988.249 1705.23C988.98 1704.8 989.553 1704.2 989.966 1703.44C990.388 1702.67 990.599 1701.8 990.599 1700.83C990.599 1699.84 990.38 1698.96 989.946 1698.18C989.516 1697.4 988.926 1696.78 988.171 1696.33C987.416 1695.88 986.555 1695.65 985.587 1695.64C984.892 1695.64 984.179 1695.74 983.445 1695.97C982.714 1696.18 982.112 1696.46 981.639 1696.8L979.065 1696.49L980.442 1685.31H992.241V1687.75H982.747L981.948 1694.45H982.081C982.547 1694.08 983.133 1693.77 983.835 1693.53C984.535 1693.28 985.269 1693.16 986.029 1693.16C987.419 1693.16 988.657 1693.49 989.745 1694.16C990.838 1694.82 991.697 1695.72 992.319 1696.86C992.946 1698.01 993.261 1699.32 993.261 1700.79C993.261 1702.24 992.936 1703.53 992.285 1704.67C991.642 1705.8 990.755 1706.69 989.623 1707.35C988.491 1708 987.205 1708.33 985.764 1708.33ZM1005.21 1708.33C1004.27 1708.31 1003.34 1708.13 1002.41 1707.8C1001.48 1707.45 1000.63 1706.88 999.861 1706.08C999.093 1705.26 998.476 1704.17 998.011 1702.78C997.545 1701.39 997.311 1699.65 997.311 1697.55C997.311 1695.54 997.5 1693.76 997.878 1692.21C998.253 1690.65 998.802 1689.34 999.517 1688.27C1000.24 1687.2 1001.1 1686.39 1002.11 1685.83C1003.13 1685.28 1004.28 1685 1005.56 1685C1006.83 1685 1007.96 1685.25 1008.96 1685.76C1009.95 1686.27 1010.77 1686.97 1011.39 1687.87C1012.02 1688.77 1012.43 1689.81 1012.62 1690.99H1009.91C1009.66 1689.97 1009.17 1689.12 1008.45 1688.45C1007.72 1687.78 1006.76 1687.44 1005.56 1687.44C1003.8 1687.44 1002.42 1688.21 1001.4 1689.73C1000.4 1691.26 999.892 1693.41 999.884 1696.18H1000.06C1000.48 1695.55 1000.97 1695.01 1001.54 1694.57C1002.11 1694.12 1002.75 1693.77 1003.44 1693.53C1004.14 1693.28 1004.87 1693.16 1005.65 1693.16C1006.95 1693.16 1008.14 1693.49 1009.22 1694.14C1010.3 1694.78 1011.17 1695.67 1011.82 1696.81C1012.47 1697.94 1012.79 1699.24 1012.79 1700.7C1012.79 1702.1 1012.48 1703.39 1011.85 1704.56C1011.22 1705.72 1010.34 1706.64 1009.2 1707.33C1008.07 1708.01 1006.74 1708.34 1005.21 1708.33ZM1005.21 1705.89C1006.14 1705.89 1006.97 1705.65 1007.71 1705.19C1008.46 1704.72 1009.05 1704.1 1009.48 1703.32C1009.91 1702.53 1010.13 1701.66 1010.13 1700.7C1010.13 1699.76 1009.92 1698.91 1009.5 1698.14C1009.08 1697.36 1008.51 1696.75 1007.78 1696.29C1007.06 1695.83 1006.23 1695.6 1005.29 1695.6C1004.59 1695.6 1003.94 1695.74 1003.33 1696.02C1002.73 1696.29 1002.2 1696.67 1001.73 1697.15C1001.28 1697.63 1000.93 1698.18 1000.67 1698.8C1000.41 1699.42 1000.28 1700.06 1000.28 1700.74C1000.28 1701.65 1000.49 1702.49 1000.91 1703.27C1001.34 1704.05 1001.93 1704.69 1002.67 1705.17C1003.41 1705.65 1004.26 1705.89 1005.21 1705.89ZM1017.86 1708.02L1028.02 1687.93V1687.75H1016.31V1685.31H1030.86V1687.88L1020.75 1708.02H1017.86Z"
+ fill="white"
+ />
+ </g>
+ <g filter="url(#filter1_d_5_2)">
+ <path
+ d="M962.448 1531.36V1529.1L972.429 1513.31H974.071V1516.81H972.962L965.42 1528.74V1528.92H978.862V1531.36H962.448ZM973.139 1536.02V1530.67V1529.62V1513.31H975.757V1536.02H973.139ZM983.903 1536.19C983.353 1536.19 982.885 1536 982.492 1535.61C982.102 1535.22 981.906 1534.74 981.906 1534.2C981.906 1533.65 982.102 1533.18 982.492 1532.79C982.885 1532.4 983.353 1532.2 983.903 1532.2C984.449 1532.2 984.917 1532.4 985.31 1532.79C985.701 1533.18 985.899 1533.65 985.899 1534.2C985.899 1534.56 985.805 1534.89 985.62 1535.2C985.443 1535.5 985.204 1535.74 984.899 1535.93C984.603 1536.11 984.272 1536.19 983.903 1536.19ZM990.549 1536.02V1534.02L998.047 1525.82C998.926 1524.86 999.65 1524.02 1000.22 1523.31C1000.79 1522.59 1001.21 1521.92 1001.48 1521.29C1001.77 1520.66 1001.91 1519.99 1001.91 1519.3C1001.91 1518.5 1001.71 1517.81 1001.33 1517.22C1000.95 1516.64 1000.44 1516.19 999.777 1515.87C999.119 1515.55 998.38 1515.4 997.557 1515.4C996.686 1515.4 995.926 1515.58 995.275 1515.94C994.63 1516.29 994.133 1516.79 993.776 1517.44C993.43 1518.08 993.256 1518.83 993.256 1519.7H990.638C990.638 1518.37 990.945 1517.2 991.559 1516.19C992.173 1515.19 993.008 1514.4 994.065 1513.84C995.129 1513.28 996.324 1513 997.648 1513C998.978 1513 1000.16 1513.28 1001.19 1513.84C1002.21 1514.4 1003.02 1515.16 1003.6 1516.12C1004.19 1517.07 1004.48 1518.13 1004.48 1519.3C1004.48 1520.13 1004.33 1520.95 1004.02 1521.75C1003.73 1522.54 1003.21 1523.42 1002.47 1524.4C1001.74 1525.37 1000.72 1526.55 999.421 1527.95L994.32 1533.4V1533.58H1004.88V1536.02H990.549ZM1009.86 1536.02V1513.31H1012.61V1524.57H1012.87L1023.08 1513.31H1026.67L1017.13 1523.55L1026.67 1536.02H1023.34L1015.45 1525.46L1012.61 1528.65V1536.02H1009.86Z"
+ fill="white"
+ />
+ </g>
+ <path
+ d="M981.641 1429.12C982.123 1429.11 982.123 1429.11 984.551 1429.1C986.581 1429.09 988.611 1429.09 990.638 1429.1C993.74 1429.11 996.837 1429.08 999.936 1429.04C1001.91 1429.04 1003.88 1429.04 1005.86 1429.04C1006.78 1429.03 1007.71 1429.02 1008.66 1429C1012.71 1429.05 1015.52 1429.46 1019.05 1431.36C1022.18 1436.32 1021.68 1441.07 1021.55 1446.73C1021.54 1447.91 1021.54 1449.09 1021.53 1450.32C1021.5 1454.09 1021.43 1457.86 1021.37 1461.64C1021.34 1464.2 1021.32 1466.76 1021.3 1469.32C1021.24 1475.6 1021.92 1482.55 1021.81 1488.83C1021.81 1488.83 1021.68 1493.31 1015.86 1490.58C1014.61 1489.71 1013.36 1488.85 1012.08 1487.96C1010.42 1486.83 1008.77 1485.69 1007.12 1484.56C1006.29 1483.99 1005.47 1483.42 1004.63 1482.83C1003.82 1482.29 1003.02 1481.74 1002.2 1481.18C1001.46 1480.67 1000.73 1480.17 999.975 1479.65C997.428 1478.23 995.489 1477.5 992.54 1477.18C989.516 1479.06 986.87 1480.93 984.116 1483.12C975.26 1490.05 972.994 1492.06 967.443 1491.21C967.443 1491.21 967.591 1486.92 967.565 1482.89C967.56 1482.35 967.56 1482.35 967.529 1479.6C967.529 1478.42 967.529 1477.25 967.531 1476.04C967.526 1474.83 967.521 1473.63 967.513 1472.38C967.508 1469.83 967.505 1467.28 967.51 1464.73C967.516 1460.82 967.484 1456.91 967.453 1453C967.45 1450.52 967.448 1448.05 967.451 1445.57C967.438 1444.4 967.424 1443.23 967.414 1442.02C967.422 1440.93 967.43 1439.84 967.437 1438.71C967.437 1437.75 967.437 1436.8 967.435 1435.81C968.827 1428.62 975.185 1429.09 981.641 1429.12Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter2_d_5_2)">
+ <path
+ d="M963.428 1365.33C962.126 1365.33 960.955 1365.07 959.912 1364.55C958.871 1364.03 958.035 1363.32 957.406 1362.42C956.778 1361.52 956.435 1360.49 956.375 1359.34H959.037C959.141 1360.37 959.607 1361.22 960.435 1361.89C961.27 1362.56 962.267 1362.89 963.428 1362.89C964.359 1362.89 965.187 1362.67 965.913 1362.23C966.644 1361.8 967.217 1361.2 967.63 1360.44C968.052 1359.67 968.263 1358.8 968.263 1357.83C968.263 1356.84 968.044 1355.96 967.61 1355.18C967.18 1354.4 966.589 1353.78 965.835 1353.33C965.08 1352.88 964.219 1352.65 963.251 1352.64C962.556 1352.64 961.843 1352.74 961.111 1352.97C960.378 1353.18 959.776 1353.46 959.303 1353.8L956.729 1353.49L958.106 1342.31H969.905V1344.75H960.411L959.612 1351.45H959.748C960.211 1351.08 960.796 1350.77 961.499 1350.53C962.202 1350.28 962.933 1350.16 963.696 1350.16C965.085 1350.16 966.321 1350.49 967.409 1351.16C968.505 1351.82 969.361 1352.72 969.983 1353.86C970.61 1355.01 970.925 1356.32 970.925 1357.79C970.925 1359.24 970.6 1360.53 969.949 1361.67C969.306 1362.8 968.419 1363.69 967.287 1364.35C966.157 1365 964.869 1365.33 963.428 1365.33ZM978.425 1361.91L978.248 1363.11C978.121 1363.95 977.928 1364.85 977.67 1365.82C977.418 1366.78 977.155 1367.68 976.882 1368.53C976.609 1369.38 976.385 1370.06 976.205 1370.56H974.209C974.306 1370.09 974.433 1369.46 974.587 1368.68C974.743 1367.91 974.899 1367.04 975.052 1366.08C975.216 1365.13 975.349 1364.15 975.453 1363.15L975.586 1361.91H978.425ZM988.499 1342.31V1365.02H985.748V1345.19H985.616L980.073 1348.87V1346.08L985.748 1342.31H988.499ZM994.5 1360.36V1358.1L1004.48 1342.31H1006.12V1345.81H1005.01L997.472 1357.74V1357.92H1010.91V1360.36H994.5ZM1005.19 1365.02V1359.67V1358.62V1342.31H1007.81V1365.02H1005.19ZM1022.34 1365.33C1020.82 1365.33 1019.47 1365.06 1018.31 1364.52C1017.15 1363.97 1016.24 1363.22 1015.59 1362.27C1014.94 1361.31 1014.62 1360.21 1014.62 1358.99C1014.62 1358.02 1014.8 1357.14 1015.19 1356.32C1015.57 1355.51 1016.1 1354.82 1016.76 1354.27C1017.44 1353.72 1018.19 1353.37 1019.02 1353.22V1353.09C1017.93 1352.81 1017.06 1352.2 1016.42 1351.26C1015.78 1350.31 1015.46 1349.24 1015.47 1348.03C1015.46 1346.88 1015.75 1345.85 1016.34 1344.94C1016.94 1344.03 1017.75 1343.31 1018.78 1342.79C1019.82 1342.26 1021.01 1342 1022.34 1342C1023.66 1342 1024.83 1342.26 1025.87 1342.79C1026.91 1343.31 1027.72 1344.03 1028.31 1344.94C1028.91 1345.85 1029.21 1346.88 1029.22 1348.03C1029.21 1349.24 1028.88 1350.31 1028.23 1351.26C1027.59 1352.2 1026.73 1352.81 1025.67 1353.09V1353.22C1026.49 1353.37 1027.23 1353.72 1027.89 1354.27C1028.55 1354.82 1029.07 1355.51 1029.46 1356.32C1029.85 1357.14 1030.05 1358.02 1030.06 1358.99C1030.05 1360.21 1029.72 1361.31 1029.06 1362.27C1028.41 1363.22 1027.51 1363.97 1026.35 1364.52C1025.19 1365.06 1023.86 1365.33 1022.34 1365.33ZM1022.34 1362.89C1023.37 1362.89 1024.26 1362.72 1025 1362.39C1025.75 1362.06 1026.33 1361.59 1026.73 1360.98C1027.14 1360.38 1027.35 1359.67 1027.36 1358.85C1027.35 1357.99 1027.13 1357.24 1026.69 1356.58C1026.25 1355.92 1025.66 1355.4 1024.9 1355.03C1024.16 1354.65 1023.3 1354.46 1022.34 1354.46C1021.38 1354.46 1020.51 1354.65 1019.75 1355.03C1018.99 1355.4 1018.4 1355.92 1017.96 1356.58C1017.53 1357.24 1017.32 1357.99 1017.33 1358.85C1017.32 1359.67 1017.52 1360.38 1017.92 1360.98C1018.33 1361.59 1018.91 1362.06 1019.66 1362.39C1020.41 1362.72 1021.31 1362.89 1022.34 1362.89ZM1022.34 1352.11C1023.16 1352.11 1023.88 1351.95 1024.51 1351.62C1025.14 1351.3 1025.64 1350.84 1026 1350.26C1026.36 1349.68 1026.55 1348.99 1026.56 1348.21C1026.55 1347.44 1026.37 1346.77 1026.01 1346.2C1025.66 1345.63 1025.17 1345.18 1024.54 1344.87C1023.91 1344.55 1023.18 1344.4 1022.34 1344.4C1021.49 1344.4 1020.75 1344.55 1020.11 1344.87C1019.48 1345.18 1018.99 1345.63 1018.64 1346.2C1018.29 1346.77 1018.12 1347.44 1018.13 1348.21C1018.12 1348.99 1018.29 1349.68 1018.65 1350.26C1019.01 1350.84 1019.51 1351.3 1020.15 1351.62C1020.78 1351.95 1021.52 1352.11 1022.34 1352.11Z"
+ fill="white"
+ />
+ </g>
+ <path
+ fillRule="evenodd"
+ clipRule="evenodd"
+ d="M977.188 1256.62C988.956 1252.25 1004.57 1253.5 1015.53 1259.22C1022.37 1263.77 1028.69 1269.31 1031.15 1277.23C1032.49 1287.77 1032.28 1297.64 1025.47 1306.32C1020.61 1311.36 1015.45 1315.75 1009.85 1320C1008.42 1321.11 1006.98 1322.22 1005.5 1323.36C1001.33 1325.71 1001.27 1325.82 998.234 1326.81C997.737 1326.63 998.414 1326.92 995.895 1326.04C995.66 1324.9 995.673 1323.02 995.452 1317.87C993.813 1317.75 989.532 1317.17 987.84 1317.05C976.496 1315.59 969.272 1310.69 962.152 1302.07C956.676 1294.74 956.213 1287.47 957.309 1278.61C960.494 1268.07 967.95 1262.26 977.188 1256.62ZM976.374 1282.47C973.498 1282.47 971.169 1284.8 971.169 1287.68C971.169 1290.55 973.498 1292.88 976.374 1292.88C979.247 1292.88 981.579 1290.55 981.579 1287.68C981.579 1284.8 979.25 1282.47 976.374 1282.47ZM994.552 1282.47C991.679 1282.47 989.347 1284.8 989.347 1287.68C989.347 1290.55 991.679 1292.88 994.552 1292.88C997.427 1292.88 999.756 1290.55 999.756 1287.68C999.756 1284.8 997.427 1282.47 994.552 1282.47ZM1012.73 1282.47C1009.85 1282.47 1007.52 1284.8 1007.52 1287.68C1007.52 1290.55 1009.85 1292.88 1012.73 1292.88C1015.6 1292.88 1017.93 1290.55 1017.93 1287.68C1017.93 1284.8 1015.6 1282.47 1012.73 1282.47Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter3_d_5_2)">
+ <path
+ d="M947.525 1193.06V1190.68L954.89 1183.05C955.676 1182.22 956.324 1181.49 956.834 1180.87C957.352 1180.24 957.737 1179.64 957.992 1179.07C958.247 1178.51 958.375 1177.9 958.375 1177.27C958.375 1176.54 958.206 1175.92 957.865 1175.4C957.524 1174.87 957.061 1174.46 956.472 1174.18C955.884 1173.89 955.223 1173.74 954.484 1173.74C953.706 1173.74 953.027 1173.9 952.444 1174.22C951.864 1174.54 951.419 1174.99 951.106 1175.57C950.794 1176.15 950.638 1176.83 950.638 1177.61H947.502C947.502 1176.28 947.806 1175.12 948.418 1174.13C949.027 1173.14 949.862 1172.37 950.924 1171.83C951.989 1171.28 953.196 1171 954.549 1171C955.916 1171 957.12 1171.27 958.161 1171.82C959.21 1172.36 960.03 1173.09 960.618 1174.03C961.206 1174.95 961.5 1176 961.5 1177.17C961.5 1177.98 961.347 1178.77 961.042 1179.54C960.746 1180.31 960.225 1181.17 959.481 1182.12C958.737 1183.06 957.701 1184.21 956.376 1185.55L952.051 1190.08V1190.24H961.852V1193.06H947.525ZM973.063 1193.35C971.73 1193.35 970.533 1193.1 969.471 1192.59C968.415 1192.07 967.572 1191.36 966.942 1190.46C966.31 1189.56 965.974 1188.54 965.932 1187.38H969.12C969.198 1188.32 969.612 1189.09 970.364 1189.69C971.114 1190.29 972.014 1190.59 973.063 1190.59C973.898 1190.59 974.64 1190.4 975.283 1190.02C975.936 1189.63 976.446 1189.09 976.815 1188.41C977.19 1187.73 977.378 1186.96 977.378 1186.09C977.378 1185.2 977.185 1184.41 976.802 1183.72C976.42 1183.02 975.894 1182.48 975.22 1182.08C974.554 1181.68 973.789 1181.48 972.925 1181.47C972.266 1181.47 971.603 1181.59 970.937 1181.81C970.27 1182.04 969.732 1182.34 969.321 1182.71L966.315 1182.26L967.535 1171.3H979.504V1174.11H970.268L969.576 1180.2H969.703C970.13 1179.79 970.692 1179.44 971.395 1179.17C972.103 1178.89 972.86 1178.76 973.669 1178.76C974.994 1178.76 976.173 1179.07 977.208 1179.7C978.249 1180.33 979.067 1181.19 979.663 1182.28C980.264 1183.37 980.563 1184.61 980.555 1186.02C980.563 1187.43 980.243 1188.69 979.597 1189.79C978.96 1190.9 978.075 1191.77 976.943 1192.41C975.816 1193.04 974.523 1193.35 973.063 1193.35ZM991.792 1193.35C990.267 1193.35 988.922 1193.09 987.753 1192.57C986.59 1192.04 985.679 1191.32 985.021 1190.4C984.362 1189.48 984.037 1188.43 984.045 1187.25C984.037 1186.33 984.225 1185.49 984.607 1184.72C984.997 1183.94 985.526 1183.29 986.189 1182.77C986.856 1182.25 987.6 1181.91 988.422 1181.77V1181.65C987.34 1181.38 986.47 1180.8 985.82 1179.9C985.167 1179 984.844 1177.97 984.852 1176.8C984.844 1175.69 985.138 1174.7 985.734 1173.83C986.335 1172.95 987.16 1172.26 988.209 1171.75C989.258 1171.25 990.452 1171 991.792 1171C993.117 1171 994.298 1171.25 995.342 1171.76C996.391 1172.27 997.216 1172.96 997.817 1173.84C998.421 1174.71 998.725 1175.7 998.73 1176.8C998.725 1177.97 998.392 1179 997.731 1179.9C997.073 1180.8 996.216 1181.38 995.16 1181.65V1181.77C995.974 1181.91 996.708 1182.25 997.361 1182.77C998.02 1183.29 998.543 1183.94 998.933 1184.72C999.329 1185.49 999.532 1186.33 999.54 1187.25C999.532 1188.43 999.199 1189.48 998.54 1190.4C997.882 1191.32 996.966 1192.04 995.797 1192.57C994.637 1193.09 993.302 1193.35 991.792 1193.35ZM991.792 1190.67C992.69 1190.67 993.471 1190.52 994.129 1190.22C994.788 1189.91 995.298 1189.49 995.66 1188.95C996.021 1188.41 996.206 1187.77 996.211 1187.04C996.206 1186.28 996.008 1185.62 995.618 1185.03C995.235 1184.45 994.715 1184 994.054 1183.66C993.395 1183.33 992.641 1183.17 991.792 1183.17C990.933 1183.17 990.174 1183.33 989.507 1183.66C988.841 1184 988.315 1184.45 987.933 1185.03C987.55 1185.62 987.363 1186.28 987.371 1187.04C987.363 1187.77 987.537 1188.41 987.891 1188.95C988.253 1189.49 988.766 1189.91 989.432 1190.22C990.098 1190.52 990.884 1190.67 991.792 1190.67ZM991.792 1180.54C992.513 1180.54 993.156 1180.39 993.715 1180.1C994.275 1179.81 994.715 1179.41 995.032 1178.89C995.358 1178.38 995.524 1177.77 995.532 1177.08C995.524 1176.4 995.363 1175.8 995.043 1175.29C994.73 1174.78 994.296 1174.39 993.736 1174.11C993.177 1173.83 992.529 1173.69 991.792 1173.69C991.04 1173.69 990.382 1173.83 989.815 1174.11C989.255 1174.39 988.82 1174.78 988.508 1175.29C988.196 1175.8 988.042 1176.4 988.05 1177.08C988.042 1177.77 988.198 1178.38 988.518 1178.89C988.836 1179.41 989.276 1179.81 989.835 1180.1C990.403 1180.39 991.053 1180.54 991.792 1180.54ZM1004.84 1193.26C1004.26 1193.26 1003.76 1193.05 1003.34 1192.64C1002.92 1192.22 1002.71 1191.72 1002.71 1191.13C1002.71 1190.55 1002.92 1190.06 1003.34 1189.65C1003.76 1189.23 1004.26 1189.02 1004.84 1189.02C1005.42 1189.02 1005.92 1189.23 1006.34 1189.65C1006.76 1190.06 1006.96 1190.55 1006.96 1191.13C1006.96 1191.52 1006.87 1191.88 1006.67 1192.21C1006.48 1192.52 1006.22 1192.78 1005.9 1192.97C1005.58 1193.16 1005.23 1193.26 1004.84 1193.26ZM1016.81 1171.3V1193.06H1013.51V1174.59H1013.38L1008.18 1177.99V1174.85L1013.61 1171.3H1016.81ZM1022.99 1193.06V1171.3H1026.28V1181.69H1026.54L1035.67 1171.3H1039.8L1031.03 1181.09L1039.84 1193.06H1035.88L1028.85 1183.33L1026.28 1186.29V1193.06H1022.99Z"
+ fill="white"
+ />
+ </g>
+ <path
+ d="M988.553 1150.16C977.873 1141.93 955.578 1127.87 956.619 1105.96C956.619 1094.76 965.067 1085.02 976.051 1085.02C981.746 1085.02 987.12 1085 994.708 1092.81C1001.48 1084.91 1007.48 1085 1013.17 1085C1024.16 1085 1032.52 1094.76 1032.52 1105.96C1033.34 1127.56 1011.24 1143.14 1000.59 1150.16C996.579 1152.24 991.838 1151.72 988.553 1150.16Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <path
+ d="M994.631 876.5C1026.17 876.5 1051.73 902.056 1051.73 933.58C1051.73 965.105 1026.17 990.661 994.631 990.661C963.097 990.661 937.533 965.105 937.533 933.58C937.533 902.056 963.097 876.5 994.631 876.5Z"
+ fill="#9D9D9D"
+ stroke="white"
+ />
+ <path
+ d="M993.947 1019.18C1009.74 1019.18 1022.55 1006.38 1022.55 990.592C1022.55 974.801 1009.74 962 993.947 962C978.152 962 965.347 974.801 965.347 990.592C965.347 1006.38 978.152 1019.18 993.947 1019.18Z"
+ fill="#FE2C55"
+ />
+ <path
+ d="M1008.87 988.577H980.264V993.747H1008.87V988.577Z"
+ fill="white"
+ />
+ <path
+ d="M991.979 976.864V1005.46H997.151V976.864H991.979Z"
+ fill="white"
+ />
+ <path
+ d="M998.114 252.67C1009.71 252.67 1019.12 243.27 1019.12 231.675C1019.12 220.08 1009.71 210.68 998.114 210.68C986.516 210.68 977.113 220.08 977.113 231.675C977.113 243.27 986.516 252.67 998.114 252.67Z"
+ stroke="white"
+ strokeWidth="4"
+ />
+ <path
+ d="M1015.72 246.31C1014.9 245.494 1013.58 245.494 1012.76 246.31C1011.94 247.126 1011.94 248.449 1012.76 249.265L1024.78 261.282C1025.6 262.098 1026.92 262.098 1027.74 261.282C1028.55 260.466 1028.55 259.143 1027.74 258.327L1015.72 246.31Z"
+ fill="white"
+ />
+ <path
+ d="M791.47 268H734.394C732.965 268 731.807 269.158 731.807 270.587V270.587C731.807 272.015 732.965 273.174 734.394 273.174H791.47C792.899 273.174 794.058 272.015 794.058 270.587C794.058 269.158 792.899 268 791.47 268Z"
+ fill="white"
+ />
+ <path
+ d="M686.65 236.274V206H706.051V210.597H692.136V218.816H704.722V223.413H692.136V236.274H686.65ZM720.094 236.717C717.874 236.717 715.954 236.229 714.327 235.254C712.701 234.278 711.438 232.913 710.541 231.159C709.653 229.405 709.211 227.355 709.211 225.01C709.211 222.664 709.653 220.61 710.541 218.846C711.438 217.082 712.701 215.712 714.327 214.736C715.954 213.761 717.874 213.273 720.094 213.273C722.311 213.273 724.234 213.761 725.861 214.736C727.487 215.712 728.744 217.082 729.629 218.846C730.527 220.61 730.975 222.664 730.975 225.01C730.975 227.355 730.527 229.405 729.629 231.159C728.744 232.913 727.487 234.278 725.861 235.254C724.234 236.229 722.311 236.717 720.094 236.717ZM720.123 232.43C721.325 232.43 722.332 232.1 723.139 231.44C723.948 230.77 724.549 229.873 724.942 228.75C725.348 227.626 725.549 226.375 725.549 224.995C725.549 223.605 725.348 222.349 724.942 221.226C724.549 220.092 723.948 219.191 723.139 218.52C722.332 217.85 721.325 217.515 720.123 217.515C718.892 217.515 717.866 217.85 717.047 218.52C716.24 219.191 715.633 220.092 715.228 221.226C714.835 222.349 714.637 223.605 714.637 224.995C714.637 226.375 714.835 227.626 715.228 228.75C715.633 229.873 716.24 230.77 717.047 231.44C717.866 232.1 718.892 232.43 720.123 232.43ZM736.559 236.274V213.568H741.751V217.353H741.985C742.402 216.042 743.11 215.032 744.117 214.322C745.132 213.603 746.29 213.243 747.591 213.243C747.885 213.243 748.216 213.258 748.583 213.288C748.955 213.307 749.267 213.342 749.512 213.391V218.314C749.285 218.235 748.926 218.166 748.434 218.106C747.95 218.037 747.482 218.003 747.029 218.003C746.053 218.003 745.176 218.215 744.398 218.639C743.628 219.053 743.021 219.629 742.579 220.368C742.134 221.107 741.913 221.96 741.913 222.925V236.274H736.559ZM763.645 206H769.842L777.248 219.393H777.545L784.954 206H791.148L780.132 224.98V236.274H774.661V224.98L763.645 206ZM801.516 236.717C799.298 236.717 797.375 236.229 795.749 235.254C794.122 234.278 792.86 232.913 791.965 231.159C791.077 229.405 790.632 227.355 790.632 225.01C790.632 222.664 791.077 220.61 791.965 218.846C792.86 217.082 794.122 215.712 795.749 214.736C797.375 213.761 799.298 213.273 801.516 213.273C803.733 213.273 805.656 213.761 807.283 214.736C808.909 215.712 810.166 217.082 811.053 218.846C811.951 220.61 812.399 222.664 812.399 225.01C812.399 227.355 811.951 229.405 811.053 231.159C810.166 232.913 808.909 234.278 807.283 235.254C805.656 236.229 803.733 236.717 801.516 236.717ZM801.547 232.43C802.749 232.43 803.754 232.1 804.563 231.44C805.37 230.77 805.971 229.873 806.367 228.75C806.77 227.626 806.973 226.375 806.973 224.995C806.973 223.605 806.77 222.349 806.367 221.226C805.971 220.092 805.37 219.191 804.563 218.52C803.754 217.85 802.749 217.515 801.547 217.515C800.313 217.515 799.288 217.85 798.471 218.52C797.661 219.191 797.055 220.092 796.652 221.226C796.256 222.349 796.061 223.605 796.061 224.995C796.061 226.375 796.256 227.626 796.652 228.75C797.055 229.873 797.661 230.77 798.471 231.44C799.288 232.1 800.313 232.43 801.547 232.43ZM832.458 226.724V213.568H837.812V236.274H832.622V232.238H832.385C831.873 233.509 831.03 234.549 829.856 235.357C828.693 236.165 827.259 236.569 825.554 236.569C824.066 236.569 822.749 236.239 821.606 235.579C820.472 234.909 819.584 233.938 818.944 232.667C818.304 231.386 817.984 229.838 817.984 228.025V213.568H823.337V227.198C823.337 228.636 823.73 229.779 824.518 230.627C825.307 231.474 826.343 231.898 827.623 231.898C828.412 231.898 829.177 231.706 829.916 231.322C830.655 230.937 831.261 230.366 831.735 229.607C832.216 228.838 832.458 227.877 832.458 226.724Z"
+ fill="white"
+ />
+ <path
+ d="M439.279 236.486V206.212H458.058V210.144H443.847V219.368H456.71V223.285H443.847V236.486H439.279ZM472.262 236.944C470.133 236.944 468.275 236.456 466.688 235.48C465.1 234.505 463.867 233.14 462.99 231.386C462.113 229.632 461.676 227.582 461.676 225.236C461.676 222.881 462.113 220.822 462.99 219.058C463.867 217.294 465.1 215.924 466.688 214.948C468.275 213.973 470.133 213.485 472.262 213.485C474.391 213.485 476.249 213.973 477.837 214.948C479.424 215.924 480.655 217.294 481.532 219.058C482.409 220.822 482.849 222.881 482.849 225.236C482.849 227.582 482.409 229.632 481.532 231.386C480.655 233.14 479.424 234.505 477.837 235.48C476.249 236.456 474.391 236.944 472.262 236.944ZM472.275 233.234C473.657 233.234 474.8 232.869 475.708 232.14C476.613 231.41 477.285 230.44 477.717 229.228C478.162 228.016 478.383 226.68 478.383 225.222C478.383 223.773 478.162 222.443 477.717 221.231C477.285 220.009 476.613 219.028 475.708 218.289C474.8 217.55 473.657 217.18 472.275 217.18C470.886 217.18 469.733 217.55 468.817 218.289C467.908 219.028 467.234 220.009 466.792 221.231C466.357 222.443 466.141 223.773 466.141 225.222C466.141 226.68 466.357 228.016 466.792 229.228C467.234 230.44 467.908 231.41 468.817 232.14C469.733 232.869 470.886 233.234 472.275 233.234ZM493.245 206.212V236.486H488.824V206.212H493.245ZM504.657 206.212V236.486H500.236V206.212H504.657ZM521.211 236.944C519.082 236.944 517.224 236.456 515.637 235.48C514.049 234.505 512.818 233.14 511.941 231.386C511.064 229.632 510.624 227.582 510.624 225.236C510.624 222.881 511.064 220.822 511.941 219.058C512.818 217.294 514.049 215.924 515.637 214.948C517.224 213.973 519.082 213.485 521.211 213.485C523.34 213.485 525.198 213.973 526.785 214.948C528.373 215.924 529.606 217.294 530.483 219.058C531.36 220.822 531.798 222.881 531.798 225.236C531.798 227.582 531.36 229.632 530.483 231.386C529.606 233.14 528.373 234.505 526.785 235.48C525.198 236.456 523.34 236.944 521.211 236.944ZM521.227 233.234C522.606 233.234 523.751 232.869 524.657 232.14C525.562 231.41 526.234 230.44 526.668 229.228C527.111 228.016 527.332 226.68 527.332 225.222C527.332 223.773 527.111 222.443 526.668 221.231C526.234 220.009 525.562 219.028 524.657 218.289C523.751 217.55 522.606 217.18 521.227 217.18C519.837 217.18 518.684 217.55 517.765 218.289C516.86 219.028 516.183 220.009 515.741 221.231C515.306 222.443 515.09 223.773 515.09 225.222C515.09 226.68 515.306 228.016 515.741 229.228C516.183 230.44 516.86 231.41 517.765 232.14C518.684 232.869 519.837 233.234 521.227 233.234ZM542.007 236.486L535.324 213.78H539.894L544.344 230.455H544.565L549.031 213.78H553.601L558.035 230.381H558.256L562.678 213.78H567.248L560.578 236.486H556.068L551.456 220.093H551.115L546.501 236.486H542.007ZM572.484 236.486V213.78H576.905V236.486H572.484ZM574.716 210.277C573.949 210.277 573.288 210.021 572.736 209.509C572.192 208.986 571.922 208.365 571.922 207.646C571.922 206.917 572.192 206.296 572.736 205.784C573.288 205.261 573.949 205 574.716 205C575.487 205 576.14 205.261 576.684 205.784C577.236 206.296 577.511 206.917 577.511 207.646C577.511 208.365 577.236 208.986 576.684 209.509C576.14 210.021 575.487 210.277 574.716 210.277ZM588.314 223.005V236.486H583.895V213.78H588.137V217.476H588.418C588.941 216.274 589.758 215.308 590.872 214.579C591.996 213.849 593.412 213.485 595.117 213.485C596.665 213.485 598.021 213.81 599.184 214.461C600.345 215.101 601.248 216.057 601.888 217.328C602.528 218.599 602.851 220.171 602.851 222.044V236.486H598.43V222.576C598.43 220.93 598 219.644 597.141 218.718C596.285 217.782 595.106 217.313 593.607 217.313C592.585 217.313 591.671 217.535 590.872 217.979C590.084 218.422 589.459 219.072 588.996 219.93C588.54 220.777 588.314 221.802 588.314 223.005ZM619.343 245.473C617.539 245.473 615.985 245.236 614.684 244.763C613.393 244.291 612.339 243.665 611.52 242.886C610.703 242.108 610.091 241.255 609.688 240.329L613.487 238.762C613.753 239.196 614.109 239.654 614.552 240.137C615.004 240.63 615.616 241.048 616.386 241.393C617.164 241.738 618.164 241.911 619.387 241.911C621.063 241.911 622.447 241.502 623.543 240.684C624.636 239.876 625.182 238.585 625.182 236.811V232.347H624.901C624.636 232.83 624.251 233.367 623.748 233.958C623.257 234.549 622.577 235.062 621.708 235.495C620.842 235.929 619.712 236.146 618.322 236.146C616.529 236.146 614.911 235.727 613.472 234.889C612.043 234.042 610.911 232.795 610.073 231.149C609.243 229.494 608.829 227.459 608.829 225.044C608.829 222.63 609.24 220.561 610.057 218.836C610.885 217.111 612.019 215.791 613.459 214.874C614.898 213.948 616.529 213.485 618.351 213.485C619.762 213.485 620.901 213.721 621.768 214.194C622.635 214.657 623.311 215.2 623.793 215.82C624.287 216.441 624.667 216.988 624.933 217.461H625.258V213.78H629.591V236.988C629.591 238.939 629.135 240.541 628.23 241.792C627.322 243.044 626.096 243.97 624.547 244.571C623.009 245.172 621.276 245.473 619.343 245.473ZM619.298 232.48C620.571 232.48 621.646 232.184 622.523 231.593C623.41 230.992 624.079 230.134 624.532 229.021C624.995 227.897 625.227 226.552 625.227 224.985C625.227 223.458 625 222.113 624.547 220.95C624.095 219.787 623.428 218.88 622.551 218.23C621.674 217.57 620.589 217.24 619.298 217.24C617.969 217.24 616.86 217.584 615.972 218.274C615.085 218.954 614.414 219.881 613.961 221.053C613.516 222.226 613.295 223.537 613.295 224.985C613.295 226.473 613.521 227.779 613.976 228.902C614.429 230.026 615.098 230.903 615.985 231.534C616.883 232.164 617.987 232.48 619.298 232.48Z"
+ fill="#C0C0C0"
+ />
+ <path
+ d="M234.87 236.274V206H253.856V209.932H239.439V219.156H252.865V223.073H239.439V232.341H254.033V236.274H234.87ZM264.134 213.568L269.147 222.408L274.203 213.568H279.039L271.957 224.921L279.098 236.274H274.263L269.147 227.789L264.046 236.274H259.196L266.263 224.921L259.285 213.568H264.134ZM284.621 244.788V213.568H288.938V217.249H289.308C289.563 216.776 289.932 216.229 290.416 215.608C290.898 214.988 291.569 214.445 292.428 213.982C293.284 213.509 294.419 213.273 295.827 213.273C297.661 213.273 299.298 213.736 300.737 214.662C302.177 215.589 303.306 216.924 304.123 218.668C304.951 220.412 305.364 222.511 305.364 224.965C305.364 227.419 304.956 229.523 304.139 231.277C303.319 233.022 302.195 234.367 300.766 235.313C299.337 236.249 297.706 236.717 295.871 236.717C294.492 236.717 293.362 236.485 292.485 236.022C291.619 235.559 290.939 235.017 290.445 234.396C289.953 233.775 289.573 233.223 289.308 232.741H289.04V244.788H284.621ZM288.951 224.921C288.951 226.517 289.183 227.917 289.646 229.119C290.109 230.321 290.781 231.263 291.658 231.942C292.535 232.613 293.609 232.948 294.882 232.948C296.201 232.948 297.307 232.598 298.195 231.898C299.08 231.189 299.751 230.228 300.204 229.016C300.667 227.803 300.899 226.439 300.899 224.921C300.899 223.423 300.672 222.078 300.22 220.885C299.775 219.693 299.106 218.752 298.208 218.062C297.32 217.372 296.212 217.028 294.882 217.028C293.599 217.028 292.516 217.358 291.629 218.018C290.752 218.678 290.086 219.599 289.633 220.782C289.18 221.965 288.951 223.344 288.951 224.921ZM315.779 206V236.274H311.36V206H315.779ZM332.336 236.732C330.207 236.732 328.349 236.244 326.762 235.268C325.174 234.293 323.943 232.928 323.066 231.174C322.189 229.42 321.749 227.37 321.749 225.024C321.749 222.669 322.189 220.61 323.066 218.846C323.943 217.082 325.174 215.712 326.762 214.736C328.349 213.761 330.207 213.273 332.336 213.273C334.465 213.273 336.323 213.761 337.91 214.736C339.498 215.712 340.731 217.082 341.608 218.846C342.485 220.61 342.922 222.669 342.922 225.024C342.922 227.37 342.485 229.42 341.608 231.174C340.731 232.928 339.498 234.293 337.91 235.268C336.323 236.244 334.465 236.732 332.336 236.732ZM332.352 233.022C333.731 233.022 334.876 232.657 335.781 231.928C336.687 231.198 337.359 230.228 337.793 229.016C338.236 227.803 338.457 226.468 338.457 225.01C338.457 223.561 338.236 222.231 337.793 221.019C337.359 219.797 336.687 218.816 335.781 218.077C334.876 217.338 333.731 216.968 332.352 216.968C330.962 216.968 329.809 217.338 328.89 218.077C327.985 218.816 327.308 219.797 326.866 221.019C326.431 222.231 326.215 223.561 326.215 225.01C326.215 226.468 326.431 227.803 326.866 229.016C327.308 230.228 327.985 231.198 328.89 231.928C329.809 232.657 330.962 233.022 332.352 233.022ZM348.9 236.274V213.568H353.173V217.175H353.41C353.824 215.953 354.553 214.993 355.596 214.293C356.653 213.583 357.845 213.228 359.175 213.228C359.453 213.228 359.778 213.238 360.15 213.258C360.536 213.278 360.838 213.302 361.054 213.332V217.56C360.877 217.51 360.562 217.456 360.106 217.397C359.653 217.328 359.201 217.293 358.748 217.293C357.702 217.293 356.77 217.515 355.953 217.959C355.143 218.392 354.503 218.998 354.03 219.777C353.556 220.545 353.319 221.423 353.319 222.408V236.274H348.9ZM374.896 236.732C372.658 236.732 370.732 236.254 369.116 235.298C367.507 234.332 366.266 232.977 365.389 231.233C364.52 229.479 364.088 227.424 364.088 225.069C364.088 222.743 364.52 220.693 365.389 218.919C366.266 217.146 367.489 215.761 369.056 214.766C370.633 213.771 372.475 213.273 374.586 213.273C375.866 213.273 377.108 213.485 378.313 213.908C379.515 214.332 380.592 214.997 381.55 215.904C382.505 216.811 383.26 217.988 383.812 219.437C384.363 220.876 384.639 222.625 384.639 224.685V226.251H366.586V222.94H380.309C380.309 221.777 380.072 220.748 379.598 219.851C379.125 218.944 378.458 218.23 377.602 217.707C376.754 217.185 375.76 216.924 374.615 216.924C373.373 216.924 372.288 217.229 371.362 217.841C370.446 218.441 369.735 219.23 369.233 220.206C368.741 221.171 368.494 222.221 368.494 223.354V225.941C368.494 227.458 368.759 228.75 369.293 229.814C369.834 230.878 370.589 231.691 371.554 232.253C372.52 232.805 373.649 233.081 374.94 233.081C375.778 233.081 376.543 232.962 377.233 232.726C377.922 232.48 378.518 232.115 379.021 231.632C379.525 231.149 379.908 230.553 380.176 229.843L384.358 230.597C384.025 231.829 383.424 232.908 382.555 233.835C381.698 234.751 380.618 235.465 379.317 235.978C378.026 236.48 376.551 236.732 374.896 236.732Z"
+ fill="#C0C0C0"
+ />
+ <path
+ d="M96.4754 244.508C96.4754 247.382 94.1452 249.712 91.2705 249.712H47.4236C44.5489 249.712 42.2188 247.382 42.2188 244.508V239.399H96.4754V244.508ZM47.3676 239.45V244.328H91.4358V239.45H47.3676ZM91.2705 199.109C94.1452 199.109 96.4754 201.439 96.4754 204.313V209.173H42.2188V204.313C42.2188 201.439 44.5489 199.11 47.4236 199.109H91.2705ZM47.3676 204.262V209.14H91.4358V204.262H47.3676Z"
+ fill="white"
+ />
+ <path
+ d="M39.3633 235.03V214.217H44.3953V230.945H53.0564V235.03H39.3633ZM60.6934 214.217V235.03H55.6614V214.217H60.6934ZM68.5795 214.217L73.2352 229.461H73.408L78.0639 214.217H83.6956L76.6711 235.03H69.972L62.9476 214.217H68.5795ZM85.9373 235.03V214.217H100.454V218.303H90.9693V222.571H99.7116V226.666H90.9693V230.945H100.454V235.03H85.9373Z"
+ fill="white"
+ />
+ <path
+ d="M59.0617 189.84L58.7406 190.161C57.7243 191.177 57.7243 192.824 58.7406 193.84L67.204 202.301C68.2203 203.317 69.868 203.317 70.8843 202.301L71.2054 201.98C72.2217 200.964 72.2217 199.317 71.2054 198.301L62.742 189.84C61.7257 188.824 60.078 188.824 59.0617 189.84Z"
+ fill="white"
+ />
+ <path
+ d="M80.4113 190.161L80.0902 189.84C79.0739 188.824 77.4261 188.824 76.4098 189.84L67.9464 198.3C66.9301 199.316 66.9301 200.964 67.9464 201.98L68.2675 202.301C69.2839 203.317 70.9316 203.317 71.9479 202.301L80.4113 193.84C81.4276 192.824 81.4276 191.177 80.4113 190.161Z"
+ fill="white"
+ />
+ <path
+ d="M79.951 41.7658V65.0386H76.4283V45.2886H76.2919L70.7237 48.9249V45.5613L76.5305 41.7658H79.951ZM95.716 41.7658V65.0386H92.1933V45.2886H92.0569L86.4887 48.9249V45.5613L92.2955 41.7658H95.716ZM104.936 62.1522C104.314 62.1522 103.78 61.9325 103.333 61.4931C102.886 61.0461 102.663 60.5083 102.663 59.8795C102.663 59.2583 102.886 58.728 103.333 58.2886C103.78 57.8416 104.314 57.6181 104.936 57.6181C105.557 57.6181 106.091 57.8416 106.538 58.2886C106.985 58.728 107.208 59.2583 107.208 59.8795C107.208 60.2961 107.102 60.6787 106.89 61.0272C106.686 61.3681 106.413 61.6408 106.072 61.8454C105.731 62.0499 105.352 62.1522 104.936 62.1522ZM104.936 50.4931C104.314 50.4931 103.78 50.2734 103.333 49.834C102.886 49.3871 102.663 48.8492 102.663 48.2204C102.663 47.5992 102.886 47.0689 103.333 46.6295C103.78 46.1825 104.314 45.959 104.936 45.959C105.557 45.959 106.091 46.1825 106.538 46.6295C106.985 47.0689 107.208 47.5992 107.208 48.2204C107.208 48.6371 107.102 49.0196 106.89 49.3681C106.686 49.709 106.413 49.9818 106.072 50.1863C105.731 50.3908 105.352 50.4931 104.936 50.4931ZM120.036 65.3568C118.612 65.3568 117.331 65.084 116.195 64.5386C115.066 63.9855 114.165 63.228 113.49 62.2658C112.816 61.3037 112.456 60.2052 112.411 58.9704H115.82C115.903 59.9704 116.346 60.7924 117.149 61.4363C117.952 62.0802 118.915 62.4022 120.036 62.4022C120.93 62.4022 121.721 62.1977 122.411 61.7886C123.108 61.3719 123.653 60.7999 124.047 60.0727C124.449 59.3454 124.649 58.5158 124.649 57.584C124.649 56.6371 124.445 55.7924 124.036 55.0499C123.627 54.3075 123.062 53.7242 122.343 53.2999C121.63 52.8757 120.812 52.6598 119.888 52.6522C119.183 52.6522 118.475 52.7734 117.763 53.0158C117.051 53.2583 116.475 53.5765 116.036 53.9704L112.82 53.4931L114.127 41.7658H126.922V44.7772H117.047L116.308 51.2886H116.445C116.899 50.8492 117.502 50.4818 118.252 50.1863C119.009 49.8908 119.82 49.7431 120.683 49.7431C122.1 49.7431 123.362 50.0802 124.468 50.7545C125.581 51.4287 126.456 52.3492 127.093 53.5158C127.737 54.6749 128.055 56.0083 128.047 57.5158C128.055 59.0234 127.714 60.3681 127.024 61.5499C126.343 62.7318 125.396 63.6636 124.183 64.3454C122.979 65.0196 121.596 65.3568 120.036 65.3568ZM132.838 65.0386V62.4931L140.713 54.334C141.554 53.4477 142.247 52.6711 142.792 52.0045C143.345 51.3302 143.758 50.6901 144.031 50.084C144.304 49.478 144.44 48.834 144.44 48.1522C144.44 47.3795 144.258 46.7128 143.895 46.1522C143.531 45.584 143.035 45.1484 142.406 44.8454C141.777 44.5348 141.069 44.3795 140.281 44.3795C139.448 44.3795 138.72 44.5499 138.099 44.8908C137.478 45.2318 137.001 45.7128 136.667 46.334C136.334 46.9552 136.167 47.6825 136.167 48.5158H132.815C132.815 47.0992 133.141 45.8605 133.792 44.7999C134.444 43.7393 135.338 42.9174 136.474 42.334C137.61 41.7431 138.902 41.4477 140.349 41.4477C141.811 41.4477 143.099 41.7393 144.213 42.3227C145.334 42.8984 146.209 43.6863 146.838 44.6863C147.466 45.6787 147.781 46.7999 147.781 48.0499C147.781 48.9136 147.618 49.7583 147.292 50.584C146.974 51.4098 146.417 52.3302 145.622 53.3454C144.826 54.353 143.72 55.5765 142.304 57.0158L137.679 61.8568V62.0272H148.156V65.0386H132.838Z"
+ fill="white"
+ />
+ <rect
+ x="906.516"
+ y="40.8203"
+ width="7.56738"
+ height="24.4583"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="895.677"
+ y="46.0703"
+ width="7.56738"
+ height="19.2083"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="884.838"
+ y="51.4316"
+ width="7.56738"
+ height="13.8469"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="874"
+ y="55.7026"
+ width="7.56738"
+ height="9.57568"
+ rx="2"
+ fill="white"
+ />
+ <path
+ d="M941.078 40.9033C934.711 40.9033 928.344 43.4627 923.774 47.6293C923.516 47.8578 923.508 48.2463 923.75 48.4977L925.836 50.6229C926.078 50.8742 926.485 50.8818 926.742 50.6457C928.563 49.0004 930.641 47.6902 932.938 46.7609C935.516 45.7098 938.258 45.1842 941.086 45.1842C943.914 45.1842 946.656 45.7174 949.235 46.7609C951.531 47.6979 953.61 49.0004 955.43 50.6457C955.688 50.8818 956.094 50.8742 956.336 50.6229L958.422 48.4977C958.664 48.2539 958.656 47.8654 958.399 47.6293C953.813 43.4627 947.445 40.9033 941.078 40.9033Z"
+ fill="white"
+ />
+ <path
+ d="M929.922 54.7285L932.156 56.8842C932.399 57.1127 932.781 57.1279 933.031 56.907C935.242 54.9951 938.078 53.9439 941.07 53.9439C944.063 53.9439 946.899 54.9875 949.11 56.907C949.36 57.1279 949.742 57.1127 949.985 56.8842L952.219 54.7285C952.477 54.4771 952.469 54.0734 952.195 53.8373C949.266 51.2551 945.352 49.6783 941.063 49.6783C936.774 49.6783 932.86 51.2551 929.93 53.8373C929.672 54.0734 929.664 54.4771 929.922 54.7285ZM941.078 58.2857C939.25 58.2857 937.594 59.0322 936.438 60.2281C936.203 60.4719 936.211 60.8451 936.453 61.0812L940.625 65.0955C940.875 65.3393 941.281 65.3393 941.531 65.0955L945.703 61.0812C945.945 60.8451 945.953 60.4719 945.719 60.2281C944.563 59.0398 942.906 58.2857 941.078 58.2857Z"
+ fill="white"
+ />
+ <rect
+ x="968.492"
+ y="38.5908"
+ width="51.4128"
+ height="29"
+ rx="8"
+ fill="#34C759"
+ />
+ <path
+ d="M980.991 44.1363V61.5908H977.301V47.6391H977.198L973.201 50.1448V46.8721L977.522 44.1363H980.991ZM991.743 61.9743C990.277 61.9687 989.016 61.6079 987.959 60.892C986.908 60.176 986.098 59.1391 985.53 57.7812C984.968 56.4232 984.689 54.7897 984.695 52.8806C984.695 50.9772 984.976 49.355 985.539 48.0141C986.107 46.6732 986.916 45.6533 987.968 44.9545C989.024 44.2499 990.283 43.8976 991.743 43.8976C993.203 43.8976 994.459 44.2499 995.51 44.9545C996.567 45.659 997.379 46.6817 997.948 48.0226C998.516 49.3579 998.797 50.9772 998.791 52.8806C998.791 54.7954 998.507 56.4317 997.939 57.7897C997.377 59.1476 996.57 60.1846 995.519 60.9005C994.468 61.6164 993.209 61.9743 991.743 61.9743ZM991.743 58.9147C992.743 58.9147 993.541 58.4118 994.138 57.4062C994.735 56.4005 995.03 54.892 995.024 52.8806C995.024 51.5567 994.888 50.4545 994.615 49.5738C994.348 48.6931 993.968 48.0312 993.473 47.588C992.985 47.1448 992.408 46.9232 991.743 46.9232C990.749 46.9232 989.953 47.4204 989.357 48.4147C988.76 49.409 988.459 50.8976 988.453 52.8806C988.453 54.2215 988.587 55.3408 988.854 56.2385C989.127 57.1306 989.51 57.801 990.004 58.2499C990.499 58.6931 991.078 58.9147 991.743 58.9147ZM1008.03 61.9743C1006.56 61.9687 1005.3 61.6079 1004.24 60.892C1003.19 60.176 1002.38 59.1391 1001.81 57.7812C1001.25 56.4232 1000.97 54.7897 1000.98 52.8806C1000.98 50.9772 1001.26 49.355 1001.82 48.0141C1002.39 46.6732 1003.2 45.6533 1004.25 44.9545C1005.31 44.2499 1006.57 43.8976 1008.03 43.8976C1009.49 43.8976 1010.74 44.2499 1011.79 44.9545C1012.85 45.659 1013.66 46.6817 1014.23 48.0226C1014.8 49.3579 1015.08 50.9772 1015.07 52.8806C1015.07 54.7954 1014.79 56.4317 1014.22 57.7897C1013.66 59.1476 1012.85 60.1846 1011.8 60.9005C1010.75 61.6164 1009.49 61.9743 1008.03 61.9743ZM1008.03 58.9147C1009.03 58.9147 1009.82 58.4118 1010.42 57.4062C1011.02 56.4005 1011.31 54.892 1011.31 52.8806C1011.31 51.5567 1011.17 50.4545 1010.9 49.5738C1010.63 48.6931 1010.25 48.0312 1009.76 47.588C1009.27 47.1448 1008.69 46.9232 1008.03 46.9232C1007.03 46.9232 1006.24 47.4204 1005.64 48.4147C1005.04 49.409 1004.74 50.8976 1004.74 52.8806C1004.74 54.2215 1004.87 55.3408 1005.14 56.2385C1005.41 57.1306 1005.79 57.801 1006.29 58.2499C1006.78 58.6931 1007.36 58.9147 1008.03 58.9147Z"
+ fill="white"
+ />
+ <path
+ d="M1022.16 48.2734V48.2734C1023.94 48.2734 1025.38 49.7175 1025.38 51.4988V54.6167C1025.38 56.398 1023.94 57.842 1022.16 57.842V57.842V48.2734Z"
+ fill="#34C759"
+ />
+ <defs>
+ <filter
+ id="filter0_d_5_2"
+ x="954.752"
+ y="1684"
+ width="78.1084"
+ height="32.5605"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter1_d_5_2"
+ x="960.448"
+ y="1512"
+ width="68.2227"
+ height="27.1934"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter2_d_5_2"
+ x="954.375"
+ y="1341"
+ width="77.6875"
+ height="32.5605"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter3_d_5_2"
+ x="945.502"
+ y="1170"
+ width="96.333"
+ height="26.353"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ </defs>
+ </svg>
+ );
+}
diff --git a/apps/web/src/lib/guides/index.ts b/apps/web/src/lib/guides/index.ts index 007e6fda..9ea75631 100644 --- a/apps/web/src/lib/guides/index.ts +++ b/apps/web/src/lib/guides/index.ts @@ -1,13 +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; -} +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;
+}
diff --git a/apps/web/src/lib/guides/registry.tsx b/apps/web/src/lib/guides/registry.tsx index 60dec428..8000d989 100644 --- a/apps/web/src/lib/guides/registry.tsx +++ b/apps/web/src/lib/guides/registry.tsx @@ -1,28 +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); -} +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);
+}
diff --git a/apps/web/src/lib/guides/types.ts b/apps/web/src/lib/guides/types.ts index 340101ac..5b776090 100644 --- a/apps/web/src/lib/guides/types.ts +++ b/apps/web/src/lib/guides/types.ts @@ -1,20 +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; -} +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;
+}
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts index 3ec96344..41093c88 100644 --- a/apps/web/src/lib/masks/__tests__/snap.test.ts +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -1,235 +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 }]); - }); -}); +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 }]);
+ });
+});
diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts index 0436151e..98aefba7 100644 --- a/apps/web/src/lib/masks/definitions/box-like.ts +++ b/apps/web/src/lib/masks/definitions/box-like.ts @@ -1,270 +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, - }; -} +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,
+ };
+}
diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts index 45d0a7c0..1fd0301c 100644 --- a/apps/web/src/lib/masks/definitions/ellipse.ts +++ b/apps/web/src/lib/masks/definitions/ellipse.ts @@ -1,72 +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; - }, - }, -}; +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;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts index c2affe30..49404ce1 100644 --- a/apps/web/src/lib/masks/definitions/index.ts +++ b/apps/web/src/lib/masks/definitions/index.ts @@ -1,39 +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 }, - }); -} +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 },
+ });
+}
diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts index 5b7f84a9..08f969ea 100644 --- a/apps/web/src/lib/masks/definitions/rectangle.ts +++ b/apps/web/src/lib/masks/definitions/rectangle.ts @@ -1,94 +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, - }); - }, - }, -}; +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,
+ });
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/split.ts b/apps/web/src/lib/masks/definitions/split.ts index 43f02db1..91cd3001 100644 --- a/apps/web/src/lib/masks/definitions/split.ts +++ b/apps/web/src/lib/masks/definitions/split.ts @@ -1,347 +1,347 @@ -import { computeFeatherUpdate } from "../param-update"; -import type { ParamValues } from "@/lib/params"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - SplitMaskParams, -} from "@/lib/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; - -// cos(Ï€/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped -// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie -// exactly on the split line, which produces spurious midpoint vertices. -const NORMAL_SNAP_EPSILON = 1e-10; - -// Guards against collinear vertices from float noise at canvas edges. -const MIN_POLYGON_AREA_PX = 0.5; -const INTERSECTION_EPSILON = 1e-6; - -function polygonArea({ vertices }: { vertices: [number, number][] }): number { - let area = 0; - for (let i = 0; i < vertices.length; i++) { - const [x1, y1] = vertices[i]; - const [x2, y2] = vertices[(i + 1) % vertices.length]; - area += x1 * y2 - x2 * y1; - } - return Math.abs(area) * 0.5; -} - -function splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, -}: { - centerX: number; - centerY: number; - rotation: number; - width: number; - height: number; -}): { normalX: number; normalY: number; lineX: number; lineY: number } { - const angleRad = (rotation * Math.PI) / 180; - const normalX = - Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); - const normalY = - Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); - const lineX = width / 2 + centerX * width; - const lineY = height / 2 + centerY * height; - return { normalX, normalY, lineX, lineY }; -} - -function pointsEqual( - a: { x: number; y: number }, - b: { x: number; y: number }, -): boolean { - return ( - Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && - Math.abs(a.y - b.y) <= INTERSECTION_EPSILON - ); -} - -export function getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, -}: { - resolvedParams: unknown; - width: number; - height: number; -}): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - const intersections: { x: number; y: number }[] = []; - - for (const [x1, y1, x2, y2] of edges) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - - if (!hit || intersections.some((point) => pointsEqual(point, hit))) { - continue; - } - - intersections.push(hit); - } - - if (intersections.length !== 2) { - return null; - } - - return [intersections[0], intersections[1]]; -} - -function computeSplitMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, - canvasSize, -}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues { - if (handleId === "position") { - const rawX = startParams.centerX + deltaX / bounds.width; - const rawY = startParams.centerY + deltaY / bounds.height; - - const minX = -bounds.cx / bounds.width; - const maxX = (canvasSize.width - bounds.cx) / bounds.width; - const minY = -bounds.cy / bounds.height; - const maxY = (canvasSize.height - bounds.cy) / bounds.height; - - return { - centerX: Math.max(minX, Math.min(maxX, rawX)), - centerY: Math.max(minY, Math.min(maxY, rawY)), - }; - } - - if (handleId === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.cos(angleRad), - directionY: -Math.sin(angleRad), - }); - } - - if (handleId === "rotation") { - const pivotX = bounds.cx + startParams.centerX * bounds.width; - const pivotY = bounds.cy + startParams.centerY * bounds.height; - const startAngle = - (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / - Math.PI; - const currentAngle = - (Math.atan2( - startCanvasY + deltaY - pivotY, - startCanvasX + deltaX - pivotX, - ) * - 180) / - Math.PI; - - let deltaAngle = currentAngle - startAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - - return { - rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, - }; - } - - return {}; -} - -export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { - type: "split", - name: "Split", - overlayShape: "line", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "none", - }, - buildDefault() { - return { - type: "split", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - centerX: 0, - centerY: 0, - rotation: 0, - }, - }; - }, - computeParamUpdate: computeSplitMaskParamUpdate, - params: [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - ], - renderer: { - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. - const featherHalf = feather / 2; - const gradient = ctx.createLinearGradient( - lineX - normalX * featherHalf, - lineY - normalY * featherHalf, - lineX + normalX * featherHalf, - lineY + normalY * featherHalf, - ); - gradient.addColorStop(0, "rgba(255,255,255,0)"); - gradient.addColorStop(1, "white"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - }, - - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - - const isInsideHalfPlane = (x: number, y: number) => - halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; - - const vertices: [number, number][] = []; - for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane(x1, y1); - const isVertex2Inside = isInsideHalfPlane(x2, y2); - - if (isVertex1Inside && isVertex2Inside) { - vertices.push([x2, y2]); - } else if (isVertex1Inside && !isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) vertices.push([hit.x, hit.y]); - } else if (!isVertex1Inside && isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) { - vertices.push([hit.x, hit.y]); - vertices.push([x2, y2]); - } - } - } - - if ( - vertices.length < 3 || - polygonArea({ vertices }) < MIN_POLYGON_AREA_PX - ) { - return new Path2D(); - } - - const path = new Path2D(); - path.moveTo(vertices[0][0], vertices[0][1]); - for (let i = 1; i < vertices.length; i++) { - path.lineTo(vertices[i][0], vertices[i][1]); - } - path.closePath(); - return path; - }, - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); - - if (!segment) { - return path; - } - - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; - }, - }, -}; +import { computeFeatherUpdate } from "../param-update";
+import type { ParamValues } from "@/lib/params";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ SplitMaskParams,
+} from "@/lib/masks/types";
+import { halfPlaneSign, lineEdgeIntersection } from "../utils";
+
+// cos(Ï€/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
+// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
+// exactly on the split line, which produces spurious midpoint vertices.
+const NORMAL_SNAP_EPSILON = 1e-10;
+
+// Guards against collinear vertices from float noise at canvas edges.
+const MIN_POLYGON_AREA_PX = 0.5;
+const INTERSECTION_EPSILON = 1e-6;
+
+function polygonArea({ vertices }: { vertices: [number, number][] }): number {
+ let area = 0;
+ for (let i = 0; i < vertices.length; i++) {
+ const [x1, y1] = vertices[i];
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
+ area += x1 * y2 - x2 * y1;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ width: number;
+ height: number;
+}): { normalX: number; normalY: number; lineX: number; lineY: number } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX =
+ Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
+ const normalY =
+ Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
+ const lineX = width / 2 + centerX * width;
+ const lineY = height / 2 + centerY * height;
+ return { normalX, normalY, lineX, lineY };
+}
+
+function pointsEqual(
+ a: { x: number; y: number },
+ b: { x: number; y: number },
+): boolean {
+ return (
+ Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
+ Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
+ );
+}
+
+export function getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+}: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+}): [{ x: number; y: number }, { x: number; y: number }] | null {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+ const intersections: { x: number; y: number }[] = [];
+
+ for (const [x1, y1, x2, y2] of edges) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+
+ if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
+ continue;
+ }
+
+ intersections.push(hit);
+ }
+
+ if (intersections.length !== 2) {
+ return null;
+ }
+
+ return [intersections[0], intersections[1]];
+}
+
+function computeSplitMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+ canvasSize,
+}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
+ if (handleId === "position") {
+ const rawX = startParams.centerX + deltaX / bounds.width;
+ const rawY = startParams.centerY + deltaY / bounds.height;
+
+ const minX = -bounds.cx / bounds.width;
+ const maxX = (canvasSize.width - bounds.cx) / bounds.width;
+ const minY = -bounds.cy / bounds.height;
+ const maxY = (canvasSize.height - bounds.cy) / bounds.height;
+
+ return {
+ centerX: Math.max(minX, Math.min(maxX, rawX)),
+ centerY: Math.max(minY, Math.min(maxY, rawY)),
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.cos(angleRad),
+ directionY: -Math.sin(angleRad),
+ });
+ }
+
+ if (handleId === "rotation") {
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ return {};
+}
+
+export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
+ type: "split",
+ name: "Split",
+ overlayShape: "line",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "none",
+ },
+ buildDefault() {
+ return {
+ type: "split",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ },
+ };
+ },
+ computeParamUpdate: computeSplitMaskParamUpdate,
+ params: [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ ],
+ renderer: {
+ renderMask({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
+ const featherHalf = feather / 2;
+ const gradient = ctx.createLinearGradient(
+ lineX - normalX * featherHalf,
+ lineY - normalY * featherHalf,
+ lineX + normalX * featherHalf,
+ lineY + normalY * featherHalf,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
+ gradient.addColorStop(1, "white");
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+ },
+
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+
+ const isInsideHalfPlane = (x: number, y: number) =>
+ halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
+
+ const vertices: [number, number][] = [];
+ for (const [x1, y1, x2, y2] of edges) {
+ const isVertex1Inside = isInsideHalfPlane(x1, y1);
+ const isVertex2Inside = isInsideHalfPlane(x2, y2);
+
+ if (isVertex1Inside && isVertex2Inside) {
+ vertices.push([x2, y2]);
+ } else if (isVertex1Inside && !isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) vertices.push([hit.x, hit.y]);
+ } else if (!isVertex1Inside && isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) {
+ vertices.push([hit.x, hit.y]);
+ vertices.push([x2, y2]);
+ }
+ }
+ }
+
+ if (
+ vertices.length < 3 ||
+ polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
+ ) {
+ return new Path2D();
+ }
+
+ const path = new Path2D();
+ path.moveTo(vertices[0][0], vertices[0][1]);
+ for (let i = 1; i < vertices.length; i++) {
+ path.lineTo(vertices[i][0], vertices[i][1]);
+ }
+ path.closePath();
+ return path;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+ });
+ const path = new Path2D();
+
+ if (!segment) {
+ return path;
+ }
+
+ path.moveTo(segment[0].x, segment[0].y);
+ path.lineTo(segment[1].x, segment[1].y);
+ return path;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts index c6d386f8..0d4d3905 100644 --- a/apps/web/src/lib/masks/geometry.ts +++ b/apps/web/src/lib/masks/geometry.ts @@ -1,112 +1,112 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { MIN_MASK_DIMENSION } from "@/constants/mask-constants"; -import type { ParamValues } from "@/lib/params"; -import type { RectangleMaskParams } from "@/lib/masks/types"; - -export function hasCenterParams(params: ParamValues): params is ParamValues & { - centerX: number; - centerY: number; -} { - return ( - typeof params.centerX === "number" && typeof params.centerY === "number" - ); -} - -export function isRectangleMaskParams( - params: ParamValues, -): params is RectangleMaskParams { - return ( - hasCenterParams(params) && - typeof params.width === "number" && - typeof params.height === "number" && - typeof params.rotation === "number" && - typeof params.scale === "number" - ); -} - -export function getMaskLocalCenter({ - params, - bounds, -}: { - params: ParamValues; - bounds: ElementBounds; -}): { x: number; y: number } | null { - if (!hasCenterParams(params)) return null; - - return { - x: params.centerX * bounds.width, - y: params.centerY * bounds.height, - }; -} - -export function setMaskLocalCenter({ - center, - bounds, -}: { - center: { x: number; y: number }; - bounds: ElementBounds; -}): Pick<ParamValues, "centerX" | "centerY"> { - return { - centerX: bounds.width === 0 ? 0 : center.x / bounds.width, - centerY: bounds.height === 0 ? 0 : center.y / bounds.height, - }; -} - -export function getMaskSnapGeometry({ - params, - bounds, -}: { - params: ParamValues; - bounds: ElementBounds; -}): { - position: { x: number; y: number }; - size: { width: number; height: number }; - rotation: number; -} | null { - const position = getMaskLocalCenter({ params, bounds }); - if (!position) return null; - - if (isRectangleMaskParams(params)) { - return { - position, - size: { - width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width, - height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height, - }, - rotation: params.rotation, - }; - } - - return { - position, - size: { width: 0, height: 0 }, - rotation: typeof params.rotation === "number" ? params.rotation : 0, - }; -} - -export function toGlobalMaskSnapLines({ - lines, - bounds, - canvasSize, -}: { - lines: SnapLine[]; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -}): SnapLine[] { - const centerX = bounds.cx - canvasSize.width / 2; - const centerY = bounds.cy - canvasSize.height / 2; - - return lines.map((line) => - line.type === "vertical" - ? { - type: "vertical" as const, - position: centerX + line.position, - } - : { - type: "horizontal" as const, - position: centerY + line.position, - }, - ); -} - +import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { MIN_MASK_DIMENSION } from "@/constants/mask-constants";
+import type { ParamValues } from "@/lib/params";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+
+export function hasCenterParams(params: ParamValues): params is ParamValues & {
+ centerX: number;
+ centerY: number;
+} {
+ return (
+ typeof params.centerX === "number" && typeof params.centerY === "number"
+ );
+}
+
+export function isRectangleMaskParams(
+ params: ParamValues,
+): params is RectangleMaskParams {
+ return (
+ hasCenterParams(params) &&
+ typeof params.width === "number" &&
+ typeof params.height === "number" &&
+ typeof params.rotation === "number" &&
+ typeof params.scale === "number"
+ );
+}
+
+export function getMaskLocalCenter({
+ params,
+ bounds,
+}: {
+ params: ParamValues;
+ bounds: ElementBounds;
+}): { x: number; y: number } | null {
+ if (!hasCenterParams(params)) return null;
+
+ return {
+ x: params.centerX * bounds.width,
+ y: params.centerY * bounds.height,
+ };
+}
+
+export function setMaskLocalCenter({
+ center,
+ bounds,
+}: {
+ center: { x: number; y: number };
+ bounds: ElementBounds;
+}): Pick<ParamValues, "centerX" | "centerY"> {
+ return {
+ centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
+ centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
+ };
+}
+
+export function getMaskSnapGeometry({
+ params,
+ bounds,
+}: {
+ params: ParamValues;
+ bounds: ElementBounds;
+}): {
+ position: { x: number; y: number };
+ size: { width: number; height: number };
+ rotation: number;
+} | null {
+ const position = getMaskLocalCenter({ params, bounds });
+ if (!position) return null;
+
+ if (isRectangleMaskParams(params)) {
+ return {
+ position,
+ size: {
+ width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
+ height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
+ },
+ rotation: params.rotation,
+ };
+ }
+
+ return {
+ position,
+ size: { width: 0, height: 0 },
+ rotation: typeof params.rotation === "number" ? params.rotation : 0,
+ };
+}
+
+export function toGlobalMaskSnapLines({
+ lines,
+ bounds,
+ canvasSize,
+}: {
+ lines: SnapLine[];
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}): SnapLine[] {
+ const centerX = bounds.cx - canvasSize.width / 2;
+ const centerY = bounds.cy - canvasSize.height / 2;
+
+ return lines.map((line) =>
+ line.type === "vertical"
+ ? {
+ type: "vertical" as const,
+ position: centerX + line.position,
+ }
+ : {
+ type: "horizontal" as const,
+ position: centerY + line.position,
+ },
+ );
+}
+
diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts index 753b17ce..92133df6 100644 --- a/apps/web/src/lib/masks/handle-positions.ts +++ b/apps/web/src/lib/masks/handle-positions.ts @@ -1,382 +1,382 @@ -import { FEATHER_HANDLE_SCALE } from "@/constants/mask-constants"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { - MaskFeatures, - MaskHandlePosition, - MaskLinePoints, - MaskOverlayShape, -} from "@/lib/masks/types"; -import type { ParamValues } from "@/lib/params"; - -const LINE_HANDLE_OFFSET_SCREEN_PX = 20; -const BOX_HANDLE_OFFSET_SCREEN_PX = 20; -const LINE_EXTENT_MULTIPLIER = 50; - -const CURSOR = { - rotate: "cursor-crosshair", - resizeDiagonal: "cursor-nwse-resize", - resizeHorizontal: "cursor-ew-resize", - resizeVertical: "cursor-ns-resize", -} as const; - -function getNumParam({ - params, - key, - fallback, -}: { - params: ParamValues; - key: string; - fallback: number; -}): number { - const value = params[key]; - return typeof value === "number" && !Number.isNaN(value) ? value : fallback; -} - -/** - * The renderer defines the split line as: - * - normal direction: (cos(rotation), sin(rotation)) - * - line direction (parallel to cut): (-sin(rotation), cos(rotation)) - * - reference point: (centerX * width, centerY * height) from element centre - * - * So rotation=0 → normal points right → line runs vertically. - */ -export function getLineMaskLinePoints({ - centerX, - centerY, - rotation, - bounds, -}: { - centerX: number; - centerY: number; - rotation: number; - bounds: ElementBounds; -}): MaskLinePoints { - const angleRad = (rotation * Math.PI) / 180; - const normalX = Math.cos(angleRad); - const normalY = Math.sin(angleRad); - const lineDirX = -normalY; - const lineDirY = normalX; - - const cx = bounds.cx + centerX * bounds.width; - const cy = bounds.cy + centerY * bounds.height; - - const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER; - - return { - start: { - x: cx - lineDirX * extent, - y: cy - lineDirY * extent, - }, - end: { - x: cx + lineDirX * extent, - y: cy + lineDirY * extent, - }, - }; -} - -export function getLineMaskHandlePositions({ - centerX, - centerY, - rotation, - feather, - bounds, - displayScale, -}: { - centerX: number; - centerY: number; - rotation: number; - feather: number; - bounds: ElementBounds; - displayScale: number; -}): MaskHandlePosition[] { - const angleRad = (rotation * Math.PI) / 180; - const normalX = Math.cos(angleRad); - const normalY = Math.sin(angleRad); - - const cx = bounds.cx + centerX * bounds.width; - const cy = bounds.cy + centerY * bounds.height; - - const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale; - const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE; - - return [ - { - id: "rotation", - x: cx + normalX * iconOffsetCanvas, - y: cy + normalY * iconOffsetCanvas, - cursor: CURSOR.rotate, - }, - { - id: "feather", - x: cx - normalX * featherOffset, - y: cy - normalY * featherOffset, - cursor: CURSOR.resizeHorizontal, - }, - ]; -} - -function rotatePoint({ - localX, - localY, - cx, - cy, - angleRad, -}: { - localX: number; - localY: number; - cx: number; - cy: number; - angleRad: number; -}): { x: number; y: number } { - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - return { - x: cx + localX * cos - localY * sin, - y: cy + localX * sin + localY * cos, - }; -} - -export function getBoxMaskHandlePositions({ - centerX, - centerY, - width, - height, - rotation, - feather, - sizeMode, - bounds, - displayScale, -}: { - centerX: number; - centerY: number; - width: number; - height: number; - rotation: number; - feather: number; - sizeMode: MaskFeatures["sizeMode"]; - bounds: ElementBounds; - displayScale: number; -}): MaskHandlePosition[] { - const cx = bounds.cx + centerX * bounds.width; - const cy = bounds.cy + centerY * bounds.height; - const angleRad = (rotation * Math.PI) / 180; - const halfWidth = (width * bounds.width) / 2; - const halfHeight = (height * bounds.height) / 2; - - const handles: MaskHandlePosition[] = []; - const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale; - - const rotHandle = rotatePoint({ - localX: 0, - localY: -halfHeight - handleOffsetCanvas, - cx, - cy, - angleRad, - }); - handles.push({ - id: "rotation", - x: rotHandle.x, - y: rotHandle.y, - cursor: CURSOR.rotate, - }); - - const featherHandle = rotatePoint({ - localX: 0, - localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE, - cx, - cy, - angleRad, - }); - handles.push({ - id: "feather", - x: featherHandle.x, - y: featherHandle.y, - cursor: CURSOR.resizeVertical, - }); - - if (sizeMode === "width-height") { - const corners = [ - { localX: -halfWidth, localY: -halfHeight, id: "top-left" }, - { localX: halfWidth, localY: -halfHeight, id: "top-right" }, - { localX: halfWidth, localY: halfHeight, id: "bottom-right" }, - { localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, - ]; - for (const { localX, localY, id } of corners) { - const point = rotatePoint({ localX, localY, cx, cy, angleRad }); - handles.push({ - id, - x: point.x, - y: point.y, - cursor: CURSOR.resizeDiagonal, - }); - } - const right = rotatePoint({ - localX: halfWidth, - localY: 0, - cx, - cy, - angleRad, - }); - const left = rotatePoint({ - localX: -halfWidth, - localY: 0, - cx, - cy, - angleRad, - }); - const bottom = rotatePoint({ - localX: 0, - localY: halfHeight, - cx, - cy, - angleRad, - }); - handles.push({ - id: "left", - x: left.x, - y: left.y, - cursor: CURSOR.resizeHorizontal, - }); - handles.push({ - id: "right", - x: right.x, - y: right.y, - cursor: CURSOR.resizeHorizontal, - }); - handles.push({ - id: "bottom", - x: bottom.x, - y: bottom.y, - cursor: CURSOR.resizeVertical, - }); - } else if (sizeMode === "height-only") { - const top = rotatePoint({ - localX: 0, - localY: -halfHeight, - cx, - cy, - angleRad, - }); - const bottom = rotatePoint({ - localX: 0, - localY: halfHeight, - cx, - cy, - angleRad, - }); - handles.push({ - id: "top", - x: top.x, - y: top.y, - cursor: CURSOR.resizeVertical, - }); - handles.push({ - id: "bottom", - x: bottom.x, - y: bottom.y, - cursor: CURSOR.resizeVertical, - }); - } else if (sizeMode === "width-only") { - const left = rotatePoint({ - localX: -halfWidth, - localY: 0, - cx, - cy, - angleRad, - }); - const right = rotatePoint({ - localX: halfWidth, - localY: 0, - cx, - cy, - angleRad, - }); - handles.push({ - id: "left", - x: left.x, - y: left.y, - cursor: CURSOR.resizeHorizontal, - }); - handles.push({ - id: "right", - x: right.x, - y: right.y, - cursor: CURSOR.resizeHorizontal, - }); - } else if (sizeMode === "uniform") { - const point = rotatePoint({ - localX: halfWidth, - localY: halfHeight, - cx, - cy, - angleRad, - }); - handles.push({ - id: "scale", - x: point.x, - y: point.y, - cursor: CURSOR.resizeDiagonal, - }); - } - - return handles; -} - -type MaskHandleResolver = (args: { - features: MaskFeatures; - params: ParamValues; - bounds: ElementBounds; - displayScale: number; -}) => MaskHandlePosition[]; - -const rectangleHandles: MaskHandleResolver = ({ - features, - params, - bounds, - displayScale, -}) => - getBoxMaskHandlePositions({ - centerX: getNumParam({ params, key: "centerX", fallback: 0 }), - centerY: getNumParam({ params, key: "centerY", fallback: 0 }), - width: getNumParam({ params, key: "width", fallback: 1 }), - height: getNumParam({ params, key: "height", fallback: 1 }), - rotation: getNumParam({ params, key: "rotation", fallback: 0 }), - feather: getNumParam({ params, key: "feather", fallback: 0 }), - sizeMode: features.sizeMode, - bounds, - displayScale, - }); - -const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = { - line: ({ params, bounds, displayScale }) => - getLineMaskHandlePositions({ - centerX: getNumParam({ params, key: "centerX", fallback: 0 }), - centerY: getNumParam({ params, key: "centerY", fallback: 0 }), - rotation: getNumParam({ params, key: "rotation", fallback: 0 }), - feather: getNumParam({ params, key: "feather", fallback: 0 }), - bounds, - displayScale, - }), - box: rectangleHandles, -}; - -export function getMaskHandlePositions({ - overlayShape, - features, - params, - bounds, - displayScale, -}: { - overlayShape: MaskOverlayShape; - features: MaskFeatures; - params: ParamValues; - bounds: ElementBounds; - displayScale: number; -}): MaskHandlePosition[] { - return HANDLE_RESOLVERS[overlayShape]({ - features, - params, - bounds, - displayScale, - }); -} +import { FEATHER_HANDLE_SCALE } from "@/constants/mask-constants";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type {
+ MaskFeatures,
+ MaskHandlePosition,
+ MaskLinePoints,
+ MaskOverlayShape,
+} from "@/lib/masks/types";
+import type { ParamValues } from "@/lib/params";
+
+const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
+const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
+const LINE_EXTENT_MULTIPLIER = 50;
+
+const CURSOR = {
+ rotate: "cursor-crosshair",
+ resizeDiagonal: "cursor-nwse-resize",
+ resizeHorizontal: "cursor-ew-resize",
+ resizeVertical: "cursor-ns-resize",
+} as const;
+
+function getNumParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
+}
+
+/**
+ * The renderer defines the split line as:
+ * - normal direction: (cos(rotation), sin(rotation))
+ * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
+ * - reference point: (centerX * width, centerY * height) from element centre
+ *
+ * So rotation=0 → normal points right → line runs vertically.
+ */
+export function getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+}): MaskLinePoints {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+ const lineDirX = -normalY;
+ const lineDirY = normalX;
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
+
+ return {
+ start: {
+ x: cx - lineDirX * extent,
+ y: cy - lineDirY * extent,
+ },
+ end: {
+ x: cx + lineDirX * extent,
+ y: cy + lineDirY * extent,
+ },
+ };
+}
+
+export function getLineMaskHandlePositions({
+ centerX,
+ centerY,
+ rotation,
+ feather,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ feather: number;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
+ const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
+
+ return [
+ {
+ id: "rotation",
+ x: cx + normalX * iconOffsetCanvas,
+ y: cy + normalY * iconOffsetCanvas,
+ cursor: CURSOR.rotate,
+ },
+ {
+ id: "feather",
+ x: cx - normalX * featherOffset,
+ y: cy - normalY * featherOffset,
+ cursor: CURSOR.resizeHorizontal,
+ },
+ ];
+}
+
+function rotatePoint({
+ localX,
+ localY,
+ cx,
+ cy,
+ angleRad,
+}: {
+ localX: number;
+ localY: number;
+ cx: number;
+ cy: number;
+ angleRad: number;
+}): { x: number; y: number } {
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: cx + localX * cos - localY * sin,
+ y: cy + localX * sin + localY * cos,
+ };
+}
+
+export function getBoxMaskHandlePositions({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ feather,
+ sizeMode,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ feather: number;
+ sizeMode: MaskFeatures["sizeMode"];
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+ const angleRad = (rotation * Math.PI) / 180;
+ const halfWidth = (width * bounds.width) / 2;
+ const halfHeight = (height * bounds.height) / 2;
+
+ const handles: MaskHandlePosition[] = [];
+ const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
+
+ const rotHandle = rotatePoint({
+ localX: 0,
+ localY: -halfHeight - handleOffsetCanvas,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "rotation",
+ x: rotHandle.x,
+ y: rotHandle.y,
+ cursor: CURSOR.rotate,
+ });
+
+ const featherHandle = rotatePoint({
+ localX: 0,
+ localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "feather",
+ x: featherHandle.x,
+ y: featherHandle.y,
+ cursor: CURSOR.resizeVertical,
+ });
+
+ if (sizeMode === "width-height") {
+ const corners = [
+ { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
+ { localX: halfWidth, localY: -halfHeight, id: "top-right" },
+ { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
+ { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
+ ];
+ for (const { localX, localY, id } of corners) {
+ const point = rotatePoint({ localX, localY, cx, cy, angleRad });
+ handles.push({
+ id,
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ });
+ }
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ } else if (sizeMode === "height-only") {
+ const top = rotatePoint({
+ localX: 0,
+ localY: -halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "top",
+ x: top.x,
+ y: top.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ } else if (sizeMode === "width-only") {
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ } else if (sizeMode === "uniform") {
+ const point = rotatePoint({
+ localX: halfWidth,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "scale",
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ });
+ }
+
+ return handles;
+}
+
+type MaskHandleResolver = (args: {
+ features: MaskFeatures;
+ params: ParamValues;
+ bounds: ElementBounds;
+ displayScale: number;
+}) => MaskHandlePosition[];
+
+const rectangleHandles: MaskHandleResolver = ({
+ features,
+ params,
+ bounds,
+ displayScale,
+}) =>
+ getBoxMaskHandlePositions({
+ centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
+ centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
+ width: getNumParam({ params, key: "width", fallback: 1 }),
+ height: getNumParam({ params, key: "height", fallback: 1 }),
+ rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
+ feather: getNumParam({ params, key: "feather", fallback: 0 }),
+ sizeMode: features.sizeMode,
+ bounds,
+ displayScale,
+ });
+
+const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
+ line: ({ params, bounds, displayScale }) =>
+ getLineMaskHandlePositions({
+ centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
+ centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
+ rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
+ feather: getNumParam({ params, key: "feather", fallback: 0 }),
+ bounds,
+ displayScale,
+ }),
+ box: rectangleHandles,
+};
+
+export function getMaskHandlePositions({
+ overlayShape,
+ features,
+ params,
+ bounds,
+ displayScale,
+}: {
+ overlayShape: MaskOverlayShape;
+ features: MaskFeatures;
+ params: ParamValues;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ return HANDLE_RESOLVERS[overlayShape]({
+ features,
+ params,
+ bounds,
+ displayScale,
+ });
+}
diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts index bc9f20b0..43871b44 100644 --- a/apps/web/src/lib/masks/index.ts +++ b/apps/web/src/lib/masks/index.ts @@ -1,18 +1,18 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/lib/masks/types"; -import { masksRegistry } from "./registry"; -import { generateUUID } from "@/utils/id"; - -export { masksRegistry } from "./registry"; -export { registerDefaultMasks } from "./definitions"; - -export function buildDefaultMaskInstance({ - maskType, - elementSize, -}: { - maskType: MaskType; - elementSize?: { width: number; height: number }; -}): Mask { - const definition = masksRegistry.get(maskType); - const context: MaskDefaultContext = { elementSize }; - return { ...definition.buildDefault(context), id: generateUUID() } as Mask; -} +import type { Mask, MaskDefaultContext, MaskType } from "@/lib/masks/types";
+import { masksRegistry } from "./registry";
+import { generateUUID } from "@/utils/id";
+
+export { masksRegistry } from "./registry";
+export { registerDefaultMasks } from "./definitions";
+
+export function buildDefaultMaskInstance({
+ maskType,
+ elementSize,
+}: {
+ maskType: MaskType;
+ elementSize?: { width: number; height: number };
+}): Mask {
+ const definition = masksRegistry.get(maskType);
+ const context: MaskDefaultContext = { elementSize };
+ return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
+}
diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts index 967a4be0..8d0fa2f6 100644 --- a/apps/web/src/lib/masks/param-update.ts +++ b/apps/web/src/lib/masks/param-update.ts @@ -1,59 +1,59 @@ -import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/constants/mask-constants"; -import { masksRegistry } from "@/lib/masks"; -import type { ParamValues } from "@/lib/params"; -import type { - BaseMaskParams, - MaskParamUpdateArgs, - MaskType, -} from "@/lib/masks/types"; - -function compactMaskParamValues({ - params, -}: { - params: Partial<ParamValues>; -}): ParamValues { - const nextParams: ParamValues = {}; - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - nextParams[key] = value; - } - } - return nextParams; -} - -export function computeFeatherUpdate({ - startFeather, - deltaX, - deltaY, - directionX, - directionY, -}: { - startFeather: number; - deltaX: number; - deltaY: number; - directionX: number; - directionY: number; -}): ParamValues { - const projection = deltaX * directionX + deltaY * directionY; - return { - feather: Math.max( - 0, - Math.min( - MAX_FEATHER, - Math.round(startFeather + projection / FEATHER_HANDLE_SCALE), - ), - ), - }; -} - -export function computeMaskParamUpdate({ - maskType, - ...args -}: { - maskType: MaskType; -} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues { - const definition = masksRegistry.get(maskType); - return compactMaskParamValues({ - params: definition.computeParamUpdate(args), - }); -} +import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/constants/mask-constants";
+import { masksRegistry } from "@/lib/masks";
+import type { ParamValues } from "@/lib/params";
+import type {
+ BaseMaskParams,
+ MaskParamUpdateArgs,
+ MaskType,
+} from "@/lib/masks/types";
+
+function compactMaskParamValues({
+ params,
+}: {
+ params: Partial<ParamValues>;
+}): ParamValues {
+ const nextParams: ParamValues = {};
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ nextParams[key] = value;
+ }
+ }
+ return nextParams;
+}
+
+export function computeFeatherUpdate({
+ startFeather,
+ deltaX,
+ deltaY,
+ directionX,
+ directionY,
+}: {
+ startFeather: number;
+ deltaX: number;
+ deltaY: number;
+ directionX: number;
+ directionY: number;
+}): ParamValues {
+ const projection = deltaX * directionX + deltaY * directionY;
+ return {
+ feather: Math.max(
+ 0,
+ Math.min(
+ MAX_FEATHER,
+ Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
+ ),
+ ),
+ };
+}
+
+export function computeMaskParamUpdate({
+ maskType,
+ ...args
+}: {
+ maskType: MaskType;
+} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues {
+ const definition = masksRegistry.get(maskType);
+ return compactMaskParamValues({
+ params: definition.computeParamUpdate(args),
+ });
+}
diff --git a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl index 4ba2c5a1..9d9a8773 100644 --- a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl +++ b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl @@ -1,36 +1,36 @@ -precision mediump float; - -uniform sampler2D u_texture; -uniform sampler2D u_jfa_outside; -uniform vec2 u_resolution; -uniform float u_feather_half; - -varying vec2 v_texCoord; - -vec2 decodeSeed(vec4 encoded) { - float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5); - float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5); - return vec2(x, y); -} - -bool isNoSeed(vec4 encoded) { - return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99; -} - -void main() { - vec2 pixelCoord = floor(v_texCoord * u_resolution); - - vec4 insideEncoded = texture2D(u_texture, v_texCoord); - vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord); - - bool hasInside = !isNoSeed(insideEncoded); - bool hasOutside = !isNoSeed(outsideEncoded); - - float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5; - float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5; - - float signedDist = distToOutside - distToInside; - float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist); - - gl_FragColor = vec4(alpha, alpha, alpha, alpha); -} +precision mediump float;
+
+uniform sampler2D u_texture;
+uniform sampler2D u_jfa_outside;
+uniform vec2 u_resolution;
+uniform float u_feather_half;
+
+varying vec2 v_texCoord;
+
+vec2 decodeSeed(vec4 encoded) {
+ float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
+ float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
+ return vec2(x, y);
+}
+
+bool isNoSeed(vec4 encoded) {
+ return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
+}
+
+void main() {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+
+ vec4 insideEncoded = texture2D(u_texture, v_texCoord);
+ vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord);
+
+ bool hasInside = !isNoSeed(insideEncoded);
+ bool hasOutside = !isNoSeed(outsideEncoded);
+
+ float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5;
+ float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5;
+
+ float signedDist = distToOutside - distToInside;
+ float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist);
+
+ gl_FragColor = vec4(alpha, alpha, alpha, alpha);
+}
diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts index 83cc0e99..13deb7cd 100644 --- a/apps/web/src/lib/masks/snap.ts +++ b/apps/web/src/lib/masks/snap.ts @@ -1,335 +1,335 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import { MIN_MASK_DIMENSION } from "@/constants/mask-constants"; -import { - snapPosition, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import type { ParamValues } from "@/lib/params"; -import { - isRectangleMaskParams, - getMaskSnapGeometry, - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "./geometry"; - -type MaskSnapResult = { - params: ParamValues; - activeLines: SnapLine[]; -}; - -const CORNER_SIZE_HANDLES = new Set([ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]); - -function getClampedRatio({ - next, - base, -}: { - next: number; - base: number; -}): number { - return ( - Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION) - ); -} - -function getPreferredEdges({ - handleId, -}: { - handleId: string; -}): ScaleEdgePreference | undefined { - const preferredEdges = { - left: - handleId === "left" || - handleId === "top-left" || - handleId === "bottom-left", - right: - handleId === "right" || - handleId === "top-right" || - handleId === "bottom-right", - top: - handleId === "top" || handleId === "top-left" || handleId === "top-right", - bottom: - handleId === "bottom" || - handleId === "bottom-left" || - handleId === "bottom-right", - } satisfies ScaleEdgePreference; - - return Object.values(preferredEdges).some(Boolean) - ? preferredEdges - : undefined; -} - -function snapMaskPosition({ - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - proposedParams: ParamValues; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult { - const geometry = getMaskSnapGeometry({ - params: proposedParams, - bounds, - }); - if (!geometry) { - return { params: proposedParams, activeLines: [] }; - } - - const { snappedPosition, activeLines } = snapPosition({ - proposedPosition: geometry.position, - canvasSize: bounds, - elementSize: geometry.size, - rotation: geometry.rotation, - snapThreshold, - }); - - return { - params: { - ...proposedParams, - ...setMaskLocalCenter({ - center: snappedPosition, - bounds, - }), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; -} - -function snapMaskRotation({ - proposedParams, -}: { - proposedParams: ParamValues; -}): MaskSnapResult { - if (typeof proposedParams.rotation !== "number") { - return { params: proposedParams, activeLines: [] }; - } - - const { snappedRotation } = snapRotation({ - proposedRotation: proposedParams.rotation, - }); - - return { - params: { - ...proposedParams, - rotation: snappedRotation, - }, - activeLines: [], - }; -} - -function snapBoxMaskSize({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - handleId: string; - startParams: ParamValues; - proposedParams: ParamValues; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult { - if ( - !isRectangleMaskParams(startParams) || - !isRectangleMaskParams(proposedParams) - ) { - return { params: proposedParams, activeLines: [] }; - } - - const geometry = getMaskSnapGeometry({ - params: proposedParams, - bounds, - }); - if (!geometry) { - return { params: proposedParams, activeLines: [] }; - } - - const localCanvasSize = bounds; - const baseWidth = - Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width; - const baseHeight = - Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; - const preferredEdges = getPreferredEdges({ handleId }); - - if (handleId === "right" || handleId === "left") { - const proposedScaleX = getClampedRatio({ - next: proposedParams.width, - base: startParams.width, - }); - const { x } = snapScaleAxes({ - proposedScaleX, - proposedScaleY: 1, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: x.activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId === "top" || handleId === "bottom") { - const proposedScaleY = getClampedRatio({ - next: proposedParams.height, - base: startParams.height, - }); - const { y } = snapScaleAxes({ - proposedScaleX: 1, - proposedScaleY, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - height: Math.max( - MIN_MASK_DIMENSION, - startParams.height * y.snappedScale, - ), - }, - activeLines: toGlobalMaskSnapLines({ - lines: y.activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId === "scale") { - const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); - const proposedScale = getClampedRatio({ - next: proposedParams.scale, - base: startParams.scale, - }); - const { snappedScale, activeLines } = snapScale({ - proposedScale, - position: geometry.position, - baseWidth: baseWidth * baseScale, - baseHeight: baseHeight * baseScale, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - if (CORNER_SIZE_HANDLES.has(handleId)) { - const proposedScale = getClampedRatio({ - next: proposedParams.width, - base: startParams.width, - }); - const { snappedScale, activeLines } = snapScale({ - proposedScale, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale), - height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - return { params: proposedParams, activeLines: [] }; -} - -export function snapMaskInteraction({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - handleId: string; - startParams: ParamValues; - proposedParams: ParamValues; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult { - if (handleId === "position") { - return snapMaskPosition({ - proposedParams, - bounds, - canvasSize, - snapThreshold, - }); - } - - if (handleId === "rotation") { - return snapMaskRotation({ proposedParams }); - } - - return snapBoxMaskSize({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, - }); -} - +import type { ElementBounds } from "@/lib/preview/element-bounds";
+import { MIN_MASK_DIMENSION } from "@/constants/mask-constants";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import {
+ isRectangleMaskParams,
+ getMaskSnapGeometry,
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "./geometry";
+
+type MaskSnapResult = {
+ params: ParamValues;
+ activeLines: SnapLine[];
+};
+
+const CORNER_SIZE_HANDLES = new Set([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+]);
+
+function getClampedRatio({
+ next,
+ base,
+}: {
+ next: number;
+ base: number;
+}): number {
+ return (
+ Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
+ );
+}
+
+function getPreferredEdges({
+ handleId,
+}: {
+ handleId: string;
+}): ScaleEdgePreference | undefined {
+ const preferredEdges = {
+ left:
+ handleId === "left" ||
+ handleId === "top-left" ||
+ handleId === "bottom-left",
+ right:
+ handleId === "right" ||
+ handleId === "top-right" ||
+ handleId === "bottom-right",
+ top:
+ handleId === "top" || handleId === "top-left" || handleId === "top-right",
+ bottom:
+ handleId === "bottom" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right",
+ } satisfies ScaleEdgePreference;
+
+ return Object.values(preferredEdges).some(Boolean)
+ ? preferredEdges
+ : undefined;
+}
+
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: geometry.position,
+ canvasSize: bounds,
+ elementSize: geometry.size,
+ rotation: geometry.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+}
+
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: ParamValues;
+}): MaskSnapResult {
+ if (typeof proposedParams.rotation !== "number") {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+}
+
+function snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: ParamValues;
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ if (
+ !isRectangleMaskParams(startParams) ||
+ !isRectangleMaskParams(proposedParams)
+ ) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const localCanvasSize = bounds;
+ const baseWidth =
+ Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
+ const baseHeight =
+ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
+ const preferredEdges = getPreferredEdges({ handleId });
+
+ if (handleId === "right" || handleId === "left") {
+ const proposedScaleX = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { x } = snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY: 1,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: x.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "top" || handleId === "bottom") {
+ const proposedScaleY = getClampedRatio({
+ next: proposedParams.height,
+ base: startParams.height,
+ });
+ const { y } = snapScaleAxes({
+ proposedScaleX: 1,
+ proposedScaleY,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height * y.snappedScale,
+ ),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: y.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "scale") {
+ const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
+ const proposedScale = getClampedRatio({
+ next: proposedParams.scale,
+ base: startParams.scale,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth: baseWidth * baseScale,
+ baseHeight: baseHeight * baseScale,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (CORNER_SIZE_HANDLES.has(handleId)) {
+ const proposedScale = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return { params: proposedParams, activeLines: [] };
+}
+
+export function snapMaskInteraction({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: ParamValues;
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ if (handleId === "position") {
+ return snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+ }
+
+ if (handleId === "rotation") {
+ return snapMaskRotation({ proposedParams });
+ }
+
+ return snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+}
+
diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts index 569ff0ff..0e15bff4 100644 --- a/apps/web/src/lib/masks/types.ts +++ b/apps/web/src/lib/masks/types.ts @@ -1,115 +1,115 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { ParamDefinition, ParamValues } from "@/lib/params"; - -export type MaskType = "split" | "rectangle" | "ellipse"; - -export interface BaseMaskParams extends ParamValues { - feather: number; - inverted: boolean; - strokeColor: string; - strokeWidth: number; - strokeAlign: "inside" | "center" | "outside"; -} - -export interface SplitMaskParams extends BaseMaskParams { - centerX: number; - centerY: number; - rotation: number; -} - -export interface RectangleMaskParams extends BaseMaskParams { - centerX: number; - centerY: number; - width: number; - height: number; - rotation: number; - scale: number; -} - -export interface SplitMask { - id: string; - type: "split"; - params: SplitMaskParams; -} - -export interface RectangleMask { - id: string; - type: "rectangle"; - params: RectangleMaskParams; -} - -export interface EllipseMask { - id: string; - type: "ellipse"; - params: RectangleMaskParams; -} - -export type Mask = SplitMask | RectangleMask | EllipseMask; - -export interface MaskRenderer { - buildPath(params: { - resolvedParams: unknown; - width: number; - height: number; - }): Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; -} - -export type MaskOverlayShape = "line" | "box"; - -export interface MaskFeatures { - hasPosition: boolean; - hasRotation: boolean; - sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; -} - -export interface MaskHandlePosition { - id: string; - x: number; - y: number; - cursor: string; -} - -export interface MaskLinePoints { - start: { x: number; y: number }; - end: { x: number; y: number }; -} - -export interface MaskDefaultContext { - elementSize?: { width: number; height: number }; -} - -export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> { - handleId: string; - startParams: TParams; - deltaX: number; - deltaY: number; - startCanvasX: number; - startCanvasY: number; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -} - -export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> { - type: MaskType; - name: string; - overlayShape: MaskOverlayShape; - features: MaskFeatures; - params: ParamDefinition<keyof TParams & string>[]; - renderer: MaskRenderer; - buildOverlayPath?: (params: { width: number; height: number }) => string; - buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; - computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues; -} +import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export type MaskType = "split" | "rectangle" | "ellipse";
+
+export interface BaseMaskParams extends ParamValues {
+ feather: number;
+ inverted: boolean;
+ strokeColor: string;
+ strokeWidth: number;
+ strokeAlign: "inside" | "center" | "outside";
+}
+
+export interface SplitMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+}
+
+export interface RectangleMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface SplitMask {
+ id: string;
+ type: "split";
+ params: SplitMaskParams;
+}
+
+export interface RectangleMask {
+ id: string;
+ type: "rectangle";
+ params: RectangleMaskParams;
+}
+
+export interface EllipseMask {
+ id: string;
+ type: "ellipse";
+ params: RectangleMaskParams;
+}
+
+export type Mask = SplitMask | RectangleMask | EllipseMask;
+
+export interface MaskRenderer {
+ buildPath(params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }): Path2D;
+ buildStrokePath?: (params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }) => Path2D;
+ /** Renders the feathered mask directly onto ctx, bypassing JFA. */
+ renderMask?: (params: {
+ resolvedParams: unknown;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ feather: number;
+ }) => void;
+}
+
+export type MaskOverlayShape = "line" | "box";
+
+export interface MaskFeatures {
+ hasPosition: boolean;
+ hasRotation: boolean;
+ sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
+}
+
+export interface MaskHandlePosition {
+ id: string;
+ x: number;
+ y: number;
+ cursor: string;
+}
+
+export interface MaskLinePoints {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+}
+
+export interface MaskDefaultContext {
+ elementSize?: { width: number; height: number };
+}
+
+export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
+ handleId: string;
+ startParams: TParams;
+ deltaX: number;
+ deltaY: number;
+ startCanvasX: number;
+ startCanvasY: number;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}
+
+export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
+ type: MaskType;
+ name: string;
+ overlayShape: MaskOverlayShape;
+ features: MaskFeatures;
+ params: ParamDefinition<keyof TParams & string>[];
+ renderer: MaskRenderer;
+ buildOverlayPath?: (params: { width: number; height: number }) => string;
+ buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
+ computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
+}
diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts index a9110237..b1ac2c39 100644 --- a/apps/web/src/lib/masks/utils.ts +++ b/apps/web/src/lib/masks/utils.ts @@ -1,64 +1,64 @@ -const LINE_PARALLEL_EPSILON = 1e-10; - -export function lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, -}: { - lineX: number; - lineY: number; - normalX: number; - normalY: number; - x1: number; - y1: number; - x2: number; - y2: number; -}): { x: number; y: number } | null { - const distance1 = halfPlaneSign({ - lineX, - lineY, - normalX, - normalY, - x: x1, - y: y1, - }); - const distance2 = halfPlaneSign({ - lineX, - lineY, - normalX, - normalY, - x: x2, - y: y2, - }); - const denom = distance1 - distance2; - if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null; - const t = distance1 / denom; - if (t < 0 || t > 1) return null; - return { - x: x1 + (x2 - x1) * t, - y: y1 + (y2 - y1) * t, - }; -} - -export function halfPlaneSign({ - lineX, - lineY, - normalX, - normalY, - x, - y, -}: { - lineX: number; - lineY: number; - normalX: number; - normalY: number; - x: number; - y: number; -}): number { - return (x - lineX) * normalX + (y - lineY) * normalY; -} +const LINE_PARALLEL_EPSILON = 1e-10;
+
+export function lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}): { x: number; y: number } | null {
+ const distance1 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x1,
+ y: y1,
+ });
+ const distance2 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x2,
+ y: y2,
+ });
+ const denom = distance1 - distance2;
+ if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
+ const t = distance1 / denom;
+ if (t < 0 || t > 1) return null;
+ return {
+ x: x1 + (x2 - x1) * t,
+ y: y1 + (y2 - y1) * t,
+ };
+}
+
+export function halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x,
+ y,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x: number;
+ y: number;
+}): number {
+ return (x - lineX) * normalX + (y - lineY) * normalY;
+}
diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts index 29af223b..f901c3bf 100644 --- a/apps/web/src/lib/media/audio-mastering.ts +++ b/apps/web/src/lib/media/audio-mastering.ts @@ -1,103 +1,103 @@ -const MASTER_LIMITER_THRESHOLD_DB = -1; -const MASTER_LIMITER_KNEE_DB = 0; -const MASTER_LIMITER_RATIO = 20; -const MASTER_LIMITER_ATTACK_SECONDS = 0.001; -const MASTER_LIMITER_RELEASE_SECONDS = 0.12; -const MASTER_OUTPUT_HEADROOM = 0.98; - -export function getAudioBufferPeak({ - audioBuffer, -}: { - audioBuffer: AudioBuffer; -}): number { - let peak = 0; - - for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) { - const channelData = audioBuffer.getChannelData(channel); - for (let index = 0; index < channelData.length; index++) { - const magnitude = Math.abs(channelData[index]); - if (magnitude > peak) { - peak = magnitude; - } - } - } - - return peak; -} - -export function createAudioMasteringChain({ - audioContext, - destination, -}: { - audioContext: AudioContext | OfflineAudioContext; - destination: AudioNode; -}): { - input: GainNode; -} { - const input = audioContext.createGain(); - const limiter = audioContext.createDynamicsCompressor(); - const outputGain = audioContext.createGain(); - - limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB; - limiter.knee.value = MASTER_LIMITER_KNEE_DB; - limiter.ratio.value = MASTER_LIMITER_RATIO; - limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS; - limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS; - outputGain.gain.value = MASTER_OUTPUT_HEADROOM; - - input.connect(limiter); - limiter.connect(outputGain); - outputGain.connect(destination); - - return { input }; -} - -export async function applyAudioMasteringToBuffer({ - audioBuffer, -}: { - audioBuffer: AudioBuffer; -}): Promise<AudioBuffer> { - if (getAudioBufferPeak({ audioBuffer }) <= MASTER_OUTPUT_HEADROOM) { - return audioBuffer; - } - - const offlineContext = new OfflineAudioContext( - audioBuffer.numberOfChannels, - Math.max(1, audioBuffer.length), - audioBuffer.sampleRate, - ); - const source = offlineContext.createBufferSource(); - source.buffer = audioBuffer; - - const { input } = createAudioMasteringChain({ - audioContext: offlineContext, - destination: offlineContext.destination, - }); - source.connect(input); - source.start(0); - - const renderedBuffer = await offlineContext.startRendering(); - clampAudioBufferPeak({ - audioBuffer: renderedBuffer, - maxPeak: MASTER_OUTPUT_HEADROOM, - }); - return renderedBuffer; -} - -function clampAudioBufferPeak({ - audioBuffer, - maxPeak, -}: { - audioBuffer: AudioBuffer; - maxPeak: number; -}): void { - for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) { - const channelData = audioBuffer.getChannelData(channel); - for (let index = 0; index < channelData.length; index++) { - channelData[index] = Math.max( - -maxPeak, - Math.min(maxPeak, channelData[index]), - ); - } - } -} +const MASTER_LIMITER_THRESHOLD_DB = -1;
+const MASTER_LIMITER_KNEE_DB = 0;
+const MASTER_LIMITER_RATIO = 20;
+const MASTER_LIMITER_ATTACK_SECONDS = 0.001;
+const MASTER_LIMITER_RELEASE_SECONDS = 0.12;
+const MASTER_OUTPUT_HEADROOM = 0.98;
+
+export function getAudioBufferPeak({
+ audioBuffer,
+}: {
+ audioBuffer: AudioBuffer;
+}): number {
+ let peak = 0;
+
+ for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
+ const channelData = audioBuffer.getChannelData(channel);
+ for (let index = 0; index < channelData.length; index++) {
+ const magnitude = Math.abs(channelData[index]);
+ if (magnitude > peak) {
+ peak = magnitude;
+ }
+ }
+ }
+
+ return peak;
+}
+
+export function createAudioMasteringChain({
+ audioContext,
+ destination,
+}: {
+ audioContext: AudioContext | OfflineAudioContext;
+ destination: AudioNode;
+}): {
+ input: GainNode;
+} {
+ const input = audioContext.createGain();
+ const limiter = audioContext.createDynamicsCompressor();
+ const outputGain = audioContext.createGain();
+
+ limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB;
+ limiter.knee.value = MASTER_LIMITER_KNEE_DB;
+ limiter.ratio.value = MASTER_LIMITER_RATIO;
+ limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS;
+ limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS;
+ outputGain.gain.value = MASTER_OUTPUT_HEADROOM;
+
+ input.connect(limiter);
+ limiter.connect(outputGain);
+ outputGain.connect(destination);
+
+ return { input };
+}
+
+export async function applyAudioMasteringToBuffer({
+ audioBuffer,
+}: {
+ audioBuffer: AudioBuffer;
+}): Promise<AudioBuffer> {
+ if (getAudioBufferPeak({ audioBuffer }) <= MASTER_OUTPUT_HEADROOM) {
+ return audioBuffer;
+ }
+
+ const offlineContext = new OfflineAudioContext(
+ audioBuffer.numberOfChannels,
+ Math.max(1, audioBuffer.length),
+ audioBuffer.sampleRate,
+ );
+ const source = offlineContext.createBufferSource();
+ source.buffer = audioBuffer;
+
+ const { input } = createAudioMasteringChain({
+ audioContext: offlineContext,
+ destination: offlineContext.destination,
+ });
+ source.connect(input);
+ source.start(0);
+
+ const renderedBuffer = await offlineContext.startRendering();
+ clampAudioBufferPeak({
+ audioBuffer: renderedBuffer,
+ maxPeak: MASTER_OUTPUT_HEADROOM,
+ });
+ return renderedBuffer;
+}
+
+function clampAudioBufferPeak({
+ audioBuffer,
+ maxPeak,
+}: {
+ audioBuffer: AudioBuffer;
+ maxPeak: number;
+}): void {
+ for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
+ const channelData = audioBuffer.getChannelData(channel);
+ for (let index = 0; index < channelData.length; index++) {
+ channelData[index] = Math.max(
+ -maxPeak,
+ Math.min(maxPeak, channelData[index]),
+ );
+ }
+ }
+}
diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts index 52c08f0c..dc73da23 100644 --- a/apps/web/src/lib/media/media-utils.ts +++ b/apps/web/src/lib/media/media-utils.ts @@ -1,32 +1,32 @@ -import type { MediaAsset, MediaType } from "@/lib/media/types"; - -export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"]; - -export function mediaSupportsAudio({ - media, -}: { - media: MediaAsset | null | undefined; -}): boolean { - if (!media) return false; - return SUPPORTS_AUDIO.includes(media.type); -} - -export const getMediaTypeFromFile = ({ - file, -}: { - file: File; -}): MediaType | null => { - const { type } = file; - - if (type.startsWith("image/")) { - return "image"; - } - if (type.startsWith("video/")) { - return "video"; - } - if (type.startsWith("audio/")) { - return "audio"; - } - - return null; -}; +import type { MediaAsset, MediaType } from "@/lib/media/types";
+
+export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
+
+export function mediaSupportsAudio({
+ media,
+}: {
+ media: MediaAsset | null | undefined;
+}): boolean {
+ if (!media) return false;
+ return SUPPORTS_AUDIO.includes(media.type);
+}
+
+export const getMediaTypeFromFile = ({
+ file,
+}: {
+ file: File;
+}): MediaType | null => {
+ const { type } = file;
+
+ if (type.startsWith("image/")) {
+ return "image";
+ }
+ if (type.startsWith("video/")) {
+ return "video";
+ }
+ if (type.startsWith("audio/")) {
+ return "audio";
+ }
+
+ return null;
+};
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index c2d1d7f3..dbb6ecad 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,158 +1,158 @@ -import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; -import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; - -export async function getVideoInfo({ - videoFile, -}: { - videoFile: File; -}): Promise<{ - duration: number; - width: number; - height: number; - fps: number; - hasAudio: boolean; -}> { - const input = new Input({ - source: new BlobSource(videoFile), - formats: ALL_FORMATS, - }); - - const duration = await input.computeDuration(); - const videoTrack = await input.getPrimaryVideoTrack(); - - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - const packetStats = await videoTrack.computePacketStats(100); - const fps = packetStats.averagePacketRate; - const audioTrack = await input.getPrimaryAudioTrack(); - - return { - duration, - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - fps, - hasAudio: audioTrack !== null, - }; -} - -const SAMPLE_RATE = 44100; -const NUM_CHANNELS = 2; - -export const extractTimelineAudio = async ({ - tracks, - mediaAssets, - totalDuration, - onProgress, -}: { - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - totalDuration: number; - onProgress?: (progress: number) => void; -}): Promise<Blob> => { - if (totalDuration === 0) { - return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) }); - } - - onProgress?.(10); - - const audioBuffer = await createTimelineAudioBuffer({ - tracks, - mediaAssets, - duration: totalDuration, - sampleRate: SAMPLE_RATE, - }); - - if (!audioBuffer) { - const silentDuration = Math.max(1, totalDuration); - const silentSamples = new Float32Array( - Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS, - ); - return createWavBlob({ samples: silentSamples }); - } - - onProgress?.(90); - - const interleavedSamples = interleaveAudioBuffer({ audioBuffer }); - onProgress?.(100); - - return createWavBlob({ samples: interleavedSamples }); -}; - -function interleaveAudioBuffer({ - audioBuffer, -}: { - audioBuffer: AudioBuffer; -}): Float32Array { - const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels); - const interleavedSamples = new Float32Array( - audioBuffer.length * NUM_CHANNELS, - ); - - for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) { - for (let channel = 0; channel < NUM_CHANNELS; channel++) { - const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1)); - interleavedSamples[sampleIndex * NUM_CHANNELS + channel] = - audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0; - } - } - - return interleavedSamples; -} - -function createWavBlob({ samples }: { samples: Float32Array }): Blob { - const numChannels = NUM_CHANNELS; - const bitsPerSample = 16; - const bytesPerSample = bitsPerSample / 8; - const numSamples = samples.length / numChannels; - const dataSize = numSamples * numChannels * bytesPerSample; - const buffer = new ArrayBuffer(44 + dataSize); - const view = new DataView(buffer); - - // riff header - writeString({ view, offset: 0, str: "RIFF" }); - view.setUint32(4, 36 + dataSize, true); - writeString({ view, offset: 8, str: "WAVE" }); - - // fmt chunk - writeString({ view, offset: 12, str: "fmt " }); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, numChannels, true); - view.setUint32(24, SAMPLE_RATE, true); - view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true); - view.setUint16(32, numChannels * bytesPerSample, true); - view.setUint16(34, bitsPerSample, true); - - // data chunk - writeString({ view, offset: 36, str: "data" }); - view.setUint32(40, dataSize, true); - - // convert float32 to int16 and write - let offset = 44; - for (let i = 0; i < samples.length; i++) { - const sample = Math.max(-1, Math.min(1, samples[i])); - const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff; - view.setInt16(offset, int16, true); - offset += 2; - } - - return new Blob([buffer], { type: "audio/wav" }); -} - -function writeString({ - view, - offset, - str, -}: { - view: DataView; - offset: number; - str: string; -}): void { - for (let i = 0; i < str.length; i++) { - view.setUint8(offset + i, str.charCodeAt(i)); - } -} +import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
+import { createTimelineAudioBuffer } from "@/lib/media/audio";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+
+export async function getVideoInfo({
+ videoFile,
+}: {
+ videoFile: File;
+}): Promise<{
+ duration: number;
+ width: number;
+ height: number;
+ fps: number;
+ hasAudio: boolean;
+}> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
+
+ const duration = await input.computeDuration();
+ const videoTrack = await input.getPrimaryVideoTrack();
+
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
+ }
+
+ const packetStats = await videoTrack.computePacketStats(100);
+ const fps = packetStats.averagePacketRate;
+ const audioTrack = await input.getPrimaryAudioTrack();
+
+ return {
+ duration,
+ width: videoTrack.displayWidth,
+ height: videoTrack.displayHeight,
+ fps,
+ hasAudio: audioTrack !== null,
+ };
+}
+
+const SAMPLE_RATE = 44100;
+const NUM_CHANNELS = 2;
+
+export const extractTimelineAudio = async ({
+ tracks,
+ mediaAssets,
+ totalDuration,
+ onProgress,
+}: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ totalDuration: number;
+ onProgress?: (progress: number) => void;
+}): Promise<Blob> => {
+ if (totalDuration === 0) {
+ return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
+ }
+
+ onProgress?.(10);
+
+ const audioBuffer = await createTimelineAudioBuffer({
+ tracks,
+ mediaAssets,
+ duration: totalDuration,
+ sampleRate: SAMPLE_RATE,
+ });
+
+ if (!audioBuffer) {
+ const silentDuration = Math.max(1, totalDuration);
+ const silentSamples = new Float32Array(
+ Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
+ );
+ return createWavBlob({ samples: silentSamples });
+ }
+
+ onProgress?.(90);
+
+ const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
+ onProgress?.(100);
+
+ return createWavBlob({ samples: interleavedSamples });
+};
+
+function interleaveAudioBuffer({
+ audioBuffer,
+}: {
+ audioBuffer: AudioBuffer;
+}): Float32Array {
+ const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
+ const interleavedSamples = new Float32Array(
+ audioBuffer.length * NUM_CHANNELS,
+ );
+
+ for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
+ for (let channel = 0; channel < NUM_CHANNELS; channel++) {
+ const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
+ interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
+ audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
+ }
+ }
+
+ return interleavedSamples;
+}
+
+function createWavBlob({ samples }: { samples: Float32Array }): Blob {
+ const numChannels = NUM_CHANNELS;
+ const bitsPerSample = 16;
+ const bytesPerSample = bitsPerSample / 8;
+ const numSamples = samples.length / numChannels;
+ const dataSize = numSamples * numChannels * bytesPerSample;
+ const buffer = new ArrayBuffer(44 + dataSize);
+ const view = new DataView(buffer);
+
+ // riff header
+ writeString({ view, offset: 0, str: "RIFF" });
+ view.setUint32(4, 36 + dataSize, true);
+ writeString({ view, offset: 8, str: "WAVE" });
+
+ // fmt chunk
+ writeString({ view, offset: 12, str: "fmt " });
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, numChannels, true);
+ view.setUint32(24, SAMPLE_RATE, true);
+ view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
+ view.setUint16(32, numChannels * bytesPerSample, true);
+ view.setUint16(34, bitsPerSample, true);
+
+ // data chunk
+ writeString({ view, offset: 36, str: "data" });
+ view.setUint32(40, dataSize, true);
+
+ // convert float32 to int16 and write
+ let offset = 44;
+ for (let i = 0; i < samples.length; i++) {
+ const sample = Math.max(-1, Math.min(1, samples[i]));
+ const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
+ view.setInt16(offset, int16, true);
+ offset += 2;
+ }
+
+ return new Blob([buffer], { type: "audio/wav" });
+}
+
+function writeString({
+ view,
+ offset,
+ str,
+}: {
+ view: DataView;
+ offset: number;
+ str: string;
+}): void {
+ for (let i = 0; i < str.length; i++) {
+ view.setUint8(offset + i, str.charCodeAt(i));
+ }
+}
diff --git a/apps/web/src/lib/media/types.ts b/apps/web/src/lib/media/types.ts index c8dddfc7..4d3e4106 100644 --- a/apps/web/src/lib/media/types.ts +++ b/apps/web/src/lib/media/types.ts @@ -1,9 +1,9 @@ -import type { MediaAssetData } from "@/services/storage/types"; - -export type MediaType = "image" | "video" | "audio"; - -export interface MediaAsset - extends Omit<MediaAssetData, "size" | "lastModified"> { - file: File; - url?: string; -} +import type { MediaAssetData } from "@/services/storage/types";
+
+export type MediaType = "image" | "video" | "audio";
+
+export interface MediaAsset
+ extends Omit<MediaAssetData, "size" | "lastModified"> {
+ file: File;
+ url?: string;
+}
diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts index e5c5ee28..45846206 100644 --- a/apps/web/src/lib/media/upload-toast.ts +++ b/apps/web/src/lib/media/upload-toast.ts @@ -1,51 +1,51 @@ -import { toast } from "sonner"; - -export interface MediaUploadToastResult { - uploadedCount: number; - assetNames?: string[]; -} - -function getAssetLabel({ count }: { count: number }): string { - return count === 1 ? "media asset" : "media assets"; -} - -function waitForNextPaint(): Promise<void> { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); -} - -export async function showMediaUploadToast<T extends MediaUploadToastResult>({ - filesCount, - promise, -}: { - filesCount: number; - promise: Promise<T> | (() => Promise<T>); -}) { - const run = typeof promise === "function" ? promise : () => promise; - const toastPromise = toast.promise(async () => { - await waitForNextPaint(); - return run(); - }, { - loading: `Uploading ${getAssetLabel({ count: filesCount })}...`, - success: ({ uploadedCount, assetNames }) => { - if (uploadedCount === 1) { - const assetName = assetNames?.[0]; - return assetName - ? `${assetName} has been uploaded` - : "1 media asset has been uploaded"; - } - - if (uploadedCount > 1) { - return `${uploadedCount} media assets have been uploaded`; - } - - return "No media assets were uploaded"; - }, - error: `Failed to upload ${getAssetLabel({ count: filesCount })}`, - }); - - return toastPromise.unwrap(); -} +import { toast } from "sonner";
+
+export interface MediaUploadToastResult {
+ uploadedCount: number;
+ assetNames?: string[];
+}
+
+function getAssetLabel({ count }: { count: number }): string {
+ return count === 1 ? "media asset" : "media assets";
+}
+
+function waitForNextPaint(): Promise<void> {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve());
+ });
+ });
+}
+
+export async function showMediaUploadToast<T extends MediaUploadToastResult>({
+ filesCount,
+ promise,
+}: {
+ filesCount: number;
+ promise: Promise<T> | (() => Promise<T>);
+}) {
+ const run = typeof promise === "function" ? promise : () => promise;
+ const toastPromise = toast.promise(async () => {
+ await waitForNextPaint();
+ return run();
+ }, {
+ loading: `Uploading ${getAssetLabel({ count: filesCount })}...`,
+ success: ({ uploadedCount, assetNames }) => {
+ if (uploadedCount === 1) {
+ const assetName = assetNames?.[0];
+ return assetName
+ ? `${assetName} has been uploaded`
+ : "1 media asset has been uploaded";
+ }
+
+ if (uploadedCount > 1) {
+ return `${uploadedCount} media assets have been uploaded`;
+ }
+
+ return "No media assets were uploaded";
+ },
+ error: `Failed to upload ${getAssetLabel({ count: filesCount })}`,
+ });
+
+ return toastPromise.unwrap();
+}
diff --git a/apps/web/src/lib/params.ts b/apps/web/src/lib/params.ts index 23085407..633bfbd0 100644 --- a/apps/web/src/lib/params.ts +++ b/apps/web/src/lib/params.ts @@ -1,50 +1,50 @@ -export type ParamValues = Record<string, number | string | boolean>; - -export type ParamGroup = "stroke"; - -interface BaseParamDefinition<TKey extends string = string> { - key: TKey; - label: string; - group?: ParamGroup; -} - -export interface NumberParamDefinition<TKey extends string = string> - extends BaseParamDefinition<TKey> { - type: "number"; - default: number; - min: number; - max?: number; - step: number; - /** When set, min/max/step are in display space. display = stored * displayMultiplier. */ - displayMultiplier?: number; - /** Show as percentage of max. min/max/step/default stay in stored space. */ - unit?: "percent"; - /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */ - shortLabel?: string; -} - -export interface BooleanParamDefinition<TKey extends string = string> - extends BaseParamDefinition<TKey> { - type: "boolean"; - default: boolean; -} - -export interface ColorParamDefinition<TKey extends string = string> - extends BaseParamDefinition<TKey> { - type: "color"; - default: string; -} - -export interface SelectParamDefinition<TKey extends string = string> - extends BaseParamDefinition<TKey> { - type: "select"; - default: string; - options: Array<{ value: string; label: string }>; -} - -export type ParamDefinition<TKey extends string = string> = - | NumberParamDefinition<TKey> - | BooleanParamDefinition<TKey> - | ColorParamDefinition<TKey> - | SelectParamDefinition<TKey>; - +export type ParamValues = Record<string, number | string | boolean>;
+
+export type ParamGroup = "stroke";
+
+interface BaseParamDefinition<TKey extends string = string> {
+ key: TKey;
+ label: string;
+ group?: ParamGroup;
+}
+
+export interface NumberParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "number";
+ default: number;
+ min: number;
+ max?: number;
+ step: number;
+ /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
+ displayMultiplier?: number;
+ /** Show as percentage of max. min/max/step/default stay in stored space. */
+ unit?: "percent";
+ /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
+ shortLabel?: string;
+}
+
+export interface BooleanParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "boolean";
+ default: boolean;
+}
+
+export interface ColorParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "color";
+ default: string;
+}
+
+export interface SelectParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "select";
+ default: string;
+ options: Array<{ value: string; label: string }>;
+}
+
+export type ParamDefinition<TKey extends string = string> =
+ | NumberParamDefinition<TKey>
+ | BooleanParamDefinition<TKey>
+ | ColorParamDefinition<TKey>
+ | SelectParamDefinition<TKey>;
+
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 5c416afc..c8420467 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,327 +1,327 @@ -import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { isMainTrack } from "@/lib/timeline"; -import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; -import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; -import { measureTextElement } from "@/lib/text/measure-element"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/lib/animation"; - -export interface ElementBounds { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; -} - -export interface ElementWithBounds { - trackId: string; - elementId: string; - element: TimelineElement; - bounds: ElementBounds; -} - -function getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - sourceWidth: number; - sourceHeight: number; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const containScale = Math.min( - canvasWidth / sourceWidth, - canvasHeight / sourceHeight, - ); - const scaledWidth = sourceWidth * containScale * transform.scaleX; - const scaledHeight = sourceHeight * containScale * transform.scaleY; - const cx = canvasWidth / 2 + transform.position.x; - const cy = canvasHeight / 2 + transform.position.y; - - return { - cx, - cy, - width: scaledWidth, - height: scaledHeight, - rotation: transform.rotate, - }; -} - -function getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect, - transform, -}: { - canvasWidth: number; - canvasHeight: number; - rect: { left: number; top: number; width: number; height: number }; - transform: { - scaleX: number; - scaleY: number; - position: { x: number; y: number }; - rotate: number; - }; -}): ElementBounds { - const localCenterX = rect.left + rect.width / 2; - const localCenterY = rect.top + rect.height / 2; - const scaledCenterX = localCenterX * transform.scaleX; - const scaledCenterY = localCenterY * transform.scaleY; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - return { - cx: - canvasWidth / 2 + - transform.position.x + - scaledCenterX * cos - - scaledCenterY * sin, - cy: - canvasHeight / 2 + - transform.position.y + - scaledCenterX * sin + - scaledCenterY * cos, - width: rect.width * transform.scaleX, - height: rect.height * transform.scaleY, - rotation: transform.rotate, - }; -} - -/** - * Bounds policy: bounds reflect base content geometry (text glyphs + background, - * sticker/image/video content area) and base transform. Post-effect spill (blur, - * glow) and mask-clipped regions are intentionally excluded — handles manipulate - * the canonical element geometry, not visual effect output. - */ -function getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, -}: { - element: TimelineElement; - canvasSize: { width: number; height: number }; - mediaAsset?: MediaAsset | null; - localTime: number; -}): ElementBounds | null { - if (element.type === "audio" || element.type === "effect") return null; - if ("hidden" in element && element.hidden) return null; - - const { width: canvasWidth, height: canvasHeight } = canvasSize; - - if (element.type === "video" || element.type === "image") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const sourceWidth = mediaAsset?.width ?? canvasWidth; - const sourceHeight = mediaAsset?.height ?? canvasHeight; - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth, - sourceHeight, - transform, - }); - } - - if (element.type === "sticker") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, - sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, - transform, - }); - } - - if (element.type === "graphic") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - return getVisualElementBounds({ - canvasWidth, - canvasHeight, - sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, - sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, - transform, - }); - } - - if (element.type === "text") { - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - const measured = measureTextElement({ - element, - canvasHeight, - localTime, - ctx, - }); - - return getTransformedRectBounds({ - canvasWidth, - canvasHeight, - rect: measured.visualRect, - transform, - }); - } - - return null; -} - -export const ROTATION_HANDLE_OFFSET = 24; - -export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -export type Edge = "right" | "left" | "bottom"; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getEdgeHandlePosition({ - bounds, - edge, -}: { - bounds: ElementBounds; - edge: Edge; -}): { x: number; y: number } { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; - const localY = edge === "bottom" ? halfHeight : 0; - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getRotationHandlePosition({ - bounds, -}: { - bounds: ElementBounds; -}): { x: number; y: number } { - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; - return { - x: bounds.cx - localY * sin, - y: bounds.cy + localY * cos, - }; -} - -export function getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, -}: { - tracks: TimelineTrack[]; - currentTime: number; - canvasSize: { width: number; height: number }; - mediaAssets: MediaAsset[]; -}): ElementWithBounds[] { - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); - const orderedTracks = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), - ].reverse(); - - const result: ElementWithBounds[] = []; - - for (const track of orderedTracks) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .filter( - (element) => - currentTime >= element.startTime && - currentTime < element.startTime + element.duration, - ) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const mediaAsset = - element.type === "video" || element.type === "image" - ? mediaMap.get(element.mediaId) - : undefined; - const bounds = getElementBounds({ - element, - canvasSize, - mediaAsset, - localTime, - }); - if (bounds) { - result.push({ - trackId: track.id, - elementId: element.id, - element, - bounds, - }); - } - } - } - - return result; -} +import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { isMainTrack } from "@/lib/timeline";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
+import { measureTextElement } from "@/lib/text/measure-element";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+
+export interface ElementBounds {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}
+
+export interface ElementWithBounds {
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ bounds: ElementBounds;
+}
+
+function getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ sourceWidth: number;
+ sourceHeight: number;
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const containScale = Math.min(
+ canvasWidth / sourceWidth,
+ canvasHeight / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
+ const cx = canvasWidth / 2 + transform.position.x;
+ const cy = canvasHeight / 2 + transform.position.y;
+
+ return {
+ cx,
+ cy,
+ width: scaledWidth,
+ height: scaledHeight,
+ rotation: transform.rotate,
+ };
+}
+
+function getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ rect: { left: number; top: number; width: number; height: number };
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const localCenterX = rect.left + rect.width / 2;
+ const localCenterY = rect.top + rect.height / 2;
+ const scaledCenterX = localCenterX * transform.scaleX;
+ const scaledCenterY = localCenterY * transform.scaleY;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+ return {
+ cx:
+ canvasWidth / 2 +
+ transform.position.x +
+ scaledCenterX * cos -
+ scaledCenterY * sin,
+ cy:
+ canvasHeight / 2 +
+ transform.position.y +
+ scaledCenterX * sin +
+ scaledCenterY * cos,
+ width: rect.width * transform.scaleX,
+ height: rect.height * transform.scaleY,
+ rotation: transform.rotate,
+ };
+}
+
+/**
+ * Bounds policy: bounds reflect base content geometry (text glyphs + background,
+ * sticker/image/video content area) and base transform. Post-effect spill (blur,
+ * glow) and mask-clipped regions are intentionally excluded — handles manipulate
+ * the canonical element geometry, not visual effect output.
+ */
+function getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+}: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+ localTime: number;
+}): ElementBounds | null {
+ if (element.type === "audio" || element.type === "effect") return null;
+ if ("hidden" in element && element.hidden) return null;
+
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+
+ if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const sourceWidth = mediaAsset?.width ?? canvasWidth;
+ const sourceHeight = mediaAsset?.height ?? canvasHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+ });
+ }
+
+ if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ transform,
+ });
+ }
+
+ if (element.type === "graphic") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
+
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
+
+ return getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect: measured.visualRect,
+ transform,
+ });
+ }
+
+ return null;
+}
+
+export const ROTATION_HANDLE_OFFSET = 24;
+
+export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+export type Edge = "right" | "left" | "bottom";
+
+export function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getEdgeHandlePosition({
+ bounds,
+ edge,
+}: {
+ bounds: ElementBounds;
+ edge: Edge;
+}): { x: number; y: number } {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
+ const localY = edge === "bottom" ? halfHeight : 0;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getRotationHandlePosition({
+ bounds,
+}: {
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
+ return {
+ x: bounds.cx - localY * sin,
+ y: bounds.cy + localY * cos,
+ };
+}
+
+export function getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+}: {
+ tracks: TimelineTrack[];
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+}): ElementWithBounds[] {
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+ const orderedTracks = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ].reverse();
+
+ const result: ElementWithBounds[] = [];
+
+ for (const track of orderedTracks) {
+ const elements = track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .filter(
+ (element) =>
+ currentTime >= element.startTime &&
+ currentTime < element.startTime + element.duration,
+ )
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const mediaAsset =
+ element.type === "video" || element.type === "image"
+ ? mediaMap.get(element.mediaId)
+ : undefined;
+ const bounds = getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+ });
+ if (bounds) {
+ result.push({
+ trackId: track.id,
+ elementId: element.id,
+ element,
+ bounds,
+ });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts index 64220264..86d35390 100644 --- a/apps/web/src/lib/preview/preview-snap.ts +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -1,539 +1,539 @@ -export interface SnapLine { - type: "horizontal" | "vertical"; - position: number; -} - -const ROTATION_SNAP_STEP_DEGREES = 90; -const ROTATION_SNAP_THRESHOLD_DEGREES = 5; -export const MIN_SCALE = 0.01; -export const SNAP_THRESHOLD_SCREEN_PIXELS = 8; - -export interface SnapResult { - snappedPosition: { x: number; y: number }; - activeLines: SnapLine[]; -} - -type ScaleEdge = "left" | "right" | "top" | "bottom"; - -export interface ScaleEdgePreference { - left?: boolean; - right?: boolean; - top?: boolean; - bottom?: boolean; -} - -function hasPreferredEdge({ - preferredEdges, - edge, -}: { - preferredEdges?: ScaleEdgePreference; - edge: ScaleEdge; -}): boolean { - return preferredEdges?.[edge] === true; -} - -function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({ - candidates, - preferredEdges, -}: { - candidates: T[]; - preferredEdges?: ScaleEdgePreference; -}): T | null { - if (candidates.length === 0) { - return null; - } - - return candidates.reduce((bestCandidate, candidate) => { - if (candidate.distance < bestCandidate.distance) { - return candidate; - } - if (candidate.distance > bestCandidate.distance) { - return bestCandidate; - } - - const shouldPreferCandidate = hasPreferredEdge({ - preferredEdges, - edge: candidate.edge, - }); - const shouldPreferBestCandidate = hasPreferredEdge({ - preferredEdges, - edge: bestCandidate.edge, - }); - - return shouldPreferCandidate && !shouldPreferBestCandidate - ? candidate - : bestCandidate; - }); -} - -export function snapPosition({ - proposedPosition, - canvasSize, - elementSize, - rotation = 0, - snapThreshold, -}: { - proposedPosition: { x: number; y: number }; - canvasSize: { width: number; height: number }; - elementSize: { width: number; height: number }; - rotation?: number; - snapThreshold: { x: number; y: number }; -}): SnapResult { - const centerX = 0; - const centerY = 0; - const left = -canvasSize.width / 2; - const right = canvasSize.width / 2; - const top = -canvasSize.height / 2; - const bottom = canvasSize.height / 2; - - const rotRad = (rotation * Math.PI) / 180; - const cosR = Math.abs(Math.cos(rotRad)); - const sinR = Math.abs(Math.sin(rotRad)); - const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2; - const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2; - const activeLines: SnapLine[] = []; - - type AxisSnapCandidate = { - snappedPosition: number; - line: SnapLine; - distance: number; - }; - - function getClosestAxisSnap({ - candidates, - threshold, - }: { - candidates: AxisSnapCandidate[]; - threshold: number; - }): AxisSnapCandidate | null { - const snapCandidatesWithinThreshold = candidates.filter( - (candidate) => candidate.distance <= threshold, - ); - if (snapCandidatesWithinThreshold.length === 0) { - return null; - } - return snapCandidatesWithinThreshold.reduce((closest, current) => - current.distance < closest.distance ? current : closest, - ); - } - - const verticalTargets = [centerX, left, right]; - const horizontalTargets = [centerY, top, bottom]; - - const xCandidates: AxisSnapCandidate[] = []; - for (const targetX of verticalTargets) { - xCandidates.push({ - snappedPosition: targetX, - line: { type: "vertical", position: targetX }, - distance: Math.abs(proposedPosition.x - targetX), - }); - xCandidates.push({ - snappedPosition: targetX + halfWidth, - line: { type: "vertical", position: targetX }, - distance: Math.abs(proposedPosition.x - halfWidth - targetX), - }); - xCandidates.push({ - snappedPosition: targetX - halfWidth, - line: { type: "vertical", position: targetX }, - distance: Math.abs(proposedPosition.x + halfWidth - targetX), - }); - } - const yCandidates: AxisSnapCandidate[] = []; - for (const targetY of horizontalTargets) { - yCandidates.push({ - snappedPosition: targetY, - line: { type: "horizontal", position: targetY }, - distance: Math.abs(proposedPosition.y - targetY), - }); - yCandidates.push({ - snappedPosition: targetY + halfHeight, - line: { type: "horizontal", position: targetY }, - distance: Math.abs(proposedPosition.y - halfHeight - targetY), - }); - yCandidates.push({ - snappedPosition: targetY - halfHeight, - line: { type: "horizontal", position: targetY }, - distance: Math.abs(proposedPosition.y + halfHeight - targetY), - }); - } - - const closestX = getClosestAxisSnap({ - candidates: xCandidates, - threshold: snapThreshold.x, - }); - const closestY = getClosestAxisSnap({ - candidates: yCandidates, - threshold: snapThreshold.y, - }); - - const x = closestX?.snappedPosition ?? proposedPosition.x; - const y = closestY?.snappedPosition ?? proposedPosition.y; - if (closestX) { - activeLines.push(closestX.line); - } - if (closestY) { - activeLines.push(closestY.line); - } - - return { - snappedPosition: { x, y }, - activeLines, - }; -} - -export interface ScaleSnapResult { - snappedScale: number; - activeLines: SnapLine[]; -} - -export function snapScale({ - proposedScale, - position, - baseWidth, - baseHeight, - rotation = 0, - canvasSize, - snapThreshold, - preferredEdges, -}: { - proposedScale: number; - position: { x: number; y: number }; - baseWidth: number; - baseHeight: number; - rotation?: number; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; - preferredEdges?: ScaleEdgePreference; -}): ScaleSnapResult { - const centerX = 0; - const centerY = 0; - const left = -canvasSize.width / 2; - const right = canvasSize.width / 2; - const top = -canvasSize.height / 2; - const bottom = canvasSize.height / 2; - - const rotRad = (rotation * Math.PI) / 180; - const cosR = Math.abs(Math.cos(rotRad)); - const sinR = Math.abs(Math.sin(rotRad)); - const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2; - const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2; - - const leftEdge = position.x - aabbBaseHalfW * proposedScale; - const rightEdge = position.x + aabbBaseHalfW * proposedScale; - const topEdge = position.y - aabbBaseHalfH * proposedScale; - const bottomEdge = position.y + aabbBaseHalfH * proposedScale; - - interface SnapCandidate { - scale: number; - distance: number; - lines: SnapLine[]; - edge: ScaleEdge; - } - - const candidates: SnapCandidate[] = []; - - const verticalTargets = [ - { position: left, line: { type: "vertical" as const, position: left } }, - { - position: centerX, - line: { type: "vertical" as const, position: centerX }, - }, - { position: right, line: { type: "vertical" as const, position: right } }, - ]; - - for (const target of verticalTargets) { - const distanceLeft = Math.abs(leftEdge - target.position); - if (distanceLeft <= snapThreshold.x) { - const scale = (position.x - target.position) / aabbBaseHalfW; - if (Math.abs(scale) > MIN_SCALE) { - candidates.push({ - scale, - distance: distanceLeft, - lines: [target.line], - edge: "left", - }); - } - } - const distanceRight = Math.abs(rightEdge - target.position); - if (distanceRight <= snapThreshold.x) { - const scale = (target.position - position.x) / aabbBaseHalfW; - if (Math.abs(scale) > MIN_SCALE) { - candidates.push({ - scale, - distance: distanceRight, - lines: [target.line], - edge: "right", - }); - } - } - } - - const horizontalTargets = [ - { position: top, line: { type: "horizontal" as const, position: top } }, - { - position: centerY, - line: { type: "horizontal" as const, position: centerY }, - }, - { - position: bottom, - line: { type: "horizontal" as const, position: bottom }, - }, - ]; - - for (const target of horizontalTargets) { - const distanceTop = Math.abs(topEdge - target.position); - if (distanceTop <= snapThreshold.y) { - const scale = (position.y - target.position) / aabbBaseHalfH; - if (Math.abs(scale) > MIN_SCALE) { - candidates.push({ - scale, - distance: distanceTop, - lines: [target.line], - edge: "top", - }); - } - } - const distanceBottom = Math.abs(bottomEdge - target.position); - if (distanceBottom <= snapThreshold.y) { - const scale = (target.position - position.y) / aabbBaseHalfH; - if (Math.abs(scale) > MIN_SCALE) { - candidates.push({ - scale, - distance: distanceBottom, - lines: [target.line], - edge: "bottom", - }); - } - } - } - - const best = pickClosestScaleCandidate({ - candidates, - preferredEdges, - }); - if (!best) { - return { snappedScale: proposedScale, activeLines: [] }; - } - - const snappedLeft = position.x - aabbBaseHalfW * best.scale; - const snappedRight = position.x + aabbBaseHalfW * best.scale; - const snappedTop = position.y - aabbBaseHalfH * best.scale; - const snappedBottom = position.y + aabbBaseHalfH * best.scale; - - const activeLines: SnapLine[] = []; - const seenKeys = new Set<string>(); - - function addLine({ line }: { line: SnapLine }) { - const key = `${line.type}-${line.position}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - activeLines.push(line); - } - } - - for (const target of verticalTargets) { - if ( - (hasPreferredEdge({ preferredEdges, edge: "left" }) && - Math.abs(snappedLeft - target.position) <= 1) || - (hasPreferredEdge({ preferredEdges, edge: "right" }) && - Math.abs(snappedRight - target.position) <= 1) || - (!preferredEdges && - (Math.abs(snappedLeft - target.position) <= 1 || - Math.abs(snappedRight - target.position) <= 1)) - ) { - addLine({ line: target.line }); - } - } - for (const target of horizontalTargets) { - if ( - (hasPreferredEdge({ preferredEdges, edge: "top" }) && - Math.abs(snappedTop - target.position) <= 1) || - (hasPreferredEdge({ preferredEdges, edge: "bottom" }) && - Math.abs(snappedBottom - target.position) <= 1) || - (!preferredEdges && - (Math.abs(snappedTop - target.position) <= 1 || - Math.abs(snappedBottom - target.position) <= 1)) - ) { - addLine({ line: target.line }); - } - } - - return { - snappedScale: best.scale, - activeLines, - }; -} - -export interface AxisSnapResult { - snappedScale: number; - /** Infinity when no snap candidate was within threshold */ - snapDistance: number; - activeLines: SnapLine[]; -} - -export function snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position, - baseWidth, - baseHeight, - rotation = 0, - canvasSize, - snapThreshold, - preferredEdges, -}: { - proposedScaleX: number; - proposedScaleY: number; - position: { x: number; y: number }; - baseWidth: number; - baseHeight: number; - rotation?: number; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; - preferredEdges?: ScaleEdgePreference; -}): { x: AxisSnapResult; y: AxisSnapResult } { - const canvasLeft = -canvasSize.width / 2; - const canvasRight = canvasSize.width / 2; - const canvasTop = -canvasSize.height / 2; - const canvasBottom = canvasSize.height / 2; - - const rotRad = (rotation * Math.PI) / 180; - const cosR = Math.abs(Math.cos(rotRad)); - const sinR = Math.abs(Math.sin(rotRad)); - const EPSILON = 1e-6; - - // Current AABB edges at proposed scales - const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2; - const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2; - const currentLeftEdge = position.x - currentAabbHalfW; - const currentRightEdge = position.x + currentAabbHalfW; - const currentTopEdge = position.y - currentAabbHalfH; - const currentBottomEdge = position.y + currentAabbHalfH; - - interface Candidate { - scale: number; - distance: number; - line: SnapLine; - edge: ScaleEdge; - } - - function bestCandidate({ - candidates, - proposedScale, - }: { - candidates: Candidate[]; - proposedScale: number; - }): AxisSnapResult { - const best = pickClosestScaleCandidate({ - candidates, - preferredEdges, - }); - if (!best) { - return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] }; - } - return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] }; - } - - // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠0 - // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠0 - const xCandidates: Candidate[] = []; - const yContribW = baseHeight * proposedScaleY * sinR; - const yContribH = baseHeight * proposedScaleY * cosR; - - if (cosR > EPSILON) { - for (const T of [canvasLeft, 0, canvasRight]) { - const line: SnapLine = { type: "vertical", position: T }; - const distLeft = Math.abs(currentLeftEdge - T); - if (distLeft <= snapThreshold.x) { - const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR); - if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" }); - } - const distRight = Math.abs(currentRightEdge - T); - if (distRight <= snapThreshold.x) { - const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR); - if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" }); - } - } - } - - if (sinR > EPSILON) { - for (const T of [canvasTop, 0, canvasBottom]) { - const line: SnapLine = { type: "horizontal", position: T }; - const distTop = Math.abs(currentTopEdge - T); - if (distTop <= snapThreshold.y) { - const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR); - if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" }); - } - const distBottom = Math.abs(currentBottomEdge - T); - if (distBottom <= snapThreshold.y) { - const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR); - if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" }); - } - } - } - - // sY candidates: snap via vertical targets — only valid when sinR ≠0 - // snap via horizontal targets — only valid when cosR ≠0 - const yCandidates: Candidate[] = []; - const xContribW = baseWidth * proposedScaleX * cosR; - const xContribH = baseWidth * proposedScaleX * sinR; - - if (sinR > EPSILON) { - for (const T of [canvasLeft, 0, canvasRight]) { - const line: SnapLine = { type: "vertical", position: T }; - const distLeft = Math.abs(currentLeftEdge - T); - if (distLeft <= snapThreshold.x) { - const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR); - if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" }); - } - const distRight = Math.abs(currentRightEdge - T); - if (distRight <= snapThreshold.x) { - const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR); - if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" }); - } - } - } - - if (cosR > EPSILON) { - for (const T of [canvasTop, 0, canvasBottom]) { - const line: SnapLine = { type: "horizontal", position: T }; - const distTop = Math.abs(currentTopEdge - T); - if (distTop <= snapThreshold.y) { - const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR); - if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" }); - } - const distBottom = Math.abs(currentBottomEdge - T); - if (distBottom <= snapThreshold.y) { - const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR); - if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" }); - } - } - } - - return { - x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }), - y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }), - }; -} - -export interface RotationSnapResult { - snappedRotation: number; - isSnapped: boolean; -} - -export function snapRotation({ - proposedRotation, -}: { - proposedRotation: number; -}): RotationSnapResult { - const nearestRotationSnap = - Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) * - ROTATION_SNAP_STEP_DEGREES; - const distanceToNearestSnap = Math.abs( - proposedRotation - nearestRotationSnap, - ); - if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) { - return { snappedRotation: nearestRotationSnap, isSnapped: true }; - } - return { snappedRotation: proposedRotation, isSnapped: false }; -} +export interface SnapLine {
+ type: "horizontal" | "vertical";
+ position: number;
+}
+
+const ROTATION_SNAP_STEP_DEGREES = 90;
+const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
+export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
+
+export interface SnapResult {
+ snappedPosition: { x: number; y: number };
+ activeLines: SnapLine[];
+}
+
+type ScaleEdge = "left" | "right" | "top" | "bottom";
+
+export interface ScaleEdgePreference {
+ left?: boolean;
+ right?: boolean;
+ top?: boolean;
+ bottom?: boolean;
+}
+
+function hasPreferredEdge({
+ preferredEdges,
+ edge,
+}: {
+ preferredEdges?: ScaleEdgePreference;
+ edge: ScaleEdge;
+}): boolean {
+ return preferredEdges?.[edge] === true;
+}
+
+function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
+ candidates,
+ preferredEdges,
+}: {
+ candidates: T[];
+ preferredEdges?: ScaleEdgePreference;
+}): T | null {
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ return candidates.reduce((bestCandidate, candidate) => {
+ if (candidate.distance < bestCandidate.distance) {
+ return candidate;
+ }
+ if (candidate.distance > bestCandidate.distance) {
+ return bestCandidate;
+ }
+
+ const shouldPreferCandidate = hasPreferredEdge({
+ preferredEdges,
+ edge: candidate.edge,
+ });
+ const shouldPreferBestCandidate = hasPreferredEdge({
+ preferredEdges,
+ edge: bestCandidate.edge,
+ });
+
+ return shouldPreferCandidate && !shouldPreferBestCandidate
+ ? candidate
+ : bestCandidate;
+ });
+}
+
+export function snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize,
+ rotation = 0,
+ snapThreshold,
+}: {
+ proposedPosition: { x: number; y: number };
+ canvasSize: { width: number; height: number };
+ elementSize: { width: number; height: number };
+ rotation?: number;
+ snapThreshold: { x: number; y: number };
+}): SnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
+ const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
+ const activeLines: SnapLine[] = [];
+
+ type AxisSnapCandidate = {
+ snappedPosition: number;
+ line: SnapLine;
+ distance: number;
+ };
+
+ function getClosestAxisSnap({
+ candidates,
+ threshold,
+ }: {
+ candidates: AxisSnapCandidate[];
+ threshold: number;
+ }): AxisSnapCandidate | null {
+ const snapCandidatesWithinThreshold = candidates.filter(
+ (candidate) => candidate.distance <= threshold,
+ );
+ if (snapCandidatesWithinThreshold.length === 0) {
+ return null;
+ }
+ return snapCandidatesWithinThreshold.reduce((closest, current) =>
+ current.distance < closest.distance ? current : closest,
+ );
+ }
+
+ const verticalTargets = [centerX, left, right];
+ const horizontalTargets = [centerY, top, bottom];
+
+ const xCandidates: AxisSnapCandidate[] = [];
+ for (const targetX of verticalTargets) {
+ xCandidates.push({
+ snappedPosition: targetX,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX + halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - halfWidth - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX - halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x + halfWidth - targetX),
+ });
+ }
+ const yCandidates: AxisSnapCandidate[] = [];
+ for (const targetY of horizontalTargets) {
+ yCandidates.push({
+ snappedPosition: targetY,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY + halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - halfHeight - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY - halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y + halfHeight - targetY),
+ });
+ }
+
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
+
+ const x = closestX?.snappedPosition ?? proposedPosition.x;
+ const y = closestY?.snappedPosition ?? proposedPosition.y;
+ if (closestX) {
+ activeLines.push(closestX.line);
+ }
+ if (closestY) {
+ activeLines.push(closestY.line);
+ }
+
+ return {
+ snappedPosition: { x, y },
+ activeLines,
+ };
+}
+
+export interface ScaleSnapResult {
+ snappedScale: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScale({
+ proposedScale,
+ position,
+ baseWidth,
+ baseHeight,
+ rotation = 0,
+ canvasSize,
+ snapThreshold,
+ preferredEdges,
+}: {
+ proposedScale: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ rotation?: number;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
+}): ScaleSnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
+ const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
+
+ const leftEdge = position.x - aabbBaseHalfW * proposedScale;
+ const rightEdge = position.x + aabbBaseHalfW * proposedScale;
+ const topEdge = position.y - aabbBaseHalfH * proposedScale;
+ const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
+
+ interface SnapCandidate {
+ scale: number;
+ distance: number;
+ lines: SnapLine[];
+ edge: ScaleEdge;
+ }
+
+ const candidates: SnapCandidate[] = [];
+
+ const verticalTargets = [
+ { position: left, line: { type: "vertical" as const, position: left } },
+ {
+ position: centerX,
+ line: { type: "vertical" as const, position: centerX },
+ },
+ { position: right, line: { type: "vertical" as const, position: right } },
+ ];
+
+ for (const target of verticalTargets) {
+ const distanceLeft = Math.abs(leftEdge - target.position);
+ if (distanceLeft <= snapThreshold.x) {
+ const scale = (position.x - target.position) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceLeft,
+ lines: [target.line],
+ edge: "left",
+ });
+ }
+ }
+ const distanceRight = Math.abs(rightEdge - target.position);
+ if (distanceRight <= snapThreshold.x) {
+ const scale = (target.position - position.x) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceRight,
+ lines: [target.line],
+ edge: "right",
+ });
+ }
+ }
+ }
+
+ const horizontalTargets = [
+ { position: top, line: { type: "horizontal" as const, position: top } },
+ {
+ position: centerY,
+ line: { type: "horizontal" as const, position: centerY },
+ },
+ {
+ position: bottom,
+ line: { type: "horizontal" as const, position: bottom },
+ },
+ ];
+
+ for (const target of horizontalTargets) {
+ const distanceTop = Math.abs(topEdge - target.position);
+ if (distanceTop <= snapThreshold.y) {
+ const scale = (position.y - target.position) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceTop,
+ lines: [target.line],
+ edge: "top",
+ });
+ }
+ }
+ const distanceBottom = Math.abs(bottomEdge - target.position);
+ if (distanceBottom <= snapThreshold.y) {
+ const scale = (target.position - position.y) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceBottom,
+ lines: [target.line],
+ edge: "bottom",
+ });
+ }
+ }
+ }
+
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, activeLines: [] };
+ }
+
+ const snappedLeft = position.x - aabbBaseHalfW * best.scale;
+ const snappedRight = position.x + aabbBaseHalfW * best.scale;
+ const snappedTop = position.y - aabbBaseHalfH * best.scale;
+ const snappedBottom = position.y + aabbBaseHalfH * best.scale;
+
+ const activeLines: SnapLine[] = [];
+ const seenKeys = new Set<string>();
+
+ function addLine({ line }: { line: SnapLine }) {
+ const key = `${line.type}-${line.position}`;
+ if (!seenKeys.has(key)) {
+ seenKeys.add(key);
+ activeLines.push(line);
+ }
+ }
+
+ for (const target of verticalTargets) {
+ if (
+ (hasPreferredEdge({ preferredEdges, edge: "left" }) &&
+ Math.abs(snappedLeft - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "right" }) &&
+ Math.abs(snappedRight - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedLeft - target.position) <= 1 ||
+ Math.abs(snappedRight - target.position) <= 1))
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+ for (const target of horizontalTargets) {
+ if (
+ (hasPreferredEdge({ preferredEdges, edge: "top" }) &&
+ Math.abs(snappedTop - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
+ Math.abs(snappedBottom - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedTop - target.position) <= 1 ||
+ Math.abs(snappedBottom - target.position) <= 1))
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+
+ return {
+ snappedScale: best.scale,
+ activeLines,
+ };
+}
+
+export interface AxisSnapResult {
+ snappedScale: number;
+ /** Infinity when no snap candidate was within threshold */
+ snapDistance: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position,
+ baseWidth,
+ baseHeight,
+ rotation = 0,
+ canvasSize,
+ snapThreshold,
+ preferredEdges,
+}: {
+ proposedScaleX: number;
+ proposedScaleY: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ rotation?: number;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
+}): { x: AxisSnapResult; y: AxisSnapResult } {
+ const canvasLeft = -canvasSize.width / 2;
+ const canvasRight = canvasSize.width / 2;
+ const canvasTop = -canvasSize.height / 2;
+ const canvasBottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const EPSILON = 1e-6;
+
+ // Current AABB edges at proposed scales
+ const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
+ const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
+ const currentLeftEdge = position.x - currentAabbHalfW;
+ const currentRightEdge = position.x + currentAabbHalfW;
+ const currentTopEdge = position.y - currentAabbHalfH;
+ const currentBottomEdge = position.y + currentAabbHalfH;
+
+ interface Candidate {
+ scale: number;
+ distance: number;
+ line: SnapLine;
+ edge: ScaleEdge;
+ }
+
+ function bestCandidate({
+ candidates,
+ proposedScale,
+ }: {
+ candidates: Candidate[];
+ proposedScale: number;
+ }): AxisSnapResult {
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
+ }
+ return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
+ }
+
+ // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠0
+ // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠0
+ const xCandidates: Candidate[] = [];
+ const yContribW = baseHeight * proposedScaleY * sinR;
+ const yContribH = baseHeight * proposedScaleY * cosR;
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ // sY candidates: snap via vertical targets — only valid when sinR ≠0
+ // snap via horizontal targets — only valid when cosR ≠0
+ const yCandidates: Candidate[] = [];
+ const xContribW = baseWidth * proposedScaleX * cosR;
+ const xContribH = baseWidth * proposedScaleX * sinR;
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ return {
+ x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
+ y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
+ };
+}
+
+export interface RotationSnapResult {
+ snappedRotation: number;
+ isSnapped: boolean;
+}
+
+export function snapRotation({
+ proposedRotation,
+}: {
+ proposedRotation: number;
+}): RotationSnapResult {
+ const nearestRotationSnap =
+ Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
+ ROTATION_SNAP_STEP_DEGREES;
+ const distanceToNearestSnap = Math.abs(
+ proposedRotation - nearestRotationSnap,
+ );
+ if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
+ return { snappedRotation: nearestRotationSnap, isSnapped: true };
+ }
+ return { snappedRotation: proposedRotation, isSnapped: false };
+}
diff --git a/apps/web/src/lib/project/__tests__/fps.test.ts b/apps/web/src/lib/project/__tests__/fps.test.ts new file mode 100644 index 00000000..b30408ef --- /dev/null +++ b/apps/web/src/lib/project/__tests__/fps.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test";
+import {
+ getHighestImportedVideoFps,
+ getRaisedProjectFpsForImportedMedia,
+} from "@/lib/project/fps";
+
+describe("getHighestImportedVideoFps", () => {
+ test("returns the highest valid video fps", () => {
+ expect(
+ getHighestImportedVideoFps({
+ mediaAssets: [
+ { type: "audio" },
+ { type: "video", fps: 30 },
+ { type: "image", fps: 120 },
+ { type: "video", fps: 60 },
+ ],
+ }),
+ ).toBe(60);
+ });
+
+ test("ignores missing and invalid fps values", () => {
+ expect(
+ getHighestImportedVideoFps({
+ mediaAssets: [
+ { type: "video" },
+ { type: "video", fps: 0 },
+ { type: "video", fps: -10 },
+ { type: "audio", fps: 120 },
+ ],
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("getRaisedProjectFpsForImportedMedia", () => {
+ test("raises the project fps to match a higher-fps import", () => {
+ expect(
+ getRaisedProjectFpsForImportedMedia({
+ currentFps: 30,
+ importedAssets: [{ type: "video", fps: 60 }],
+ }),
+ ).toBe(60);
+ });
+
+ test("does not lower the project fps for lower-fps imports", () => {
+ expect(
+ getRaisedProjectFpsForImportedMedia({
+ currentFps: 60,
+ importedAssets: [{ type: "video", fps: 10 }],
+ }),
+ ).toBeNull();
+ });
+
+ test("ignores non-video imports", () => {
+ expect(
+ getRaisedProjectFpsForImportedMedia({
+ currentFps: 30,
+ importedAssets: [
+ { type: "image", fps: 60 },
+ { type: "audio", fps: 120 },
+ ],
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/project/fps.ts b/apps/web/src/lib/project/fps.ts new file mode 100644 index 00000000..f031d348 --- /dev/null +++ b/apps/web/src/lib/project/fps.ts @@ -0,0 +1,39 @@ +import type { MediaAsset } from "@/lib/media/types";
+
+type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
+
+export function getHighestImportedVideoFps({
+ mediaAssets,
+}: {
+ mediaAssets: MediaAssetFpsInput[];
+}): number | null {
+ let highestFps: number | null = null;
+
+ for (const asset of mediaAssets) {
+ const fps = asset.fps ?? Number.NaN;
+ if (asset.type !== "video") continue;
+ if (!Number.isFinite(fps) || fps <= 0) continue;
+
+ highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
+ }
+
+ return highestFps;
+}
+
+export function getRaisedProjectFpsForImportedMedia({
+ currentFps,
+ importedAssets,
+}: {
+ currentFps: number;
+ importedAssets: MediaAssetFpsInput[];
+}): number | null {
+ const highestImportedVideoFps = getHighestImportedVideoFps({
+ mediaAssets: importedAssets,
+ });
+
+ if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
+ return null;
+ }
+
+ return highestImportedVideoFps;
+}
diff --git a/apps/web/src/lib/project/types.ts b/apps/web/src/lib/project/types.ts index 734c3835..8f45db9e 100644 --- a/apps/web/src/lib/project/types.ts +++ b/apps/web/src/lib/project/types.ts @@ -1,53 +1,53 @@ -import type { TScene } from "@/lib/timeline/types"; - -export type TBackground = - | { - type: "color"; - color: string; - } - | { - type: "blur"; - blurIntensity: number; - }; - -export interface TCanvasSize { - width: number; - height: number; -} - -export interface TProjectMetadata { - id: string; - name: string; - thumbnail?: string; - duration: number; - createdAt: Date; - updatedAt: Date; -} - -export interface TProjectSettings { - fps: number; - canvasSize: TCanvasSize; - canvasSizeMode?: "preset" | "custom"; - lastCustomCanvasSize?: TCanvasSize | null; - originalCanvasSize?: TCanvasSize | null; - background: TBackground; -} - -export interface TTimelineViewState { - zoomLevel: number; - scrollLeft: number; - playheadTime: number; -} - -export interface TProject { - metadata: TProjectMetadata; - scenes: TScene[]; - currentSceneId: string; - settings: TProjectSettings; - version: number; - timelineViewState?: TTimelineViewState; -} - -export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"; -export type TSortOrder = "asc" | "desc"; -export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`; +import type { TScene } from "@/lib/timeline/types";
+
+export type TBackground =
+ | {
+ type: "color";
+ color: string;
+ }
+ | {
+ type: "blur";
+ blurIntensity: number;
+ };
+
+export interface TCanvasSize {
+ width: number;
+ height: number;
+}
+
+export interface TProjectMetadata {
+ id: string;
+ name: string;
+ thumbnail?: string;
+ duration: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface TProjectSettings {
+ fps: number;
+ canvasSize: TCanvasSize;
+ canvasSizeMode?: "preset" | "custom";
+ lastCustomCanvasSize?: TCanvasSize | null;
+ originalCanvasSize?: TCanvasSize | null;
+ background: TBackground;
+}
+
+export interface TTimelineViewState {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+}
+
+export interface TProject {
+ metadata: TProjectMetadata;
+ scenes: TScene[];
+ currentSceneId: string;
+ settings: TProjectSettings;
+ version: number;
+ timelineViewState?: TTimelineViewState;
+}
+
+export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
+export type TSortOrder = "asc" | "desc";
+export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index d18889af..6b59c473 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -1,21 +1,21 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { webEnv } from "@opencut/env/web"; - -const redis = new Redis({ - url: webEnv.UPSTASH_REDIS_REST_URL, - token: webEnv.UPSTASH_REDIS_REST_TOKEN, -}); - -export const baseRateLimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute - analytics: true, - prefix: "rate-limit", -}); - -export async function checkRateLimit({ request }: { request: Request }) { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - return { success, limited: !success }; -} +import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+import { webEnv } from "@opencut/env/web";
+
+const redis = new Redis({
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const baseRateLimit = new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
+ analytics: true,
+ prefix: "rate-limit",
+});
+
+export async function checkRateLimit({ request }: { request: Request }) {
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+ return { success, limited: !success };
+}
diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts index 433feae7..f1c92f67 100644 --- a/apps/web/src/lib/registry.ts +++ b/apps/web/src/lib/registry.ts @@ -1,40 +1,40 @@ -import type { ParamDefinition, ParamValues } from "@/lib/params"; - -export function buildDefaultParamValues( - params: ParamDefinition[], -): ParamValues { - const values: ParamValues = {}; - for (const param of params) { - values[param.key] = param.default; - } - return values; -} - -export class DefinitionRegistry<TKey extends string, TDefinition> { - private definitions = new Map<TKey, TDefinition>(); - private entityName: string; - - constructor(entityName: string) { - this.entityName = entityName; - } - - register(key: TKey, definition: TDefinition): void { - this.definitions.set(key, definition); - } - - has(key: TKey): boolean { - return this.definitions.has(key); - } - - get(key: TKey): TDefinition { - const def = this.definitions.get(key); - if (!def) { - throw new Error(`Unknown ${this.entityName}: ${key}`); - } - return def; - } - - getAll(): TDefinition[] { - return Array.from(this.definitions.values()); - } -} +import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export function buildDefaultParamValues(
+ params: ParamDefinition[],
+): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ values[param.key] = param.default;
+ }
+ return values;
+}
+
+export class DefinitionRegistry<TKey extends string, TDefinition> {
+ private definitions = new Map<TKey, TDefinition>();
+ private entityName: string;
+
+ constructor(entityName: string) {
+ this.entityName = entityName;
+ }
+
+ register(key: TKey, definition: TDefinition): void {
+ this.definitions.set(key, definition);
+ }
+
+ has(key: TKey): boolean {
+ return this.definitions.has(key);
+ }
+
+ get(key: TKey): TDefinition {
+ const def = this.definitions.get(key);
+ if (!def) {
+ throw new Error(`Unknown ${this.entityName}: ${key}`);
+ }
+ return def;
+ }
+
+ getAll(): TDefinition[] {
+ return Array.from(this.definitions.values());
+ }
+}
diff --git a/apps/web/src/lib/rendering.ts b/apps/web/src/lib/rendering.ts index d16296e2..c5555371 100644 --- a/apps/web/src/lib/rendering.ts +++ b/apps/web/src/lib/rendering.ts @@ -1,28 +1,28 @@ -export interface Transform { - scaleX: number; - scaleY: number; - position: { - x: number; - y: number; - }; - rotate: number; -} - -export type BlendMode = - | "normal" - | "darken" - | "multiply" - | "color-burn" - | "lighten" - | "screen" - | "plus-lighter" - | "color-dodge" - | "overlay" - | "soft-light" - | "hard-light" - | "difference" - | "exclusion" - | "hue" - | "saturation" - | "color" - | "luminosity"; +export interface Transform {
+ scaleX: number;
+ scaleY: number;
+ position: {
+ x: number;
+ y: number;
+ };
+ rotate: number;
+}
+
+export type BlendMode =
+ | "normal"
+ | "darken"
+ | "multiply"
+ | "color-burn"
+ | "lighten"
+ | "screen"
+ | "plus-lighter"
+ | "color-dodge"
+ | "overlay"
+ | "soft-light"
+ | "hard-light"
+ | "difference"
+ | "exclusion"
+ | "hue"
+ | "saturation"
+ | "color"
+ | "luminosity";
diff --git a/apps/web/src/lib/retime/__tests__/resolve.test.ts b/apps/web/src/lib/retime/__tests__/resolve.test.ts index 4fcf6249..2fbd6675 100644 --- a/apps/web/src/lib/retime/__tests__/resolve.test.ts +++ b/apps/web/src/lib/retime/__tests__/resolve.test.ts @@ -1,61 +1,61 @@ -import { describe, expect, test } from "bun:test"; -import { - getClipTimeAtSourceTime, - getEffectiveRateAt, - getSourceTimeAtClipTime, - getTimelineDurationForSourceSpan, -} from "@/lib/retime"; -import type { RetimeConfig } from "@/lib/timeline"; - -const twoX: RetimeConfig = { rate: 2 }; -const halfX: RetimeConfig = { rate: 0.5 }; - -describe("retime resolve", () => { - test("maps clip time to source time at 2x speed", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: twoX })).toBe(10); - }); - - test("maps clip time to source time at 0.5x speed", () => { - expect(getSourceTimeAtClipTime({ clipTime: 4, retime: halfX })).toBe(2); - }); - - test("returns clip time unchanged when no retime", () => { - expect(getSourceTimeAtClipTime({ clipTime: 7 })).toBe(7); - }); - - test("inverts source time back to clip time at 2x speed", () => { - expect(getClipTimeAtSourceTime({ sourceTime: 10, retime: twoX })).toBe(5); - }); - - test("returns effective rate", () => { - expect(getEffectiveRateAt({ retime: twoX })).toBe(2); - expect(getEffectiveRateAt({})).toBe(1); - }); - - test("derives timeline duration for a visible source span", () => { - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: twoX }), - ).toBe(5); - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: halfX }), - ).toBe(20); - }); - - test("clamps invalid rates to 1", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 0 } })).toBe( - 5, - ); - expect( - getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: -1 } }), - ).toBe(5); - }); - - test("caps retime rates above 5x", () => { - expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 100 } })).toBe( - 25, - ); - expect( - getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: { rate: 100 } }), - ).toBe(2); - }); -}); +import { describe, expect, test } from "bun:test";
+import {
+ getClipTimeAtSourceTime,
+ getEffectiveRateAt,
+ getSourceTimeAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
+import type { RetimeConfig } from "@/lib/timeline";
+
+const twoX: RetimeConfig = { rate: 2 };
+const halfX: RetimeConfig = { rate: 0.5 };
+
+describe("retime resolve", () => {
+ test("maps clip time to source time at 2x speed", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: twoX })).toBe(10);
+ });
+
+ test("maps clip time to source time at 0.5x speed", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 4, retime: halfX })).toBe(2);
+ });
+
+ test("returns clip time unchanged when no retime", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 7 })).toBe(7);
+ });
+
+ test("inverts source time back to clip time at 2x speed", () => {
+ expect(getClipTimeAtSourceTime({ sourceTime: 10, retime: twoX })).toBe(5);
+ });
+
+ test("returns effective rate", () => {
+ expect(getEffectiveRateAt({ retime: twoX })).toBe(2);
+ expect(getEffectiveRateAt({})).toBe(1);
+ });
+
+ test("derives timeline duration for a visible source span", () => {
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: twoX }),
+ ).toBe(5);
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: halfX }),
+ ).toBe(20);
+ });
+
+ test("clamps invalid rates to 1", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 0 } })).toBe(
+ 5,
+ );
+ expect(
+ getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: -1 } }),
+ ).toBe(5);
+ });
+
+ test("caps retime rates above 5x", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 100 } })).toBe(
+ 25,
+ );
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: { rate: 100 } }),
+ ).toBe(2);
+ });
+});
diff --git a/apps/web/src/lib/retime/__tests__/split.test.ts b/apps/web/src/lib/retime/__tests__/split.test.ts index 0ce532a1..a00eadac 100644 --- a/apps/web/src/lib/retime/__tests__/split.test.ts +++ b/apps/web/src/lib/retime/__tests__/split.test.ts @@ -1,31 +1,31 @@ -import { describe, expect, test } from "bun:test"; -import { - getSourceSpanAtClipTime, - splitRetimeAtClipTime, -} from "@/lib/retime"; -import type { RetimeConfig } from "@/lib/timeline"; - -describe("retime split", () => { - test("measures source span at a clip time", () => { - const retime: RetimeConfig = { rate: 2 }; - expect(getSourceSpanAtClipTime({ clipTime: 5, retime })).toBe(10); - }); - - test("returns zero for non-positive clip time", () => { - expect(getSourceSpanAtClipTime({ clipTime: 0 })).toBe(0); - expect(getSourceSpanAtClipTime({ clipTime: -1 })).toBe(0); - }); - - test("passes the same retime to both halves when splitting", () => { - const retime: RetimeConfig = { rate: 1.5 }; - const result = splitRetimeAtClipTime({ retime, splitClipTime: 3 }); - expect(result.left).toBe(retime); - expect(result.right).toBe(retime); - }); - - test("returns undefined on both sides when no retime", () => { - const result = splitRetimeAtClipTime({ splitClipTime: 3 }); - expect(result.left).toBeUndefined(); - expect(result.right).toBeUndefined(); - }); -}); +import { describe, expect, test } from "bun:test";
+import {
+ getSourceSpanAtClipTime,
+ splitRetimeAtClipTime,
+} from "@/lib/retime";
+import type { RetimeConfig } from "@/lib/timeline";
+
+describe("retime split", () => {
+ test("measures source span at a clip time", () => {
+ const retime: RetimeConfig = { rate: 2 };
+ expect(getSourceSpanAtClipTime({ clipTime: 5, retime })).toBe(10);
+ });
+
+ test("returns zero for non-positive clip time", () => {
+ expect(getSourceSpanAtClipTime({ clipTime: 0 })).toBe(0);
+ expect(getSourceSpanAtClipTime({ clipTime: -1 })).toBe(0);
+ });
+
+ test("passes the same retime to both halves when splitting", () => {
+ const retime: RetimeConfig = { rate: 1.5 };
+ const result = splitRetimeAtClipTime({ retime, splitClipTime: 3 });
+ expect(result.left).toBe(retime);
+ expect(result.right).toBe(retime);
+ });
+
+ test("returns undefined on both sides when no retime", () => {
+ const result = splitRetimeAtClipTime({ splitClipTime: 3 });
+ expect(result.left).toBeUndefined();
+ expect(result.right).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts index 51ef38d0..f8dd297c 100644 --- a/apps/web/src/lib/retime/audio-stretch.ts +++ b/apps/web/src/lib/retime/audio-stretch.ts @@ -1,182 +1,182 @@ -import { PitchShifter } from "soundtouchjs"; -import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants"; -import type { RetimeConfig } from "@/lib/timeline"; -import { getSourceTimeAtClipTime } from "./resolve"; - -const RATE_EPSILON = 1e-6; - -function sampleLinear({ - channelData, - position, -}: { - channelData: Float32Array; - position: number; -}): number { - if (position <= 0) { - return channelData[0] ?? 0; - } - const lower = Math.floor(position); - const upper = Math.min(channelData.length - 1, lower + 1); - if (lower >= channelData.length) { - return 0; - } - const fraction = position - lower; - return channelData[lower] * (1 - fraction) + channelData[upper] * fraction; -} - -function buildResampledBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - targetSampleRate, - retime, -}: { - audioContext: BaseAudioContext; - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - targetSampleRate: number; - retime?: RetimeConfig; -}): AudioBuffer { - const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate)); - const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); - const outputBuffer = audioContext.createBuffer( - numChannels, - outputLength, - targetSampleRate, - ); - - for (let channel = 0; channel < numChannels; channel++) { - const sourceData = sourceBuffer.getChannelData( - Math.min(channel, sourceBuffer.numberOfChannels - 1), - ); - const outputData = outputBuffer.getChannelData(channel); - - for (let i = 0; i < outputLength; i++) { - const clipTime = i / targetSampleRate; - const sourceTime = - trimStart + getSourceTimeAtClipTime({ clipTime, retime }); - outputData[i] = sampleLinear({ - channelData: sourceData, - position: sourceTime * sourceBuffer.sampleRate, - }); - } - } - - return outputBuffer; -} - -async function buildPitchPreservedBuffer({ - sourceBuffer, - trimStart, - clipDuration, - rate, - targetSampleRate, -}: { - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - rate: number; - targetSampleRate: number; -}): Promise<AudioBuffer> { - const nativeSampleRate = sourceBuffer.sampleRate; - const sourceDuration = clipDuration * rate; - const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate)); - const numSourceSamples = Math.max( - 1, - Math.ceil(sourceDuration * nativeSampleRate), - ); - const available = Math.max(0, sourceBuffer.length - startSample); - const actualSamples = Math.max(1, Math.min(numSourceSamples, available)); - const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); - - // Resample to targetSampleRate first — soundtouchjs reads raw channel data - // and does not respect the source buffer's native sample rate. - const resampledLength = Math.max( - 1, - Math.ceil(sourceDuration * targetSampleRate), - ); - const resampleCtx = new OfflineAudioContext( - numChannels, - resampledLength, - targetSampleRate, - ); - const nativeBuffer = resampleCtx.createBuffer( - numChannels, - actualSamples, - nativeSampleRate, - ); - - for (let ch = 0; ch < numChannels; ch++) { - const src = sourceBuffer.getChannelData( - Math.min(ch, sourceBuffer.numberOfChannels - 1), - ); - nativeBuffer.copyToChannel( - src.subarray(startSample, startSample + actualSamples), - ch, - ); - } - - const resampleSourceNode = resampleCtx.createBufferSource(); - resampleSourceNode.buffer = nativeBuffer; - resampleSourceNode.connect(resampleCtx.destination); - resampleSourceNode.start(0); - const resampledBuffer = await resampleCtx.startRendering(); - - const outputSamples = Math.max( - 1, - Math.ceil(clipDuration * targetSampleRate), - ); - const stretchCtx = new OfflineAudioContext( - numChannels, - outputSamples, - targetSampleRate, - ); - const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096); - shifter.tempo = rate; - shifter.pitch = 1; - shifter.connect(stretchCtx.destination); - return stretchCtx.startRendering(); -} - -export async function renderRetimedBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - retime, - maintainPitch = false, -}: { - audioContext: BaseAudioContext; - sourceBuffer: AudioBuffer; - trimStart: number; - clipDuration: number; - retime?: RetimeConfig; - maintainPitch?: boolean; -}): Promise<AudioBuffer> { - const targetSampleRate = audioContext.sampleRate; - const rate = clampRetimeRate({ rate: retime?.rate ?? 1 }); - const usePitchPreservation = - shouldMaintainPitch({ rate, maintainPitch }) && - Math.abs(rate - 1) > RATE_EPSILON; - - if (usePitchPreservation) { - return buildPitchPreservedBuffer({ - sourceBuffer, - trimStart, - clipDuration, - rate, - targetSampleRate, - }); - } - - return buildResampledBuffer({ - audioContext, - sourceBuffer, - trimStart, - clipDuration, - targetSampleRate, - retime, - }); -} +import { PitchShifter } from "soundtouchjs";
+import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants";
+import type { RetimeConfig } from "@/lib/timeline";
+import { getSourceTimeAtClipTime } from "./resolve";
+
+const RATE_EPSILON = 1e-6;
+
+function sampleLinear({
+ channelData,
+ position,
+}: {
+ channelData: Float32Array;
+ position: number;
+}): number {
+ if (position <= 0) {
+ return channelData[0] ?? 0;
+ }
+ const lower = Math.floor(position);
+ const upper = Math.min(channelData.length - 1, lower + 1);
+ if (lower >= channelData.length) {
+ return 0;
+ }
+ const fraction = position - lower;
+ return channelData[lower] * (1 - fraction) + channelData[upper] * fraction;
+}
+
+function buildResampledBuffer({
+ audioContext,
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ targetSampleRate,
+ retime,
+}: {
+ audioContext: BaseAudioContext;
+ sourceBuffer: AudioBuffer;
+ trimStart: number;
+ clipDuration: number;
+ targetSampleRate: number;
+ retime?: RetimeConfig;
+}): AudioBuffer {
+ const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate));
+ const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels));
+ const outputBuffer = audioContext.createBuffer(
+ numChannels,
+ outputLength,
+ targetSampleRate,
+ );
+
+ for (let channel = 0; channel < numChannels; channel++) {
+ const sourceData = sourceBuffer.getChannelData(
+ Math.min(channel, sourceBuffer.numberOfChannels - 1),
+ );
+ const outputData = outputBuffer.getChannelData(channel);
+
+ for (let i = 0; i < outputLength; i++) {
+ const clipTime = i / targetSampleRate;
+ const sourceTime =
+ trimStart + getSourceTimeAtClipTime({ clipTime, retime });
+ outputData[i] = sampleLinear({
+ channelData: sourceData,
+ position: sourceTime * sourceBuffer.sampleRate,
+ });
+ }
+ }
+
+ return outputBuffer;
+}
+
+async function buildPitchPreservedBuffer({
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ rate,
+ targetSampleRate,
+}: {
+ sourceBuffer: AudioBuffer;
+ trimStart: number;
+ clipDuration: number;
+ rate: number;
+ targetSampleRate: number;
+}): Promise<AudioBuffer> {
+ const nativeSampleRate = sourceBuffer.sampleRate;
+ const sourceDuration = clipDuration * rate;
+ const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate));
+ const numSourceSamples = Math.max(
+ 1,
+ Math.ceil(sourceDuration * nativeSampleRate),
+ );
+ const available = Math.max(0, sourceBuffer.length - startSample);
+ const actualSamples = Math.max(1, Math.min(numSourceSamples, available));
+ const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels));
+
+ // Resample to targetSampleRate first — soundtouchjs reads raw channel data
+ // and does not respect the source buffer's native sample rate.
+ const resampledLength = Math.max(
+ 1,
+ Math.ceil(sourceDuration * targetSampleRate),
+ );
+ const resampleCtx = new OfflineAudioContext(
+ numChannels,
+ resampledLength,
+ targetSampleRate,
+ );
+ const nativeBuffer = resampleCtx.createBuffer(
+ numChannels,
+ actualSamples,
+ nativeSampleRate,
+ );
+
+ for (let ch = 0; ch < numChannels; ch++) {
+ const src = sourceBuffer.getChannelData(
+ Math.min(ch, sourceBuffer.numberOfChannels - 1),
+ );
+ nativeBuffer.copyToChannel(
+ src.subarray(startSample, startSample + actualSamples),
+ ch,
+ );
+ }
+
+ const resampleSourceNode = resampleCtx.createBufferSource();
+ resampleSourceNode.buffer = nativeBuffer;
+ resampleSourceNode.connect(resampleCtx.destination);
+ resampleSourceNode.start(0);
+ const resampledBuffer = await resampleCtx.startRendering();
+
+ const outputSamples = Math.max(
+ 1,
+ Math.ceil(clipDuration * targetSampleRate),
+ );
+ const stretchCtx = new OfflineAudioContext(
+ numChannels,
+ outputSamples,
+ targetSampleRate,
+ );
+ const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096);
+ shifter.tempo = rate;
+ shifter.pitch = 1;
+ shifter.connect(stretchCtx.destination);
+ return stretchCtx.startRendering();
+}
+
+export async function renderRetimedBuffer({
+ audioContext,
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ retime,
+ maintainPitch = false,
+}: {
+ audioContext: BaseAudioContext;
+ sourceBuffer: AudioBuffer;
+ trimStart: number;
+ clipDuration: number;
+ retime?: RetimeConfig;
+ maintainPitch?: boolean;
+}): Promise<AudioBuffer> {
+ const targetSampleRate = audioContext.sampleRate;
+ const rate = clampRetimeRate({ rate: retime?.rate ?? 1 });
+ const usePitchPreservation =
+ shouldMaintainPitch({ rate, maintainPitch }) &&
+ Math.abs(rate - 1) > RATE_EPSILON;
+
+ if (usePitchPreservation) {
+ return buildPitchPreservedBuffer({
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ rate,
+ targetSampleRate,
+ });
+ }
+
+ return buildResampledBuffer({
+ audioContext,
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ targetSampleRate,
+ retime,
+ });
+}
diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts index 4915c203..ca6725dd 100644 --- a/apps/web/src/lib/retime/index.ts +++ b/apps/web/src/lib/retime/index.ts @@ -1,4 +1,4 @@ -export * from "./audio-stretch"; -export * from "./presets"; -export * from "./resolve"; -export * from "./split"; +export * from "./audio-stretch";
+export * from "./presets";
+export * from "./resolve";
+export * from "./split";
diff --git a/apps/web/src/lib/retime/presets.ts b/apps/web/src/lib/retime/presets.ts index 382320b0..777c0219 100644 --- a/apps/web/src/lib/retime/presets.ts +++ b/apps/web/src/lib/retime/presets.ts @@ -1,12 +1,12 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { clampRetimeRate } from "@/constants/retime-constants"; - -export function buildConstantRetime({ - rate, - maintainPitch = false, -}: { - rate: number; - maintainPitch?: boolean; -}): RetimeConfig { - return { rate: clampRetimeRate({ rate }), maintainPitch }; -} +import type { RetimeConfig } from "@/lib/timeline";
+import { clampRetimeRate } from "@/constants/retime-constants";
+
+export function buildConstantRetime({
+ rate,
+ maintainPitch = false,
+}: {
+ rate: number;
+ maintainPitch?: boolean;
+}): RetimeConfig {
+ return { rate: clampRetimeRate({ rate }), maintainPitch };
+}
diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts index cfcfb6a2..31a562be 100644 --- a/apps/web/src/lib/retime/resolve.ts +++ b/apps/web/src/lib/retime/resolve.ts @@ -1,48 +1,48 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { clampRetimeRate } from "@/constants/retime-constants"; - -function getSafeRate({ rate }: { rate: number }): number { - return clampRetimeRate({ rate }); -} - -export function getSourceTimeAtClipTime({ - clipTime, - retime, -}: { - clipTime: number; - retime?: RetimeConfig; -}): number { - return clipTime * getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getClipTimeAtSourceTime({ - sourceTime, - retime, -}: { - sourceTime: number; - retime?: RetimeConfig; -}): number { - return sourceTime / getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getEffectiveRateAt({ - retime, -}: { - clipTime?: number; - retime?: RetimeConfig; -}): number { - return getSafeRate({ rate: retime?.rate ?? 1 }); -} - -export function getTimelineDurationForSourceSpan({ - sourceSpan, - retime, -}: { - sourceSpan: number; - retime?: RetimeConfig; -}): number { - if (sourceSpan <= 0) { - return 0; - } - return sourceSpan / getSafeRate({ rate: retime?.rate ?? 1 }); -} +import type { RetimeConfig } from "@/lib/timeline";
+import { clampRetimeRate } from "@/constants/retime-constants";
+
+function getSafeRate({ rate }: { rate: number }): number {
+ return clampRetimeRate({ rate });
+}
+
+export function getSourceTimeAtClipTime({
+ clipTime,
+ retime,
+}: {
+ clipTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return clipTime * getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getClipTimeAtSourceTime({
+ sourceTime,
+ retime,
+}: {
+ sourceTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return sourceTime / getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getEffectiveRateAt({
+ retime,
+}: {
+ clipTime?: number;
+ retime?: RetimeConfig;
+}): number {
+ return getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime,
+}: {
+ sourceSpan: number;
+ retime?: RetimeConfig;
+}): number {
+ if (sourceSpan <= 0) {
+ return 0;
+ }
+ return sourceSpan / getSafeRate({ rate: retime?.rate ?? 1 });
+}
diff --git a/apps/web/src/lib/retime/split.ts b/apps/web/src/lib/retime/split.ts index 12982292..2635590e 100644 --- a/apps/web/src/lib/retime/split.ts +++ b/apps/web/src/lib/retime/split.ts @@ -1,34 +1,34 @@ -import type { RetimeConfig } from "@/lib/timeline"; -import { getSourceTimeAtClipTime } from "./resolve"; - -export function getSourceSpanAtClipTime({ - clipTime, - retime, -}: { - clipTime: number; - retime?: RetimeConfig; -}): number { - return Math.max(0, getSourceTimeAtClipTime({ clipTime, retime })); -} - -export function splitRetimeAtClipTime({ - retime, -}: { - retime?: RetimeConfig; - splitClipTime: number; -}): { - left: RetimeConfig | undefined; - right: RetimeConfig | undefined; -} { - return { left: retime, right: retime }; -} - -export function adjustRetimeForTrimChange({ - retime, -}: { - retime?: RetimeConfig; - clipTrimTime: number; - side: "start" | "end"; -}): RetimeConfig | undefined { - return retime; -} +import type { RetimeConfig } from "@/lib/timeline";
+import { getSourceTimeAtClipTime } from "./resolve";
+
+export function getSourceSpanAtClipTime({
+ clipTime,
+ retime,
+}: {
+ clipTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return Math.max(0, getSourceTimeAtClipTime({ clipTime, retime }));
+}
+
+export function splitRetimeAtClipTime({
+ retime,
+}: {
+ retime?: RetimeConfig;
+ splitClipTime: number;
+}): {
+ left: RetimeConfig | undefined;
+ right: RetimeConfig | undefined;
+} {
+ return { left: retime, right: retime };
+}
+
+export function adjustRetimeForTrimChange({
+ retime,
+}: {
+ retime?: RetimeConfig;
+ clipTrimTime: number;
+ side: "start" | "end";
+}): RetimeConfig | undefined {
+ return retime;
+}
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 198116c0..9a63242c 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,103 +1,103 @@ -import type { TScene } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { calculateTotalDuration } from "@/lib/timeline"; -import { ensureMainTrack } from "@/lib/timeline/track-utils"; - -export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { - return scenes.find((scene) => scene.isMain) || null; -} - -export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] { - const hasMain = scenes.some((scene) => scene.isMain); - if (!hasMain) { - const mainScene = buildDefaultScene({ name: "Main scene", isMain: true }); - return [mainScene, ...scenes]; - } - return scenes; -} - -export function buildDefaultScene({ - name, - isMain, -}: { - name: string; - isMain: boolean; -}): TScene { - const tracks = ensureMainTrack({ tracks: [] }); - return { - id: generateUUID(), - name, - isMain, - tracks, - bookmarks: [], - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -export function canDeleteScene({ scene }: { scene: TScene }): { - canDelete: boolean; - reason?: string; -} { - if (scene.isMain) { - return { canDelete: false, reason: "Cannot delete main scene" }; - } - return { canDelete: true }; -} - -export function getFallbackSceneAfterDelete({ - scenes, - deletedSceneId, - currentSceneId, -}: { - scenes: TScene[]; - deletedSceneId: string; - currentSceneId: string | null; -}): TScene | null { - if (currentSceneId !== deletedSceneId) { - return scenes.find((s) => s.id === currentSceneId) || null; - } - return getMainScene({ scenes }); -} - -export function findCurrentScene({ - scenes, - currentSceneId, -}: { - scenes: TScene[]; - currentSceneId: string; -}): TScene | null { - return ( - scenes.find((s) => s.id === currentSceneId) || - getMainScene({ scenes }) || - scenes[0] || - null - ); -} - -export function getProjectDurationFromScenes({ - scenes, -}: { - scenes: TScene[]; -}): number { - const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; - if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) { - return 0; - } - - return calculateTotalDuration({ tracks: mainScene.tracks }); -} - -export function updateSceneInArray({ - scenes, - sceneId, - updates, -}: { - scenes: TScene[]; - sceneId: string; - updates: Partial<TScene>; -}): TScene[] { - return scenes.map((scene) => - scene.id === sceneId ? { ...scene, ...updates } : scene, - ); -} +import type { TScene } from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { calculateTotalDuration } from "@/lib/timeline";
+import { ensureMainTrack } from "@/lib/timeline/track-utils";
+
+export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
+ return scenes.find((scene) => scene.isMain) || null;
+}
+
+export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
+ return [mainScene, ...scenes];
+ }
+ return scenes;
+}
+
+export function buildDefaultScene({
+ name,
+ isMain,
+}: {
+ name: string;
+ isMain: boolean;
+}): TScene {
+ const tracks = ensureMainTrack({ tracks: [] });
+ return {
+ id: generateUUID(),
+ name,
+ isMain,
+ tracks,
+ bookmarks: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
+} {
+ if (scene.isMain) {
+ return { canDelete: false, reason: "Cannot delete main scene" };
+ }
+ return { canDelete: true };
+}
+
+export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
+}): TScene | null {
+ if (currentSceneId !== deletedSceneId) {
+ return scenes.find((s) => s.id === currentSceneId) || null;
+ }
+ return getMainScene({ scenes });
+}
+
+export function findCurrentScene({
+ scenes,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ currentSceneId: string;
+}): TScene | null {
+ return (
+ scenes.find((s) => s.id === currentSceneId) ||
+ getMainScene({ scenes }) ||
+ scenes[0] ||
+ null
+ );
+}
+
+export function getProjectDurationFromScenes({
+ scenes,
+}: {
+ scenes: TScene[];
+}): number {
+ const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
+ if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) {
+ return 0;
+ }
+
+ return calculateTotalDuration({ tracks: mainScene.tracks });
+}
+
+export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+}: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
+}): TScene[] {
+ return scenes.map((scene) =>
+ scene.id === sceneId ? { ...scene, ...updates } : scene,
+ );
+}
diff --git a/apps/web/src/lib/selection/constants.ts b/apps/web/src/lib/selection/constants.ts index a1aee842..a262c8ce 100644 --- a/apps/web/src/lib/selection/constants.ts +++ b/apps/web/src/lib/selection/constants.ts @@ -1 +1 @@ -export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item"; +export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item";
diff --git a/apps/web/src/lib/selection/context.tsx b/apps/web/src/lib/selection/context.tsx index ced49c88..8e87aa40 100644 --- a/apps/web/src/lib/selection/context.tsx +++ b/apps/web/src/lib/selection/context.tsx @@ -1,52 +1,52 @@ -"use client"; - -import { createContext, useContext } from "react"; - -interface SelectionContextValue { - selectedIds: string[]; - anchorId: string | null; - highlightedId: string | null; - isBoxSelecting: boolean; - isSelected: (id: string) => boolean; - clearSelection: () => void; - handleItemClick: ({ - event, - id, - }: { - event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>; - id: string; - }) => void; - handleItemMouseDown: ({ - event, - id, - }: { - event: React.MouseEvent<HTMLDivElement>; - id: string; - }) => void; - registerItem: (id: string, element: HTMLElement | null) => void; -} - -export const SelectionContext = createContext<SelectionContextValue | null>(null); - -export function useSelectionContext() { - const context = useContext(SelectionContext); - - if (!context) { - throw new Error("useSelectionContext must be used within SelectableSurface"); - } - - return context; -} - -export function useSelection() { - const { selectedIds, anchorId, highlightedId, isSelected, clearSelection } = - useSelectionContext(); - - return { - selectedIds, - anchorId, - highlightedId, - isSelected, - clearSelection, - }; -} +"use client";
+
+import { createContext, useContext } from "react";
+
+interface SelectionContextValue {
+ selectedIds: string[];
+ anchorId: string | null;
+ highlightedId: string | null;
+ isBoxSelecting: boolean;
+ isSelected: (id: string) => boolean;
+ clearSelection: () => void;
+ handleItemClick: ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ handleItemMouseDown: ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ registerItem: (id: string, element: HTMLElement | null) => void;
+}
+
+export const SelectionContext = createContext<SelectionContextValue | null>(null);
+
+export function useSelectionContext() {
+ const context = useContext(SelectionContext);
+
+ if (!context) {
+ throw new Error("useSelectionContext must be used within SelectableSurface");
+ }
+
+ return context;
+}
+
+export function useSelection() {
+ const { selectedIds, anchorId, highlightedId, isSelected, clearSelection } =
+ useSelectionContext();
+
+ return {
+ selectedIds,
+ anchorId,
+ highlightedId,
+ isSelected,
+ clearSelection,
+ };
+}
diff --git a/apps/web/src/lib/selection/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts index ba086425..d8186238 100644 --- a/apps/web/src/lib/selection/hit-testing.ts +++ b/apps/web/src/lib/selection/hit-testing.ts @@ -1,50 +1,50 @@ -function isIntersecting({ - selectionRect, - itemRect, -}: { - selectionRect: DOMRect; - itemRect: DOMRect; -}) { - return !( - itemRect.right < selectionRect.left || - itemRect.left > selectionRect.right || - itemRect.bottom < selectionRect.top || - itemRect.top > selectionRect.bottom - ); -} - -function createSelectionRect({ - startPos, - currentPos, -}: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; -}) { - return new DOMRect( - Math.min(startPos.x, currentPos.x), - Math.min(startPos.y, currentPos.y), - Math.abs(currentPos.x - startPos.x), - Math.abs(currentPos.y - startPos.y), - ); -} - -export function resolveElementIntersections({ - startPos, - currentPos, - elements, -}: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - elements: Map<string, HTMLElement>; -}) { - const selectionRect = createSelectionRect({ startPos, currentPos }); - - return [...elements.entries()] - .filter(([, element]) => - isIntersecting({ - selectionRect, - itemRect: element.getBoundingClientRect(), - }), - ) - .map(([id]) => id); -} +function isIntersecting({
+ selectionRect,
+ itemRect,
+}: {
+ selectionRect: DOMRect;
+ itemRect: DOMRect;
+}) {
+ return !(
+ itemRect.right < selectionRect.left ||
+ itemRect.left > selectionRect.right ||
+ itemRect.bottom < selectionRect.top ||
+ itemRect.top > selectionRect.bottom
+ );
+}
+
+function createSelectionRect({
+ startPos,
+ currentPos,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}) {
+ return new DOMRect(
+ Math.min(startPos.x, currentPos.x),
+ Math.min(startPos.y, currentPos.y),
+ Math.abs(currentPos.x - startPos.x),
+ Math.abs(currentPos.y - startPos.y),
+ );
+}
+
+export function resolveElementIntersections({
+ startPos,
+ currentPos,
+ elements,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ elements: Map<string, HTMLElement>;
+}) {
+ const selectionRect = createSelectionRect({ startPos, currentPos });
+
+ return [...elements.entries()]
+ .filter(([, element]) =>
+ isIntersecting({
+ selectionRect,
+ itemRect: element.getBoundingClientRect(),
+ }),
+ )
+ .map(([id]) => id);
+}
diff --git a/apps/web/src/lib/selection/hooks/use-box-select.ts b/apps/web/src/lib/selection/hooks/use-box-select.ts index 5a9a5cd0..ab8464ec 100644 --- a/apps/web/src/lib/selection/hooks/use-box-select.ts +++ b/apps/web/src/lib/selection/hooks/use-box-select.ts @@ -1,170 +1,170 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { - BoxSelectionSnapshot, - ResolveIntersections, -} from "@/lib/selection/types"; - -interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; - isAdditive: boolean; -} - -export function useBoxSelect<TId>({ - containerRef, - resolveIntersections, - selectedIds, - anchorId, - onSelectionChange, - shouldStartSelection, - getIsAdditiveSelection, - isEnabled = true, -}: { - containerRef: React.RefObject<HTMLElement | null>; - resolveIntersections: ResolveIntersections<TId>; - selectedIds: TId[]; - anchorId: TId | null; - onSelectionChange: (state: { - intersectedIds: TId[]; - initialSelectedIds: TId[]; - initialAnchorId: TId | null; - isAdditive: boolean; - }) => void; - shouldStartSelection?: (event: React.MouseEvent<Element>) => boolean; - getIsAdditiveSelection?: (event: React.MouseEvent<Element>) => boolean; - isEnabled?: boolean; -}) { - const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>( - null, - ); - const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey); - - const handleMouseDown = useCallback( - (event: React.MouseEvent<Element>) => { - const canStartSelection = shouldStartSelectionCheck(event); - if (!isEnabled || event.button !== 0 || !canStartSelection) { - return; - } - - setSelectionBox({ - startPos: { x: event.clientX, y: event.clientY }, - currentPos: { x: event.clientX, y: event.clientY }, - isActive: false, - isAdditive: getIsAdditiveSelectionCheck(event), - initialSelectedIds: selectedIds, - initialAnchorId: anchorId, - }); - }, - [ - anchorId, - getIsAdditiveSelectionCheck, - isEnabled, - selectedIds, - shouldStartSelectionCheck, - ], - ); - - const updateSelection = useCallback( - ({ - startPos, - currentPos, - isAdditive, - initialSelectedIds, - initialAnchorId, - }: SelectionBoxState<TId>) => { - const intersectedIds = resolveIntersections({ - startPos, - currentPos, - }); - onSelectionChange({ - intersectedIds, - initialSelectedIds, - initialAnchorId, - isAdditive, - }); - }, - [onSelectionChange, resolveIntersections], - ); - - useEffect(() => { - if (!selectionBox) { - return; - } - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - const deltaX = Math.abs(clientX - selectionBox.startPos.x); - const deltaY = Math.abs(clientY - selectionBox.startPos.y); - const nextSelectionBox = { - ...selectionBox, - currentPos: { x: clientX, y: clientY }, - isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, - }; - - setSelectionBox(nextSelectionBox); - - if (!nextSelectionBox.isActive) { - return; - } - - updateSelection(nextSelectionBox); - }; - - const handleMouseUp = () => { - if (selectionBox.isActive) { - justFinishedSelectingRef.current = true; - requestAnimationFrame(() => { - justFinishedSelectingRef.current = false; - }); - } - - setSelectionBox(null); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [selectionBox, updateSelection]); - - useEffect(() => { - if (!selectionBox) { - return; - } - - const previousBodyUserSelect = document.body.style.userSelect; - const previousContainerUserSelect = containerRef.current?.style.userSelect ?? ""; - - document.body.style.userSelect = "none"; - if (containerRef.current) { - containerRef.current.style.userSelect = "none"; - } - - return () => { - document.body.style.userSelect = previousBodyUserSelect; - if (containerRef.current) { - containerRef.current.style.userSelect = previousContainerUserSelect; - } - }; - }, [containerRef, selectionBox]); - - const shouldIgnoreClick = useCallback(() => { - return justFinishedSelectingRef.current; - }, []); - - return { - selectionBox, - handleMouseDown, - isSelecting: selectionBox?.isActive ?? false, - shouldIgnoreClick, - }; -} +"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type {
+ BoxSelectionSnapshot,
+ ResolveIntersections,
+} from "@/lib/selection/types";
+
+interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+ isAdditive: boolean;
+}
+
+export function useBoxSelect<TId>({
+ containerRef,
+ resolveIntersections,
+ selectedIds,
+ anchorId,
+ onSelectionChange,
+ shouldStartSelection,
+ getIsAdditiveSelection,
+ isEnabled = true,
+}: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ resolveIntersections: ResolveIntersections<TId>;
+ selectedIds: TId[];
+ anchorId: TId | null;
+ onSelectionChange: (state: {
+ intersectedIds: TId[];
+ initialSelectedIds: TId[];
+ initialAnchorId: TId | null;
+ isAdditive: boolean;
+ }) => void;
+ shouldStartSelection?: (event: React.MouseEvent<Element>) => boolean;
+ getIsAdditiveSelection?: (event: React.MouseEvent<Element>) => boolean;
+ isEnabled?: boolean;
+}) {
+ const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(
+ null,
+ );
+ const justFinishedSelectingRef = useRef(false);
+ const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
+ const getIsAdditiveSelectionCheck =
+ getIsAdditiveSelection ??
+ ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
+
+ const handleMouseDown = useCallback(
+ (event: React.MouseEvent<Element>) => {
+ const canStartSelection = shouldStartSelectionCheck(event);
+ if (!isEnabled || event.button !== 0 || !canStartSelection) {
+ return;
+ }
+
+ setSelectionBox({
+ startPos: { x: event.clientX, y: event.clientY },
+ currentPos: { x: event.clientX, y: event.clientY },
+ isActive: false,
+ isAdditive: getIsAdditiveSelectionCheck(event),
+ initialSelectedIds: selectedIds,
+ initialAnchorId: anchorId,
+ });
+ },
+ [
+ anchorId,
+ getIsAdditiveSelectionCheck,
+ isEnabled,
+ selectedIds,
+ shouldStartSelectionCheck,
+ ],
+ );
+
+ const updateSelection = useCallback(
+ ({
+ startPos,
+ currentPos,
+ isAdditive,
+ initialSelectedIds,
+ initialAnchorId,
+ }: SelectionBoxState<TId>) => {
+ const intersectedIds = resolveIntersections({
+ startPos,
+ currentPos,
+ });
+ onSelectionChange({
+ intersectedIds,
+ initialSelectedIds,
+ initialAnchorId,
+ isAdditive,
+ });
+ },
+ [onSelectionChange, resolveIntersections],
+ );
+
+ useEffect(() => {
+ if (!selectionBox) {
+ return;
+ }
+
+ const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
+ const deltaX = Math.abs(clientX - selectionBox.startPos.x);
+ const deltaY = Math.abs(clientY - selectionBox.startPos.y);
+ const nextSelectionBox = {
+ ...selectionBox,
+ currentPos: { x: clientX, y: clientY },
+ isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
+ };
+
+ setSelectionBox(nextSelectionBox);
+
+ if (!nextSelectionBox.isActive) {
+ return;
+ }
+
+ updateSelection(nextSelectionBox);
+ };
+
+ const handleMouseUp = () => {
+ if (selectionBox.isActive) {
+ justFinishedSelectingRef.current = true;
+ requestAnimationFrame(() => {
+ justFinishedSelectingRef.current = false;
+ });
+ }
+
+ setSelectionBox(null);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [selectionBox, updateSelection]);
+
+ useEffect(() => {
+ if (!selectionBox) {
+ return;
+ }
+
+ const previousBodyUserSelect = document.body.style.userSelect;
+ const previousContainerUserSelect = containerRef.current?.style.userSelect ?? "";
+
+ document.body.style.userSelect = "none";
+ if (containerRef.current) {
+ containerRef.current.style.userSelect = "none";
+ }
+
+ return () => {
+ document.body.style.userSelect = previousBodyUserSelect;
+ if (containerRef.current) {
+ containerRef.current.style.userSelect = previousContainerUserSelect;
+ }
+ };
+ }, [containerRef, selectionBox]);
+
+ const shouldIgnoreClick = useCallback(() => {
+ return justFinishedSelectingRef.current;
+ }, []);
+
+ return {
+ selectionBox,
+ handleMouseDown,
+ isSelecting: selectionBox?.isActive ?? false,
+ shouldIgnoreClick,
+ };
+}
diff --git a/apps/web/src/lib/selection/hooks/use-selection-scope.ts b/apps/web/src/lib/selection/hooks/use-selection-scope.ts index 7ed63815..a7090c5d 100644 --- a/apps/web/src/lib/selection/hooks/use-selection-scope.ts +++ b/apps/web/src/lib/selection/hooks/use-selection-scope.ts @@ -1,28 +1,28 @@ -import { useEffect, useRef } from "react"; -import { useSelectionContext } from "@/lib/selection/context"; -import { activateScope, type ScopeEntry } from "@/lib/selection/scope"; - -export function useSelectionScope() { - const { selectedIds, clearSelection } = useSelectionContext(); - const hasSelectionRef = useRef(selectedIds.length > 0); - const clearSelectionRef = useRef(clearSelection); - const hasSelection = selectedIds.length > 0; - - hasSelectionRef.current = hasSelection; - clearSelectionRef.current = clearSelection; - - const entryRef = useRef<ScopeEntry>({ - hasSelection: () => hasSelectionRef.current, - clear: () => { - clearSelectionRef.current(); - }, - }); - - useEffect(() => { - if (!hasSelection) { - return; - } - - return activateScope({ entry: entryRef.current }); - }, [hasSelection]); -} +import { useEffect, useRef } from "react";
+import { useSelectionContext } from "@/lib/selection/context";
+import { activateScope, type ScopeEntry } from "@/lib/selection/scope";
+
+export function useSelectionScope() {
+ const { selectedIds, clearSelection } = useSelectionContext();
+ const hasSelectionRef = useRef(selectedIds.length > 0);
+ const clearSelectionRef = useRef(clearSelection);
+ const hasSelection = selectedIds.length > 0;
+
+ hasSelectionRef.current = hasSelection;
+ clearSelectionRef.current = clearSelection;
+
+ const entryRef = useRef<ScopeEntry>({
+ hasSelection: () => hasSelectionRef.current,
+ clear: () => {
+ clearSelectionRef.current();
+ },
+ });
+
+ useEffect(() => {
+ if (!hasSelection) {
+ return;
+ }
+
+ return activateScope({ entry: entryRef.current });
+ }, [hasSelection]);
+}
diff --git a/apps/web/src/lib/selection/index.ts b/apps/web/src/lib/selection/index.ts index b8699ecf..b1ded820 100644 --- a/apps/web/src/lib/selection/index.ts +++ b/apps/web/src/lib/selection/index.ts @@ -1,4 +1,4 @@ -export { SelectableItem } from "@/lib/selection/selectable-item"; -export { SelectableSurface } from "@/lib/selection/selectable-surface"; -export { useSelection } from "@/lib/selection/context"; -export { useSelectionScope } from "@/lib/selection/hooks/use-selection-scope"; +export { SelectableItem } from "@/lib/selection/selectable-item";
+export { SelectableSurface } from "@/lib/selection/selectable-surface";
+export { useSelection } from "@/lib/selection/context";
+export { useSelectionScope } from "@/lib/selection/hooks/use-selection-scope";
diff --git a/apps/web/src/lib/selection/scope.ts b/apps/web/src/lib/selection/scope.ts index 45bf6b56..35e86945 100644 --- a/apps/web/src/lib/selection/scope.ts +++ b/apps/web/src/lib/selection/scope.ts @@ -1,29 +1,29 @@ -export type ScopeEntry = { - hasSelection: () => boolean; - clear: () => void; -}; - -let activeScope: ScopeEntry | null = null; - -export function activateScope({ entry }: { entry: ScopeEntry }): () => void { - if (activeScope && activeScope !== entry) { - activeScope.clear(); - } - - activeScope = entry; - - return () => { - if (activeScope === entry) { - activeScope = null; - } - }; -} - -export function clearActiveScope(): boolean { - if (!activeScope?.hasSelection()) { - return false; - } - - activeScope.clear(); - return true; -} +export type ScopeEntry = {
+ hasSelection: () => boolean;
+ clear: () => void;
+};
+
+let activeScope: ScopeEntry | null = null;
+
+export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
+ if (activeScope && activeScope !== entry) {
+ activeScope.clear();
+ }
+
+ activeScope = entry;
+
+ return () => {
+ if (activeScope === entry) {
+ activeScope = null;
+ }
+ };
+}
+
+export function clearActiveScope(): boolean {
+ if (!activeScope?.hasSelection()) {
+ return false;
+ }
+
+ activeScope.clear();
+ return true;
+}
diff --git a/apps/web/src/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx index 1d7e74a8..52fd679b 100644 --- a/apps/web/src/lib/selection/selectable-item.tsx +++ b/apps/web/src/lib/selection/selectable-item.tsx @@ -1,107 +1,107 @@ -"use client"; - -import { forwardRef, useCallback } from "react"; -import { useSelectionContext } from "@/lib/selection/context"; -import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants"; -import type { SelectableItemProps } from "@/lib/selection/types"; -import { cn } from "@/utils/ui"; - -function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) { - if (typeof ref === "function") { - ref(value); - return; - } - - if (ref) { - ref.current = value; - } -} - -export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>( - function SelectableItem( - { - id, - children, - className, - onClick, - onKeyDown, - onMouseDown, - onContextMenu, - tabIndex, - ...rest - }: SelectableItemProps, - forwardedRef, - ) { - const { - highlightedId, - isBoxSelecting, - isSelected, - handleItemClick, - handleItemMouseDown, - registerItem, - } = useSelectionContext(); - const isItemSelected = isSelected(id); - const isItemHighlighted = highlightedId === id; - const stateClassName = cn( - "relative", - isBoxSelecting && "pointer-events-none", - isItemSelected && "ring-1 ring-primary rounded-sm bg-primary/10", - isItemHighlighted && - (isItemSelected - ? "rounded-sm shadow-[0_0_0_1px_hsl(var(--primary))]" - : "ring-1 ring-primary/60 rounded-sm bg-primary/5"), - ); - - const handleRef = useCallback( - (element: HTMLDivElement | null) => { - registerItem(id, element); - setForwardedRef(forwardedRef, element); - }, - [forwardedRef, id, registerItem], - ); - - return ( - <div - ref={handleRef} - className={cn(stateClassName, className)} - {...{ [SELECTABLE_ITEM_ATTRIBUTE]: "true" }} - role="option" - aria-selected={isItemSelected} - tabIndex={tabIndex ?? 0} - onClick={(event) => { - onClick?.(event); - if (event.defaultPrevented) { - return; - } - - handleItemClick({ event, id }); - }} - onMouseDown={(event) => { - onMouseDown?.(event); - if (event.defaultPrevented) { - return; - } - - handleItemMouseDown({ event, id }); - }} - onKeyDown={(event) => { - onKeyDown?.(event); - if (event.defaultPrevented || event.target !== event.currentTarget) { - return; - } - - if (event.key !== "Enter" && event.key !== " ") { - return; - } - - event.preventDefault(); - handleItemClick({ event, id }); - }} - onContextMenu={onContextMenu} - {...rest} - > - {children} - </div> - ); - }, -); +"use client";
+
+import { forwardRef, useCallback } from "react";
+import { useSelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import type { SelectableItemProps } from "@/lib/selection/types";
+import { cn } from "@/utils/ui";
+
+function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
+ if (typeof ref === "function") {
+ ref(value);
+ return;
+ }
+
+ if (ref) {
+ ref.current = value;
+ }
+}
+
+export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
+ function SelectableItem(
+ {
+ id,
+ children,
+ className,
+ onClick,
+ onKeyDown,
+ onMouseDown,
+ onContextMenu,
+ tabIndex,
+ ...rest
+ }: SelectableItemProps,
+ forwardedRef,
+ ) {
+ const {
+ highlightedId,
+ isBoxSelecting,
+ isSelected,
+ handleItemClick,
+ handleItemMouseDown,
+ registerItem,
+ } = useSelectionContext();
+ const isItemSelected = isSelected(id);
+ const isItemHighlighted = highlightedId === id;
+ const stateClassName = cn(
+ "relative",
+ isBoxSelecting && "pointer-events-none",
+ isItemSelected && "ring-1 ring-primary rounded-sm bg-primary/10",
+ isItemHighlighted &&
+ (isItemSelected
+ ? "rounded-sm shadow-[0_0_0_1px_hsl(var(--primary))]"
+ : "ring-1 ring-primary/60 rounded-sm bg-primary/5"),
+ );
+
+ const handleRef = useCallback(
+ (element: HTMLDivElement | null) => {
+ registerItem(id, element);
+ setForwardedRef(forwardedRef, element);
+ },
+ [forwardedRef, id, registerItem],
+ );
+
+ return (
+ <div
+ ref={handleRef}
+ className={cn(stateClassName, className)}
+ {...{ [SELECTABLE_ITEM_ATTRIBUTE]: "true" }}
+ role="option"
+ aria-selected={isItemSelected}
+ tabIndex={tabIndex ?? 0}
+ onClick={(event) => {
+ onClick?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ handleItemClick({ event, id });
+ }}
+ onMouseDown={(event) => {
+ onMouseDown?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ handleItemMouseDown({ event, id });
+ }}
+ onKeyDown={(event) => {
+ onKeyDown?.(event);
+ if (event.defaultPrevented || event.target !== event.currentTarget) {
+ return;
+ }
+
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+
+ event.preventDefault();
+ handleItemClick({ event, id });
+ }}
+ onContextMenu={onContextMenu}
+ {...rest}
+ >
+ {children}
+ </div>
+ );
+ },
+);
diff --git a/apps/web/src/lib/selection/selectable-surface.tsx b/apps/web/src/lib/selection/selectable-surface.tsx index 605b3d18..20c7adb3 100644 --- a/apps/web/src/lib/selection/selectable-surface.tsx +++ b/apps/web/src/lib/selection/selectable-surface.tsx @@ -1,288 +1,288 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SelectionBox } from "@/lib/selection/selection-box"; -import { SelectionContext } from "@/lib/selection/context"; -import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants"; -import { resolveElementIntersections } from "@/lib/selection/hit-testing"; -import { - applyBoxSelection, - clearSelection, - isSelected as getIsSelected, - pruneSelection, - replaceSelection, - selectRange, - toggleSelection, -} from "@/lib/selection/state"; -import type { - SelectableSurfaceProps, - SelectionState, -} from "@/lib/selection/types"; -import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; -import { cn } from "@/utils/ui"; - -export function SelectableSurface({ - orderedIds, - children, - className, - ariaLabel = "Selectable items", - revealId = null, - onRevealComplete, - onSelectionChange, -}: SelectableSurfaceProps) { - const [selectionState, setSelectionState] = useState<SelectionState>(() => - clearSelection(), - ); - const [highlightedId, setHighlightedId] = useState<string | null>(null); - const containerRef = useRef<HTMLDivElement>(null); - const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map()); - - const registerItem = useCallback( - (id: string, element: HTMLElement | null) => { - if (element) { - itemElementsRef.current.set(id, element); - } else { - itemElementsRef.current.delete(id); - } - }, - [], - ); - - const getSelectableElements = useCallback(() => { - return itemElementsRef.current; - }, []); - - const getItemElement = useCallback((id: string) => { - return itemElementsRef.current.get(id) ?? null; - }, []); - const shouldStartSelection = useCallback( - (event: React.MouseEvent<Element>) => { - return !( - event.target instanceof Element && - event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`) - ); - }, - [], - ); - const resolveIntersections = useCallback( - ({ - startPos, - currentPos, - }: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - }) => { - return resolveElementIntersections({ - startPos, - currentPos, - elements: getSelectableElements(), - }); - }, - [getSelectableElements], - ); - - const clearSelectionState = useCallback(() => { - setSelectionState(clearSelection()); - }, []); - - const selectedIdSet = useMemo( - () => new Set(selectionState.selectedIds), - [selectionState.selectedIds], - ); - - const isSelected = useCallback( - (id: string) => selectedIdSet.has(id), - [selectedIdSet], - ); - - const handleItemClick = useCallback( - ({ - event, - id, - }: { - event: - | React.MouseEvent<HTMLDivElement> - | React.KeyboardEvent<HTMLDivElement>; - id: string; - }) => { - setSelectionState((state) => { - const isToggleSelection = event.ctrlKey || event.metaKey; - - if (event.shiftKey) { - return selectRange({ - state, - orderedIds, - targetId: id, - isAdditive: isToggleSelection, - }); - } - - if (isToggleSelection) { - return toggleSelection({ - state, - id, - }); - } - - return replaceSelection({ - ids: [id], - anchorId: id, - }); - }); - }, - [orderedIds], - ); - - const selectUnselectedItem = useCallback((id: string) => { - setSelectionState((state) => { - if (getIsSelected({ state, id })) { - return state; - } - - return replaceSelection({ ids: [id], anchorId: id }); - }); - }, []); - - const handleItemMouseDown = useCallback( - ({ - event, - id, - }: { - event: React.MouseEvent<HTMLDivElement>; - id: string; - }) => { - if (event.button !== 2) { - return; - } - - selectUnselectedItem(id); - }, - [selectUnselectedItem], - ); - - const handleBoxSelectionChange = useCallback( - (change: Parameters<typeof applyBoxSelection>[0]) => { - setSelectionState(applyBoxSelection(change)); - }, - [], - ); - - const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({ - containerRef, - resolveIntersections, - selectedIds: selectionState.selectedIds, - anchorId: selectionState.anchorId, - onSelectionChange: handleBoxSelectionChange, - shouldStartSelection, - }); - - const handleBackgroundClick = useCallback( - (event: React.MouseEvent<HTMLDivElement>) => { - const clickedSelectableItem = - event.target instanceof Element && - event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`); - const isIgnoringClick = shouldIgnoreClick(); - if (clickedSelectableItem || isIgnoringClick) { - return; - } - - clearSelectionState(); - }, - [clearSelectionState, shouldIgnoreClick], - ); - - const handleBackgroundKeyDown = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.target !== event.currentTarget) { - return; - } - - if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") { - return; - } - - event.preventDefault(); - clearSelectionState(); - }, - [clearSelectionState], - ); - - useEffect(() => { - setSelectionState((state) => - pruneSelection({ - state, - orderedIds, - }), - ); - }, [orderedIds]); - - useEffect(() => { - onSelectionChange?.(selectionState); - }, [onSelectionChange, selectionState]); - - useEffect(() => { - if (!revealId) { - return; - } - - setHighlightedId(revealId); - getItemElement(revealId)?.scrollIntoView({ block: "center" }); - - const timer = setTimeout(() => { - setHighlightedId(null); - onRevealComplete?.(); - }, 1500); - - return () => clearTimeout(timer); - }, [getItemElement, onRevealComplete, revealId]); - - const isBoxSelecting = selectionBox?.isActive ?? false; - - const contextValue = useMemo(() => { - return { - selectedIds: selectionState.selectedIds, - anchorId: selectionState.anchorId, - highlightedId, - isBoxSelecting, - isSelected, - clearSelection: clearSelectionState, - handleItemClick, - handleItemMouseDown, - registerItem, - }; - }, [ - clearSelectionState, - handleItemClick, - handleItemMouseDown, - highlightedId, - isBoxSelecting, - isSelected, - registerItem, - selectionState.anchorId, - selectionState.selectedIds, - ]); - - return ( - <SelectionContext.Provider value={contextValue}> - <div - ref={containerRef} - className={cn("relative min-h-full", className)} - role="listbox" - aria-label={ariaLabel} - aria-multiselectable="true" - tabIndex={0} - onMouseDown={handleMouseDown} - onClick={handleBackgroundClick} - onKeyDown={handleBackgroundKeyDown} - > - {children} - <SelectionBox - startPos={selectionBox?.startPos ?? null} - currentPos={selectionBox?.currentPos ?? null} - containerRef={containerRef} - isActive={selectionBox?.isActive ?? false} - /> - </div> - </SelectionContext.Provider> - ); -} +"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { SelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import { resolveElementIntersections } from "@/lib/selection/hit-testing";
+import {
+ applyBoxSelection,
+ clearSelection,
+ isSelected as getIsSelected,
+ pruneSelection,
+ replaceSelection,
+ selectRange,
+ toggleSelection,
+} from "@/lib/selection/state";
+import type {
+ SelectableSurfaceProps,
+ SelectionState,
+} from "@/lib/selection/types";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
+import { cn } from "@/utils/ui";
+
+export function SelectableSurface({
+ orderedIds,
+ children,
+ className,
+ ariaLabel = "Selectable items",
+ revealId = null,
+ onRevealComplete,
+ onSelectionChange,
+}: SelectableSurfaceProps) {
+ const [selectionState, setSelectionState] = useState<SelectionState>(() =>
+ clearSelection(),
+ );
+ const [highlightedId, setHighlightedId] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map());
+
+ const registerItem = useCallback(
+ (id: string, element: HTMLElement | null) => {
+ if (element) {
+ itemElementsRef.current.set(id, element);
+ } else {
+ itemElementsRef.current.delete(id);
+ }
+ },
+ [],
+ );
+
+ const getSelectableElements = useCallback(() => {
+ return itemElementsRef.current;
+ }, []);
+
+ const getItemElement = useCallback((id: string) => {
+ return itemElementsRef.current.get(id) ?? null;
+ }, []);
+ const shouldStartSelection = useCallback(
+ (event: React.MouseEvent<Element>) => {
+ return !(
+ event.target instanceof Element &&
+ event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`)
+ );
+ },
+ [],
+ );
+ const resolveIntersections = useCallback(
+ ({
+ startPos,
+ currentPos,
+ }: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ }) => {
+ return resolveElementIntersections({
+ startPos,
+ currentPos,
+ elements: getSelectableElements(),
+ });
+ },
+ [getSelectableElements],
+ );
+
+ const clearSelectionState = useCallback(() => {
+ setSelectionState(clearSelection());
+ }, []);
+
+ const selectedIdSet = useMemo(
+ () => new Set(selectionState.selectedIds),
+ [selectionState.selectedIds],
+ );
+
+ const isSelected = useCallback(
+ (id: string) => selectedIdSet.has(id),
+ [selectedIdSet],
+ );
+
+ const handleItemClick = useCallback(
+ ({
+ event,
+ id,
+ }: {
+ event:
+ | React.MouseEvent<HTMLDivElement>
+ | React.KeyboardEvent<HTMLDivElement>;
+ id: string;
+ }) => {
+ setSelectionState((state) => {
+ const isToggleSelection = event.ctrlKey || event.metaKey;
+
+ if (event.shiftKey) {
+ return selectRange({
+ state,
+ orderedIds,
+ targetId: id,
+ isAdditive: isToggleSelection,
+ });
+ }
+
+ if (isToggleSelection) {
+ return toggleSelection({
+ state,
+ id,
+ });
+ }
+
+ return replaceSelection({
+ ids: [id],
+ anchorId: id,
+ });
+ });
+ },
+ [orderedIds],
+ );
+
+ const selectUnselectedItem = useCallback((id: string) => {
+ setSelectionState((state) => {
+ if (getIsSelected({ state, id })) {
+ return state;
+ }
+
+ return replaceSelection({ ids: [id], anchorId: id });
+ });
+ }, []);
+
+ const handleItemMouseDown = useCallback(
+ ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement>;
+ id: string;
+ }) => {
+ if (event.button !== 2) {
+ return;
+ }
+
+ selectUnselectedItem(id);
+ },
+ [selectUnselectedItem],
+ );
+
+ const handleBoxSelectionChange = useCallback(
+ (change: Parameters<typeof applyBoxSelection>[0]) => {
+ setSelectionState(applyBoxSelection(change));
+ },
+ [],
+ );
+
+ const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
+
+ const handleBackgroundClick = useCallback(
+ (event: React.MouseEvent<HTMLDivElement>) => {
+ const clickedSelectableItem =
+ event.target instanceof Element &&
+ event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`);
+ const isIgnoringClick = shouldIgnoreClick();
+ if (clickedSelectableItem || isIgnoringClick) {
+ return;
+ }
+
+ clearSelectionState();
+ },
+ [clearSelectionState, shouldIgnoreClick],
+ );
+
+ const handleBackgroundKeyDown = useCallback(
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") {
+ return;
+ }
+
+ event.preventDefault();
+ clearSelectionState();
+ },
+ [clearSelectionState],
+ );
+
+ useEffect(() => {
+ setSelectionState((state) =>
+ pruneSelection({
+ state,
+ orderedIds,
+ }),
+ );
+ }, [orderedIds]);
+
+ useEffect(() => {
+ onSelectionChange?.(selectionState);
+ }, [onSelectionChange, selectionState]);
+
+ useEffect(() => {
+ if (!revealId) {
+ return;
+ }
+
+ setHighlightedId(revealId);
+ getItemElement(revealId)?.scrollIntoView({ block: "center" });
+
+ const timer = setTimeout(() => {
+ setHighlightedId(null);
+ onRevealComplete?.();
+ }, 1500);
+
+ return () => clearTimeout(timer);
+ }, [getItemElement, onRevealComplete, revealId]);
+
+ const isBoxSelecting = selectionBox?.isActive ?? false;
+
+ const contextValue = useMemo(() => {
+ return {
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ highlightedId,
+ isBoxSelecting,
+ isSelected,
+ clearSelection: clearSelectionState,
+ handleItemClick,
+ handleItemMouseDown,
+ registerItem,
+ };
+ }, [
+ clearSelectionState,
+ handleItemClick,
+ handleItemMouseDown,
+ highlightedId,
+ isBoxSelecting,
+ isSelected,
+ registerItem,
+ selectionState.anchorId,
+ selectionState.selectedIds,
+ ]);
+
+ return (
+ <SelectionContext.Provider value={contextValue}>
+ <div
+ ref={containerRef}
+ className={cn("relative min-h-full", className)}
+ role="listbox"
+ aria-label={ariaLabel}
+ aria-multiselectable="true"
+ tabIndex={0}
+ onMouseDown={handleMouseDown}
+ onClick={handleBackgroundClick}
+ onKeyDown={handleBackgroundKeyDown}
+ >
+ {children}
+ <SelectionBox
+ startPos={selectionBox?.startPos ?? null}
+ currentPos={selectionBox?.currentPos ?? null}
+ containerRef={containerRef}
+ isActive={selectionBox?.isActive ?? false}
+ />
+ </div>
+ </SelectionContext.Provider>
+ );
+}
diff --git a/apps/web/src/lib/selection/selection-box.tsx b/apps/web/src/lib/selection/selection-box.tsx index 0b757e36..e30f574d 100644 --- a/apps/web/src/lib/selection/selection-box.tsx +++ b/apps/web/src/lib/selection/selection-box.tsx @@ -1,50 +1,50 @@ -"use client"; - -import { useMemo } from "react"; - -interface SelectionBoxProps { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - containerRef: React.RefObject<HTMLElement | null>; - isActive: boolean; -} - -export function SelectionBox({ - startPos, - currentPos, - containerRef, - isActive, -}: SelectionBoxProps) { - const selectionBoxStyle = useMemo(() => { - if (!isActive || !startPos || !currentPos || !containerRef.current) { - return null; - } - - const containerRect = containerRef.current.getBoundingClientRect(); - const startX = startPos.x - containerRect.left; - const startY = startPos.y - containerRect.top; - const currentX = currentPos.x - containerRect.left; - const currentY = currentPos.y - containerRect.top; - - const left = Math.min(startX, currentX); - const top = Math.min(startY, currentY); - const width = Math.abs(currentX - startX); - const height = Math.abs(currentY - startY); - - return { - left: `${left}px`, - top: `${top}px`, - width: `${width}px`, - height: `${height}px`, - }; - }, [containerRef, currentPos, isActive, startPos]); - - if (!selectionBoxStyle) return null; - - return ( - <div - style={selectionBoxStyle} - className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border" - /> - ); -} +"use client";
+
+import { useMemo } from "react";
+
+interface SelectionBoxProps {
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject<HTMLElement | null>;
+ isActive: boolean;
+}
+
+export function SelectionBox({
+ startPos,
+ currentPos,
+ containerRef,
+ isActive,
+}: SelectionBoxProps) {
+ const selectionBoxStyle = useMemo(() => {
+ if (!isActive || !startPos || !currentPos || !containerRef.current) {
+ return null;
+ }
+
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ const left = Math.min(startX, currentX);
+ const top = Math.min(startY, currentY);
+ const width = Math.abs(currentX - startX);
+ const height = Math.abs(currentY - startY);
+
+ return {
+ left: `${left}px`,
+ top: `${top}px`,
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+ }, [containerRef, currentPos, isActive, startPos]);
+
+ if (!selectionBoxStyle) return null;
+
+ return (
+ <div
+ style={selectionBoxStyle}
+ className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
+ />
+ );
+}
diff --git a/apps/web/src/lib/selection/state.ts b/apps/web/src/lib/selection/state.ts index b4c454fb..b0ba3089 100644 --- a/apps/web/src/lib/selection/state.ts +++ b/apps/web/src/lib/selection/state.ts @@ -1,165 +1,165 @@ -import type { - BoxSelectionChange, - SelectionState, -} from "@/lib/selection/types"; - -function dedupeIds({ ids }: { ids: string[] }) { - return [...new Set(ids)]; -} - -function getRangeIds({ - orderedIds, - anchorId, - targetId, -}: { - orderedIds: string[]; - anchorId: string; - targetId: string; -}) { - const anchorIndex = orderedIds.indexOf(anchorId); - const targetIndex = orderedIds.indexOf(targetId); - - if (anchorIndex === -1 || targetIndex === -1) { - return [targetId]; - } - - const rangeStart = Math.min(anchorIndex, targetIndex); - const rangeEnd = Math.max(anchorIndex, targetIndex); - return orderedIds.slice(rangeStart, rangeEnd + 1); -} - -export function replaceSelection({ - ids, - anchorId, -}: { - ids: string[]; - anchorId?: string | null; -}): SelectionState { - const selectedIds = dedupeIds({ ids }); - return { - selectedIds, - anchorId: anchorId ?? selectedIds[selectedIds.length - 1] ?? null, - }; -} - -export function clearSelection(): SelectionState { - return { - selectedIds: [], - anchorId: null, - }; -} - -export function pruneSelection({ - state, - orderedIds, -}: { - state: SelectionState; - orderedIds: string[]; -}): SelectionState { - const validIds = new Set(orderedIds); - const selectedIds = state.selectedIds.filter((id) => validIds.has(id)); - const anchorId = - state.anchorId && validIds.has(state.anchorId) - ? state.anchorId - : selectedIds[selectedIds.length - 1] ?? null; - const isUnchanged = - selectedIds.length === state.selectedIds.length && - anchorId === state.anchorId; - - if (isUnchanged) { - return state; - } - - return { - selectedIds, - anchorId, - }; -} - -export function isSelected({ - state, - id, -}: { - state: SelectionState; - id: string; -}) { - return state.selectedIds.includes(id); -} - -export function toggleSelection({ - state, - id, -}: { - state: SelectionState; - id: string; -}): SelectionState { - if (isSelected({ state, id })) { - const selectedIds = state.selectedIds.filter((selectedId) => selectedId !== id); - const anchorId = - state.anchorId === id - ? selectedIds[selectedIds.length - 1] ?? null - : state.anchorId; - return replaceSelection({ - ids: selectedIds, - anchorId, - }); - } - - return replaceSelection({ - ids: [...state.selectedIds, id], - anchorId: id, - }); -} - -export function selectRange({ - state, - orderedIds, - targetId, - isAdditive, -}: { - state: SelectionState; - orderedIds: string[]; - targetId: string; - isAdditive: boolean; -}): SelectionState { - const anchorId = - state.anchorId ?? - state.selectedIds[state.selectedIds.length - 1] ?? - targetId; - const rangeIds = getRangeIds({ - orderedIds, - anchorId, - targetId, - }); - const selectedIds = isAdditive - ? dedupeIds({ - ids: [...state.selectedIds, ...rangeIds], - }) - : rangeIds; - - return replaceSelection({ - ids: selectedIds, - anchorId, - }); -} - -export function applyBoxSelection({ - intersectedIds, - initialSelectedIds, - initialAnchorId, - isAdditive, -}: BoxSelectionChange): SelectionState { - const selectedIds = isAdditive - ? dedupeIds({ - ids: [...initialSelectedIds, ...intersectedIds], - }) - : intersectedIds; - const anchorId = isAdditive - ? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null - : intersectedIds[intersectedIds.length - 1] ?? null; - - return replaceSelection({ - ids: selectedIds, - anchorId, - }); -} +import type {
+ BoxSelectionChange,
+ SelectionState,
+} from "@/lib/selection/types";
+
+function dedupeIds({ ids }: { ids: string[] }) {
+ return [...new Set(ids)];
+}
+
+function getRangeIds({
+ orderedIds,
+ anchorId,
+ targetId,
+}: {
+ orderedIds: string[];
+ anchorId: string;
+ targetId: string;
+}) {
+ const anchorIndex = orderedIds.indexOf(anchorId);
+ const targetIndex = orderedIds.indexOf(targetId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return [targetId];
+ }
+
+ const rangeStart = Math.min(anchorIndex, targetIndex);
+ const rangeEnd = Math.max(anchorIndex, targetIndex);
+ return orderedIds.slice(rangeStart, rangeEnd + 1);
+}
+
+export function replaceSelection({
+ ids,
+ anchorId,
+}: {
+ ids: string[];
+ anchorId?: string | null;
+}): SelectionState {
+ const selectedIds = dedupeIds({ ids });
+ return {
+ selectedIds,
+ anchorId: anchorId ?? selectedIds[selectedIds.length - 1] ?? null,
+ };
+}
+
+export function clearSelection(): SelectionState {
+ return {
+ selectedIds: [],
+ anchorId: null,
+ };
+}
+
+export function pruneSelection({
+ state,
+ orderedIds,
+}: {
+ state: SelectionState;
+ orderedIds: string[];
+}): SelectionState {
+ const validIds = new Set(orderedIds);
+ const selectedIds = state.selectedIds.filter((id) => validIds.has(id));
+ const anchorId =
+ state.anchorId && validIds.has(state.anchorId)
+ ? state.anchorId
+ : selectedIds[selectedIds.length - 1] ?? null;
+ const isUnchanged =
+ selectedIds.length === state.selectedIds.length &&
+ anchorId === state.anchorId;
+
+ if (isUnchanged) {
+ return state;
+ }
+
+ return {
+ selectedIds,
+ anchorId,
+ };
+}
+
+export function isSelected({
+ state,
+ id,
+}: {
+ state: SelectionState;
+ id: string;
+}) {
+ return state.selectedIds.includes(id);
+}
+
+export function toggleSelection({
+ state,
+ id,
+}: {
+ state: SelectionState;
+ id: string;
+}): SelectionState {
+ if (isSelected({ state, id })) {
+ const selectedIds = state.selectedIds.filter((selectedId) => selectedId !== id);
+ const anchorId =
+ state.anchorId === id
+ ? selectedIds[selectedIds.length - 1] ?? null
+ : state.anchorId;
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+ }
+
+ return replaceSelection({
+ ids: [...state.selectedIds, id],
+ anchorId: id,
+ });
+}
+
+export function selectRange({
+ state,
+ orderedIds,
+ targetId,
+ isAdditive,
+}: {
+ state: SelectionState;
+ orderedIds: string[];
+ targetId: string;
+ isAdditive: boolean;
+}): SelectionState {
+ const anchorId =
+ state.anchorId ??
+ state.selectedIds[state.selectedIds.length - 1] ??
+ targetId;
+ const rangeIds = getRangeIds({
+ orderedIds,
+ anchorId,
+ targetId,
+ });
+ const selectedIds = isAdditive
+ ? dedupeIds({
+ ids: [...state.selectedIds, ...rangeIds],
+ })
+ : rangeIds;
+
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+}
+
+export function applyBoxSelection({
+ intersectedIds,
+ initialSelectedIds,
+ initialAnchorId,
+ isAdditive,
+}: BoxSelectionChange): SelectionState {
+ const selectedIds = isAdditive
+ ? dedupeIds({
+ ids: [...initialSelectedIds, ...intersectedIds],
+ })
+ : intersectedIds;
+ const anchorId = isAdditive
+ ? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null
+ : intersectedIds[intersectedIds.length - 1] ?? null;
+
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+}
diff --git a/apps/web/src/lib/selection/types.ts b/apps/web/src/lib/selection/types.ts index 94fd7abe..31520788 100644 --- a/apps/web/src/lib/selection/types.ts +++ b/apps/web/src/lib/selection/types.ts @@ -1,39 +1,39 @@ -export interface SelectionState { - selectedIds: string[]; - anchorId: string | null; -} - -export interface BoxSelectionSnapshot<TId = string> { - initialSelectedIds: TId[]; - initialAnchorId: TId | null; -} - -export interface BoxSelectionChange<TId = string> - extends BoxSelectionSnapshot<TId> { - intersectedIds: TId[]; - isAdditive: boolean; -} - -export type ResolveIntersections<TId = string> = ({ - startPos, - currentPos, -}: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; -}) => TId[]; - -export interface SelectableSurfaceProps { - orderedIds: string[]; - children: React.ReactNode; - className?: string; - ariaLabel?: string; - revealId?: string | null; - onRevealComplete?: () => void; - onSelectionChange?: (state: SelectionState) => void; -} - -export interface SelectableItemProps - extends React.HTMLAttributes<HTMLDivElement> { - id: string; - children: React.ReactNode; -} +export interface SelectionState {
+ selectedIds: string[];
+ anchorId: string | null;
+}
+
+export interface BoxSelectionSnapshot<TId = string> {
+ initialSelectedIds: TId[];
+ initialAnchorId: TId | null;
+}
+
+export interface BoxSelectionChange<TId = string>
+ extends BoxSelectionSnapshot<TId> {
+ intersectedIds: TId[];
+ isAdditive: boolean;
+}
+
+export type ResolveIntersections<TId = string> = ({
+ startPos,
+ currentPos,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}) => TId[];
+
+export interface SelectableSurfaceProps {
+ orderedIds: string[];
+ children: React.ReactNode;
+ className?: string;
+ ariaLabel?: string;
+ revealId?: string | null;
+ onRevealComplete?: () => void;
+ onSelectionChange?: (state: SelectionState) => void;
+}
+
+export interface SelectableItemProps
+ extends React.HTMLAttributes<HTMLDivElement> {
+ id: string;
+ children: React.ReactNode;
+}
diff --git a/apps/web/src/lib/shaders/jfa-init.frag.glsl b/apps/web/src/lib/shaders/jfa-init.frag.glsl index d199d234..e465752c 100644 --- a/apps/web/src/lib/shaders/jfa-init.frag.glsl +++ b/apps/web/src/lib/shaders/jfa-init.frag.glsl @@ -1,25 +1,25 @@ -precision mediump float; - -uniform sampler2D u_texture; -uniform vec2 u_resolution; -uniform float u_invert; - -varying vec2 v_texCoord; - -void main() { - float mask = texture2D(u_texture, v_texCoord).r; - bool isSeed = u_invert > 0.5 ? mask < 0.5 : mask > 0.5; - - if (isSeed) { - vec2 pixelCoord = floor(v_texCoord * u_resolution); - float x = pixelCoord.x; - float y = pixelCoord.y; - float xHi = floor(x / 256.0); - float xLo = x - xHi * 256.0; - float yHi = floor(y / 256.0); - float yLo = y - yHi * 256.0; - gl_FragColor = vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0); - } else { - gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); - } -} +precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_invert;
+
+varying vec2 v_texCoord;
+
+void main() {
+ float mask = texture2D(u_texture, v_texCoord).r;
+ bool isSeed = u_invert > 0.5 ? mask < 0.5 : mask > 0.5;
+
+ if (isSeed) {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+ float x = pixelCoord.x;
+ float y = pixelCoord.y;
+ float xHi = floor(x / 256.0);
+ float xLo = x - xHi * 256.0;
+ float yHi = floor(y / 256.0);
+ float yLo = y - yHi * 256.0;
+ gl_FragColor = vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
+ } else {
+ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
+ }
+}
diff --git a/apps/web/src/lib/shaders/jfa-step.frag.glsl b/apps/web/src/lib/shaders/jfa-step.frag.glsl index 5a95f497..9b76a70b 100644 --- a/apps/web/src/lib/shaders/jfa-step.frag.glsl +++ b/apps/web/src/lib/shaders/jfa-step.frag.glsl @@ -1,60 +1,60 @@ -precision mediump float; - -uniform sampler2D u_texture; -uniform vec2 u_resolution; -uniform float u_step_size; - -varying vec2 v_texCoord; - -vec2 decodeSeed(vec4 encoded) { - float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5); - float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5); - return vec2(x, y); -} - -vec4 encodeSeed(vec2 seed) { - float xHi = floor(seed.x / 256.0); - float xLo = seed.x - xHi * 256.0; - float yHi = floor(seed.y / 256.0); - float yLo = seed.y - yHi * 256.0; - return vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0); -} - -bool isNoSeed(vec4 encoded) { - return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99; -} - -void main() { - vec2 pixelCoord = floor(v_texCoord * u_resolution); - vec2 texelSize = 1.0 / u_resolution; - - float bestDist = 1e10; - vec2 bestSeed = vec2(65535.0); - - for (int dy = -1; dy <= 1; dy++) { - for (int dx = -1; dx <= 1; dx++) { - vec2 offset = vec2(float(dx), float(dy)) * u_step_size; - vec2 sampleUV = v_texCoord + offset * texelSize; - - if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) - continue; - - vec4 encoded = texture2D(u_texture, sampleUV); - if (isNoSeed(encoded)) - continue; - - vec2 seed = decodeSeed(encoded); - float dist = distance(pixelCoord, seed); - if (dist < bestDist) { - bestDist = dist; - bestSeed = seed; - } - } - } - - if (bestDist < 1e9) { - gl_FragColor = encodeSeed(bestSeed); - } else { - gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); - } -} +precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_step_size;
+
+varying vec2 v_texCoord;
+
+vec2 decodeSeed(vec4 encoded) {
+ float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
+ float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
+ return vec2(x, y);
+}
+
+vec4 encodeSeed(vec2 seed) {
+ float xHi = floor(seed.x / 256.0);
+ float xLo = seed.x - xHi * 256.0;
+ float yHi = floor(seed.y / 256.0);
+ float yLo = seed.y - yHi * 256.0;
+ return vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
+}
+
+bool isNoSeed(vec4 encoded) {
+ return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
+}
+
+void main() {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+ vec2 texelSize = 1.0 / u_resolution;
+
+ float bestDist = 1e10;
+ vec2 bestSeed = vec2(65535.0);
+
+ for (int dy = -1; dy <= 1; dy++) {
+ for (int dx = -1; dx <= 1; dx++) {
+ vec2 offset = vec2(float(dx), float(dy)) * u_step_size;
+ vec2 sampleUV = v_texCoord + offset * texelSize;
+
+ if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0)
+ continue;
+
+ vec4 encoded = texture2D(u_texture, sampleUV);
+ if (isNoSeed(encoded))
+ continue;
+
+ vec2 seed = decodeSeed(encoded);
+ float dist = distance(pixelCoord, seed);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestSeed = seed;
+ }
+ }
+ }
+
+ if (bestDist < 1e9) {
+ gl_FragColor = encodeSeed(bestSeed);
+ } else {
+ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
+ }
+}
diff --git a/apps/web/src/lib/sounds/types.ts b/apps/web/src/lib/sounds/types.ts index eecf7701..f7526846 100644 --- a/apps/web/src/lib/sounds/types.ts +++ b/apps/web/src/lib/sounds/types.ts @@ -1,39 +1,39 @@ -export interface SoundEffect { - id: number; - name: string; - description: string; - url: string; - previewUrl?: string; - downloadUrl?: string; - duration: number; - filesize: number; - type: string; - channels: number; - bitrate: number; - bitdepth: number; - samplerate: number; - username: string; - tags: string[]; - license: string; - created: string; - downloads: number; - rating: number; - ratingCount: number; -} - -export interface SavedSound { - id: number; // freesound id - name: string; - username: string; - previewUrl?: string; - downloadUrl?: string; - duration: number; - tags: string[]; - license: string; - savedAt: string; // iso date string -} - -export interface SavedSoundsData { - sounds: SavedSound[]; - lastModified: string; -} +export interface SoundEffect {
+ id: number;
+ name: string;
+ description: string;
+ url: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ filesize: number;
+ type: string;
+ channels: number;
+ bitrate: number;
+ bitdepth: number;
+ samplerate: number;
+ username: string;
+ tags: string[];
+ license: string;
+ created: string;
+ downloads: number;
+ rating: number;
+ ratingCount: number;
+}
+
+export interface SavedSound {
+ id: number; // freesound id
+ name: string;
+ username: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ tags: string[];
+ license: string;
+ savedAt: string; // iso date string
+}
+
+export interface SavedSoundsData {
+ sounds: SavedSound[];
+ lastModified: string;
+}
diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts index 3236e213..26458a7c 100644 --- a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts +++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts @@ -1,34 +1,34 @@ -import { describe, expect, test } from "bun:test"; -import { buildStickerId, parseStickerId } from "../sticker-id"; - -describe("sticker-id strict mode", () => { - test("parses provider-prefixed IDs", () => { - expect(parseStickerId({ stickerId: "flags:US" })).toEqual({ - providerId: "flags", - providerValue: "US", - }); - expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({ - providerId: "shapes", - providerValue: "circle", - }); - }); - - test("throws for IDs without provider prefix", () => { - expect(() => parseStickerId({ stickerId: "home" })).toThrow(); - }); - - test("throws for malformed IDs", () => { - expect(() => parseStickerId({ stickerId: "" })).toThrow(); - expect(() => parseStickerId({ stickerId: "flags:" })).toThrow(); - expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow(); - }); - - test("builds sticker IDs unchanged", () => { - expect( - buildStickerId({ - providerId: "flags", - providerValue: "US", - }), - ).toBe("flags:US"); - }); -}); +import { describe, expect, test } from "bun:test";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+
+describe("sticker-id strict mode", () => {
+ test("parses provider-prefixed IDs", () => {
+ expect(parseStickerId({ stickerId: "flags:US" })).toEqual({
+ providerId: "flags",
+ providerValue: "US",
+ });
+ expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({
+ providerId: "shapes",
+ providerValue: "circle",
+ });
+ });
+
+ test("throws for IDs without provider prefix", () => {
+ expect(() => parseStickerId({ stickerId: "home" })).toThrow();
+ });
+
+ test("throws for malformed IDs", () => {
+ expect(() => parseStickerId({ stickerId: "" })).toThrow();
+ expect(() => parseStickerId({ stickerId: "flags:" })).toThrow();
+ expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow();
+ });
+
+ test("builds sticker IDs unchanged", () => {
+ expect(
+ buildStickerId({
+ providerId: "flags",
+ providerValue: "US",
+ }),
+ ).toBe("flags:US");
+ });
+});
diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts index 47be18fe..d39fe0f5 100644 --- a/apps/web/src/lib/stickers/index.ts +++ b/apps/web/src/lib/stickers/index.ts @@ -1,317 +1,317 @@ -import { - STICKER_CATEGORIES, - STICKER_INTRINSIC_SIZE_FALLBACK, -} from "@/constants/sticker-constants"; -import type { StickerCategory } from "@/lib/stickers/types"; -import { stickersRegistry } from "./registry"; -import { resolveStickerId } from "./resolver"; -import { registerDefaultStickerProviders } from "./providers"; -import { parseStickerId } from "./sticker-id"; -import type { - StickerBrowseResult, - StickerItem, - StickerProvider, - StickerSearchResult, -} from "./types"; - -const DEFAULT_BROWSE_LIMIT = 12; -const DEFAULT_SEARCH_LIMIT = 100; - -function mergeSearchResults({ - results, -}: { - results: StickerSearchResult[]; -}): StickerSearchResult { - const deduplicatedItems = new Map< - string, - StickerSearchResult["items"][number] - >(); - let total = 0; - let hasMore = false; - - for (const result of results) { - total += result.total; - hasMore = hasMore || result.hasMore; - for (const item of result.items) { - if (!deduplicatedItems.has(item.id)) { - deduplicatedItems.set(item.id, item); - } - } - } - - return { - items: Array.from(deduplicatedItems.values()), - total, - hasMore, - }; -} - -function getProviderByCategory({ - category, -}: { - category: StickerCategory; -}): StickerProvider | null { - if (category === "all") { - return null; - } - - try { - return stickersRegistry.get(category); - } catch { - return null; - } -} - -function getEmptyBrowseResult(): StickerBrowseResult { - return { - sections: [], - }; -} - -function getStickerNameFromId({ stickerId }: { stickerId: string }): string { - const stickerIdParts = stickerId.split(":"); - if (stickerIdParts.length <= 1) { - return stickerId; - } - return ( - stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? - stickerId - ); -} - -function toRecentStickerItem({ - stickerId, -}: { - stickerId: string; -}): StickerItem | null { - try { - const { providerId } = parseStickerId({ stickerId }); - return { - id: stickerId, - provider: providerId, - name: getStickerNameFromId({ stickerId }), - previewUrl: resolveStickerId({ - stickerId, - options: { width: 64, height: 64 }, - }), - metadata: {}, - }; - } catch { - return null; - } -} - -export async function searchStickers({ - query, - category, - limit = DEFAULT_SEARCH_LIMIT, -}: { - query: string; - category: StickerCategory; - limit?: number; -}): Promise<StickerSearchResult> { - registerDefaultStickerProviders({}); - - const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; - if (effectiveCategory !== "all") { - const provider = getProviderByCategory({ category: effectiveCategory }); - if (!provider) { - return { - items: [], - total: 0, - hasMore: false, - }; - } - return provider.search({ - query, - options: { limit }, - }); - } - - const providers = stickersRegistry.getAll(); - if (providers.length === 0) { - return { - items: [], - total: 0, - hasMore: false, - }; - } - - const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); - const settledResults = await Promise.allSettled( - providers.map((provider) => - provider.search({ - query, - options: { limit: perProviderLimit }, - }), - ), - ); - - const fulfilledResults = settledResults - .filter( - (result): result is PromiseFulfilledResult<StickerSearchResult> => - result.status === "fulfilled", - ) - .map((result) => result.value); - - return mergeSearchResults({ - results: fulfilledResults, - }); -} - -export async function searchAll({ - query, - limit = DEFAULT_SEARCH_LIMIT, -}: { - query: string; - limit?: number; -}): Promise<StickerBrowseResult> { - registerDefaultStickerProviders({}); - - const providers = stickersRegistry.getAll(); - if (providers.length === 0) { - return { sections: [] }; - } - - const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); - const settledResults = await Promise.allSettled( - providers.map(async (provider) => { - const result = await provider.search({ - query, - options: { limit: perProviderLimit }, - }); - return { provider, result }; - }), - ); - - const sections: StickerBrowseResult["sections"] = []; - for (const settled of settledResults) { - if (settled.status !== "fulfilled") { - continue; - } - const { provider, result } = settled.value; - if (result.items.length === 0) { - continue; - } - const category = provider.id as StickerCategory; - sections.push({ - id: category, - title: STICKER_CATEGORIES[category] ?? provider.id, - items: result.items, - hasMore: result.hasMore, - layout: "grid", - }); - } - - return { sections }; -} - -export async function browseCategory({ - category, -}: { - category: StickerCategory; -}): Promise<StickerBrowseResult> { - registerDefaultStickerProviders({}); - - const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; - if (effectiveCategory === "all") { - return getEmptyBrowseResult(); - } - - const provider = getProviderByCategory({ category: effectiveCategory }); - if (!provider) { - return getEmptyBrowseResult(); - } - - return provider.browse({ options: {} }); -} - -export async function browseAll({ - recentStickers, - limit = DEFAULT_BROWSE_LIMIT, -}: { - recentStickers: string[]; - limit?: number; -}): Promise<StickerBrowseResult> { - registerDefaultStickerProviders({}); - - const sections: StickerBrowseResult["sections"] = []; - const recentItems = recentStickers - .map((stickerId) => toRecentStickerItem({ stickerId })) - .filter((item): item is StickerItem => item !== null); - - if (recentItems.length > 0) { - sections.push({ - id: "recent", - title: "Recently used", - items: recentItems.slice(0, limit), - hasMore: recentItems.length > limit, - layout: "row", - }); - } - - const settledResults = await Promise.allSettled( - stickersRegistry.getAll().map(async (provider) => { - const browseResult = await provider.browse({ - options: { limit }, - }); - const firstSection = browseResult.sections[0]; - - if (!firstSection || firstSection.items.length === 0) { - return null; - } - - const category = provider.id as StickerCategory; - return { - ...firstSection, - id: category, - title: STICKER_CATEGORIES[category] ?? firstSection.title, - layout: "row" as const, - action: { - type: "see-all" as const, - category, - sectionId: firstSection.id, - }, - }; - }), - ); - - for (const result of settledResults) { - if (result.status === "fulfilled" && result.value) { - sections.push(result.value); - } - } - - return { sections }; -} - -export async function resolveStickerIntrinsicSize({ - stickerId, -}: { - stickerId: string; -}): Promise<{ width: number; height: number }> { - const url = resolveStickerId({ stickerId }); - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => - resolve({ width: img.naturalWidth, height: img.naturalHeight }); - img.onerror = () => - resolve({ - width: STICKER_INTRINSIC_SIZE_FALLBACK, - height: STICKER_INTRINSIC_SIZE_FALLBACK, - }); - img.src = url; - }); -} - -export { resolveStickerId }; -export { resolveQueryToRegions, getRegionLabel } from "./providers/flags"; -export type { - StickerBrowseResult, - StickerBrowseSection, - StickerCategory, - StickerItem, - StickerProvider, - StickerResolveOptions, - StickerSearchResult, -} from "./types"; +import {
+ STICKER_CATEGORIES,
+ STICKER_INTRINSIC_SIZE_FALLBACK,
+} from "@/constants/sticker-constants";
+import type { StickerCategory } from "@/lib/stickers/types";
+import { stickersRegistry } from "./registry";
+import { resolveStickerId } from "./resolver";
+import { registerDefaultStickerProviders } from "./providers";
+import { parseStickerId } from "./sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "./types";
+
+const DEFAULT_BROWSE_LIMIT = 12;
+const DEFAULT_SEARCH_LIMIT = 100;
+
+function mergeSearchResults({
+ results,
+}: {
+ results: StickerSearchResult[];
+}): StickerSearchResult {
+ const deduplicatedItems = new Map<
+ string,
+ StickerSearchResult["items"][number]
+ >();
+ let total = 0;
+ let hasMore = false;
+
+ for (const result of results) {
+ total += result.total;
+ hasMore = hasMore || result.hasMore;
+ for (const item of result.items) {
+ if (!deduplicatedItems.has(item.id)) {
+ deduplicatedItems.set(item.id, item);
+ }
+ }
+ }
+
+ return {
+ items: Array.from(deduplicatedItems.values()),
+ total,
+ hasMore,
+ };
+}
+
+function getProviderByCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): StickerProvider | null {
+ if (category === "all") {
+ return null;
+ }
+
+ try {
+ return stickersRegistry.get(category);
+ } catch {
+ return null;
+ }
+}
+
+function getEmptyBrowseResult(): StickerBrowseResult {
+ return {
+ sections: [],
+ };
+}
+
+function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
+ const stickerIdParts = stickerId.split(":");
+ if (stickerIdParts.length <= 1) {
+ return stickerId;
+ }
+ return (
+ stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
+ stickerId
+ );
+}
+
+function toRecentStickerItem({
+ stickerId,
+}: {
+ stickerId: string;
+}): StickerItem | null {
+ try {
+ const { providerId } = parseStickerId({ stickerId });
+ return {
+ id: stickerId,
+ provider: providerId,
+ name: getStickerNameFromId({ stickerId }),
+ previewUrl: resolveStickerId({
+ stickerId,
+ options: { width: 64, height: 64 },
+ }),
+ metadata: {},
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function searchStickers({
+ query,
+ category,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ category: StickerCategory;
+ limit?: number;
+}): Promise<StickerSearchResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory !== "all") {
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+ return provider.search({
+ query,
+ options: { limit },
+ });
+ }
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map((provider) =>
+ provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ }),
+ ),
+ );
+
+ const fulfilledResults = settledResults
+ .filter(
+ (result): result is PromiseFulfilledResult<StickerSearchResult> =>
+ result.status === "fulfilled",
+ )
+ .map((result) => result.value);
+
+ return mergeSearchResults({
+ results: fulfilledResults,
+ });
+}
+
+export async function searchAll({
+ query,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return { sections: [] };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map(async (provider) => {
+ const result = await provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ });
+ return { provider, result };
+ }),
+ );
+
+ const sections: StickerBrowseResult["sections"] = [];
+ for (const settled of settledResults) {
+ if (settled.status !== "fulfilled") {
+ continue;
+ }
+ const { provider, result } = settled.value;
+ if (result.items.length === 0) {
+ continue;
+ }
+ const category = provider.id as StickerCategory;
+ sections.push({
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? provider.id,
+ items: result.items,
+ hasMore: result.hasMore,
+ layout: "grid",
+ });
+ }
+
+ return { sections };
+}
+
+export async function browseCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory === "all") {
+ return getEmptyBrowseResult();
+ }
+
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return getEmptyBrowseResult();
+ }
+
+ return provider.browse({ options: {} });
+}
+
+export async function browseAll({
+ recentStickers,
+ limit = DEFAULT_BROWSE_LIMIT,
+}: {
+ recentStickers: string[];
+ limit?: number;
+}): Promise<StickerBrowseResult> {
+ registerDefaultStickerProviders({});
+
+ const sections: StickerBrowseResult["sections"] = [];
+ const recentItems = recentStickers
+ .map((stickerId) => toRecentStickerItem({ stickerId }))
+ .filter((item): item is StickerItem => item !== null);
+
+ if (recentItems.length > 0) {
+ sections.push({
+ id: "recent",
+ title: "Recently used",
+ items: recentItems.slice(0, limit),
+ hasMore: recentItems.length > limit,
+ layout: "row",
+ });
+ }
+
+ const settledResults = await Promise.allSettled(
+ stickersRegistry.getAll().map(async (provider) => {
+ const browseResult = await provider.browse({
+ options: { limit },
+ });
+ const firstSection = browseResult.sections[0];
+
+ if (!firstSection || firstSection.items.length === 0) {
+ return null;
+ }
+
+ const category = provider.id as StickerCategory;
+ return {
+ ...firstSection,
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? firstSection.title,
+ layout: "row" as const,
+ action: {
+ type: "see-all" as const,
+ category,
+ sectionId: firstSection.id,
+ },
+ };
+ }),
+ );
+
+ for (const result of settledResults) {
+ if (result.status === "fulfilled" && result.value) {
+ sections.push(result.value);
+ }
+ }
+
+ return { sections };
+}
+
+export async function resolveStickerIntrinsicSize({
+ stickerId,
+}: {
+ stickerId: string;
+}): Promise<{ width: number; height: number }> {
+ const url = resolveStickerId({ stickerId });
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () =>
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
+ img.onerror = () =>
+ resolve({
+ width: STICKER_INTRINSIC_SIZE_FALLBACK,
+ height: STICKER_INTRINSIC_SIZE_FALLBACK,
+ });
+ img.src = url;
+ });
+}
+
+export { resolveStickerId };
+export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
+export type {
+ StickerBrowseResult,
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem,
+ StickerProvider,
+ StickerResolveOptions,
+ StickerSearchResult,
+} from "./types";
diff --git a/apps/web/src/lib/stickers/providers/countries-data.ts b/apps/web/src/lib/stickers/providers/countries-data.ts index 1bcc1f50..05551699 100644 --- a/apps/web/src/lib/stickers/providers/countries-data.ts +++ b/apps/web/src/lib/stickers/providers/countries-data.ts @@ -1,1819 +1,1819 @@ -export const REGIONS = [ - { id: "Western Europe", aliases: ["west europe"] }, - { id: "Eastern Europe", aliases: ["east europe"] }, - { id: "Northern Europe", aliases: ["north europe"] }, - { id: "Southern Europe", aliases: ["south europe"] }, - { id: "South Asia", aliases: ["southern asia"] }, - { id: "Southeast Asia", aliases: ["south east asia"] }, - { id: "East Asia", aliases: ["eastern asia", "far east"] }, - { id: "Central Asia", aliases: [] }, - { id: "Middle East", aliases: ["west asia"] }, - { id: "North Africa", aliases: ["northern africa"] }, - { id: "Sub-Saharan Africa", aliases: ["subsaharan africa"] }, - { id: "North America", aliases: ["northern america"] }, - { id: "South America", aliases: ["southern america", "latin america"] }, - { id: "Central America", aliases: ["central am"] }, - { id: "Caribbean", aliases: ["caribbean islands"] }, - { id: "Oceania", aliases: ["pacific", "pacific islands"] }, - { id: "Antarctica", aliases: [] }, - { id: "Atlantic Ocean", aliases: ["atlantic"] }, - { id: "North Atlantic", aliases: [] }, -] as const; - -export type RegionId = (typeof REGIONS)[number]["id"]; - -export const REGION_GROUPS: Partial<Record<string, RegionId[]>> = { - europe: ["Western Europe", "Eastern Europe", "Northern Europe", "Southern Europe"], - asia: ["South Asia", "Southeast Asia", "East Asia", "Central Asia"], - africa: ["Sub-Saharan Africa", "North Africa"], - america: ["North America", "South America", "Central America", "Caribbean"], -}; - -export interface CountryRecord { - name: string; - code: string; - languages?: string[]; - flag_colors?: string[]; - region?: RegionId; -} - -export const COUNTRIES: CountryRecord[] = [ - { - "name": "Andorra", - "code": "AD", - "languages": ["catalan"], - "flag_colors": ["blue", "yellow", "red"], - "region": "Western Europe" - }, - { - "name": "United Arab Emirates", - "code": "AE", - "languages": ["arabic"], - "flag_colors": ["red", "green", "white", "black"], - "region": "Middle East" - }, - { - "name": "Afghanistan", - "code": "AF", - "languages": ["dari", "pashto"], - "flag_colors": ["black", "red", "green"], - "region": "South Asia" - }, - { - "name": "Antigua and Barbuda", - "code": "AG", - "languages": ["english"], - "flag_colors": ["red", "black", "blue", "white", "yellow"], - "region": "Caribbean" - }, - { - "name": "Anguilla", - "code": "AI", - "languages": ["english"], - "flag_colors": ["blue", "white", "orange"], - "region": "Caribbean" - }, - { - "name": "Albania", - "code": "AL", - "languages": ["albanian"], - "flag_colors": ["red", "black"], - "region": "Southern Europe" - }, - { - "name": "Armenia", - "code": "AM", - "languages": ["armenian"], - "flag_colors": ["red", "blue", "orange"], - "region": "Middle East" - }, - { - "name": "Angola", - "code": "AO", - "languages": ["portuguese"], - "flag_colors": ["red", "black", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Antarctica", - "code": "AQ", - "languages": [], - "flag_colors": [], - "region": "Antarctica" - }, - { - "name": "Argentina", - "code": "AR", - "languages": ["spanish"], - "flag_colors": ["blue", "white", "yellow"], - "region": "South America" - }, - { - "name": "American Samoa", - "code": "AS", - "languages": ["english", "samoan"], - "flag_colors": ["red", "white", "blue"], - "region": "Oceania" - }, - { - "name": "Austria", - "code": "AT", - "languages": ["german"], - "flag_colors": ["red", "white"], - "region": "Western Europe" - }, - { - "name": "Australia", - "code": "AU", - "languages": ["english"], - "flag_colors": ["blue", "white", "red"], - "region": "Oceania" - }, - { - "name": "Aruba", - "code": "AW", - "languages": ["dutch", "papiamento"], - "flag_colors": ["blue", "yellow", "red", "white"], - "region": "Caribbean" - }, - { - "name": "Aland Islands", - "code": "AX", - "languages": ["swedish"], - "flag_colors": ["blue", "yellow", "red"], - "region": "Northern Europe" - }, - { - "name": "Azerbaijan", - "code": "AZ", - "languages": ["azerbaijani"], - "flag_colors": ["blue", "red", "green", "white"], - "region": "Middle East" - }, - { - "name": "Bosnia and Herzegovina", - "code": "BA", - "languages": ["bosnian", "croatian", "serbian"], - "flag_colors": ["blue", "yellow", "white"], - "region": "Southern Europe" - }, - { - "name": "Barbados", - "code": "BB", - "languages": ["english"], - "flag_colors": ["blue", "yellow", "black"], - "region": "Caribbean" - }, - { - "name": "Bangladesh", - "code": "BD", - "languages": ["bengali"], - "flag_colors": ["green", "red"], - "region": "South Asia" - }, - { - "name": "Belgium", - "code": "BE", - "languages": ["french", "dutch", "german"], - "flag_colors": ["black", "yellow", "red"], - "region": "Western Europe" - }, - { - "name": "Burkina Faso", - "code": "BF", - "languages": ["french"], - "flag_colors": ["red", "green", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Bulgaria", - "code": "BG", - "languages": ["bulgarian"], - "flag_colors": ["white", "green", "red"], - "region": "Eastern Europe" - }, - { - "name": "Bahrain", - "code": "BH", - "languages": ["arabic"], - "flag_colors": ["red", "white"], - "region": "Middle East" - }, - { - "name": "Burundi", - "code": "BI", - "languages": ["kirundi", "french", "english"], - "flag_colors": ["red", "green", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Benin", - "code": "BJ", - "languages": ["french"], - "flag_colors": ["green", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Saint Barthelemy", - "code": "BL", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Bermuda", - "code": "BM", - "languages": ["english"], - "flag_colors": ["blue", "white", "red"], - "region": "North Atlantic" - }, - { - "name": "Brunei", - "code": "BN", - "languages": ["malay"], - "flag_colors": ["yellow", "white", "black", "red"], - "region": "Southeast Asia" - }, - { - "name": "Bolivia", - "code": "BO", - "languages": ["spanish", "quechua", "aymara"], - "flag_colors": ["red", "yellow", "green"], - "region": "South America" - }, - { - "name": "Caribbean Netherlands", - "code": "BQ", - "languages": ["dutch", "papiamento", "english"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Brazil", - "code": "BR", - "languages": ["portuguese"], - "flag_colors": ["green", "yellow", "blue", "white"], - "region": "South America" - }, - { - "name": "Bahamas", - "code": "BS", - "languages": ["english"], - "flag_colors": ["blue", "yellow", "black"], - "region": "Caribbean" - }, - { - "name": "Bhutan", - "code": "BT", - "languages": ["dzongkha"], - "flag_colors": ["yellow", "orange", "white"], - "region": "South Asia" - }, - { - "name": "Bouvet Island", - "code": "BV", - "languages": [], - "flag_colors": ["red", "white", "blue"], - "region": "Antarctica" - }, - { - "name": "Botswana", - "code": "BW", - "languages": ["english", "tswana"], - "flag_colors": ["blue", "black", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Belarus", - "code": "BY", - "languages": ["belarusian", "russian"], - "flag_colors": ["red", "green", "white"], - "region": "Eastern Europe" - }, - { - "name": "Belize", - "code": "BZ", - "languages": ["english"], - "flag_colors": ["blue", "red", "white"], - "region": "Central America" - }, - { - "name": "Canada", - "code": "CA", - "languages": ["english", "french"], - "flag_colors": ["red", "white"], - "region": "North America" - }, - { - "name": "Cocos (Keeling) Islands", - "code": "CC", - "languages": ["english"], - "flag_colors": ["green", "red", "yellow", "white"], - "region": "Southeast Asia" - }, - { - "name": "Democratic Republic of the Congo", - "code": "CD", - "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"], - "flag_colors": ["blue", "red", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Central African Republic", - "code": "CF", - "languages": ["french", "sango"], - "flag_colors": ["blue", "white", "green", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Republic of the Congo", - "code": "CG", - "languages": ["french", "lingala", "kituba"], - "flag_colors": ["green", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Switzerland", - "code": "CH", - "languages": ["german", "french", "italian"], - "flag_colors": ["red", "white"], - "region": "Western Europe" - }, - { - "name": "Cote d'Ivoire", - "code": "CI", - "languages": ["french"], - "flag_colors": ["orange", "white", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Cook Islands", - "code": "CK", - "languages": ["english", "cook islands maori"], - "flag_colors": ["blue", "white"], - "region": "Oceania" - }, - { - "name": "Chile", - "code": "CL", - "languages": ["spanish"], - "flag_colors": ["white", "red", "blue"], - "region": "South America" - }, - { - "name": "Cameroon", - "code": "CM", - "languages": ["english", "french"], - "flag_colors": ["green", "red", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "China", - "code": "CN", - "languages": ["mandarin"], - "flag_colors": ["red", "yellow"], - "region": "East Asia" - }, - { - "name": "Colombia", - "code": "CO", - "languages": ["spanish"], - "flag_colors": ["yellow", "blue", "red"], - "region": "South America" - }, - { - "name": "Costa Rica", - "code": "CR", - "languages": ["spanish"], - "flag_colors": ["blue", "white", "red"], - "region": "Central America" - }, - { - "name": "Cuba", - "code": "CU", - "languages": ["spanish"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Cape Verde", - "code": "CV", - "languages": ["portuguese"], - "flag_colors": ["blue", "white", "red", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Curacao", - "code": "CW", - "languages": ["dutch", "papiamento", "english"], - "flag_colors": ["blue", "yellow", "white"], - "region": "Caribbean" - }, - { - "name": "Christmas Island", - "code": "CX", - "languages": ["english"], - "flag_colors": ["green", "blue", "yellow", "white"], - "region": "Oceania" - }, - { - "name": "Cyprus", - "code": "CY", - "languages": ["greek", "turkish"], - "flag_colors": ["white", "orange", "green"], - "region": "Middle East" - }, - { - "name": "Czechia", - "code": "CZ", - "languages": ["czech"], - "flag_colors": ["white", "red", "blue"], - "region": "Eastern Europe" - }, - { - "name": "Germany", - "code": "DE", - "languages": ["german"], - "flag_colors": ["black", "red", "yellow"], - "region": "Western Europe" - }, - { - "name": "Djibouti", - "code": "DJ", - "languages": ["arabic", "french", "afar", "somali"], - "flag_colors": ["blue", "green", "white", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Denmark", - "code": "DK", - "languages": ["danish"], - "flag_colors": ["red", "white"], - "region": "Northern Europe" - }, - { - "name": "Dominica", - "code": "DM", - "languages": ["english"], - "flag_colors": ["green", "yellow", "black", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Dominican Republic", - "code": "DO", - "languages": ["spanish"], - "flag_colors": ["red", "white", "blue"], - "region": "Caribbean" - }, - { - "name": "Algeria", - "code": "DZ", - "languages": ["arabic", "tamazight"], - "flag_colors": ["green", "white", "red"], - "region": "North Africa" - }, - { - "name": "Ecuador", - "code": "EC", - "languages": ["spanish"], - "flag_colors": ["yellow", "blue", "red"], - "region": "South America" - }, - { - "name": "Estonia", - "code": "EE", - "languages": ["estonian"], - "flag_colors": ["blue", "black", "white"], - "region": "Northern Europe" - }, - { - "name": "Egypt", - "code": "EG", - "languages": ["arabic"], - "flag_colors": ["red", "white", "black", "yellow"], - "region": "North Africa" - }, - { - "name": "Western Sahara", - "code": "EH", - "languages": ["arabic", "spanish"], - "flag_colors": ["black", "white", "green", "red"], - "region": "North Africa" - }, - { - "name": "Eritrea", - "code": "ER", - "languages": ["tigrinya", "arabic", "english"], - "flag_colors": ["red", "green", "blue", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Spain", - "code": "ES", - "languages": ["spanish"], - "flag_colors": ["red", "yellow"], - "region": "Southern Europe" - }, - { - "name": "Ethiopia", - "code": "ET", - "languages": ["amharic", "oromo"], - "flag_colors": ["green", "yellow", "red", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "European Union", - "code": "EU", - "languages": ["english", "french", "german"], - "flag_colors": ["blue", "yellow"], - "region": "Western Europe" - }, - { - "name": "Finland", - "code": "FI", - "languages": ["finnish", "swedish"], - "flag_colors": ["white", "blue"], - "region": "Northern Europe" - }, - { - "name": "Fiji", - "code": "FJ", - "languages": ["english", "fijian", "hindi"], - "flag_colors": ["blue", "white", "red", "yellow"], - "region": "Oceania" - }, - { - "name": "Falkland Islands", - "code": "FK", - "languages": ["english"], - "flag_colors": ["blue", "white", "red", "yellow"], - "region": "South America" - }, - { - "name": "Micronesia", - "code": "FM", - "languages": ["english"], - "flag_colors": ["blue", "white"], - "region": "Oceania" - }, - { - "name": "Faroe Islands", - "code": "FO", - "languages": ["faroese", "danish"], - "flag_colors": ["white", "red", "blue"], - "region": "Northern Europe" - }, - { - "name": "France", - "code": "FR", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Western Europe" - }, - { - "name": "Gabon", - "code": "GA", - "languages": ["french"], - "flag_colors": ["green", "yellow", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "England", - "code": "GB-ENG", - "languages": ["english"], - "flag_colors": ["red", "white"], - "region": "Western Europe" - }, - { - "name": "Northern Ireland", - "code": "GB-NIR", - "languages": ["english", "irish"], - "flag_colors": ["white", "red"], - "region": "Western Europe" - }, - { - "name": "Scotland", - "code": "GB-SCT", - "languages": ["english", "scottish gaelic"], - "flag_colors": ["blue", "white"], - "region": "Western Europe" - }, - { - "name": "Wales", - "code": "GB-WLS", - "languages": ["english", "welsh"], - "flag_colors": ["green", "white", "red"], - "region": "Western Europe" - }, - { - "name": "United Kingdom", - "code": "GB", - "languages": ["english"], - "flag_colors": ["red", "white", "blue"], - "region": "Western Europe" - }, - { - "name": "Grenada", - "code": "GD", - "languages": ["english"], - "flag_colors": ["red", "yellow", "green", "black"], - "region": "Caribbean" - }, - { - "name": "Georgia", - "code": "GE", - "languages": ["georgian"], - "flag_colors": ["white", "red"], - "region": "Middle East" - }, - { - "name": "French Guiana", - "code": "GF", - "languages": ["french"], - "flag_colors": ["green", "yellow", "red"], - "region": "South America" - }, - { - "name": "Guernsey", - "code": "GG", - "languages": ["english", "french"], - "flag_colors": ["white", "red", "yellow"], - "region": "Western Europe" - }, - { - "name": "Ghana", - "code": "GH", - "languages": ["english"], - "flag_colors": ["red", "yellow", "green", "black"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Gibraltar", - "code": "GI", - "languages": ["english"], - "flag_colors": ["red", "white", "yellow"], - "region": "Southern Europe" - }, - { - "name": "Greenland", - "code": "GL", - "languages": ["kalaallisut", "danish"], - "flag_colors": ["red", "white"], - "region": "North America" - }, - { - "name": "Gambia", - "code": "GM", - "languages": ["english"], - "flag_colors": ["red", "blue", "green", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Guinea", - "code": "GN", - "languages": ["french"], - "flag_colors": ["red", "yellow", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Guadeloupe", - "code": "GP", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Equatorial Guinea", - "code": "GQ", - "languages": ["spanish", "french", "portuguese"], - "flag_colors": ["green", "white", "red", "blue", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Greece", - "code": "GR", - "languages": ["greek"], - "flag_colors": ["blue", "white"], - "region": "Southern Europe" - }, - { - "name": "South Georgia and the South Sandwich Islands", - "code": "GS", - "languages": [], - "flag_colors": ["blue", "white", "yellow"], - "region": "Atlantic Ocean" - }, - { - "name": "Guatemala", - "code": "GT", - "languages": ["spanish"], - "flag_colors": ["blue", "white"], - "region": "Central America" - }, - { - "name": "Guam", - "code": "GU", - "languages": ["english", "chamorro"], - "flag_colors": ["blue", "red"], - "region": "Oceania" - }, - { - "name": "Guinea-Bissau", - "code": "GW", - "languages": ["portuguese"], - "flag_colors": ["red", "yellow", "green", "black"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Guyana", - "code": "GY", - "languages": ["english"], - "flag_colors": ["green", "yellow", "red", "black", "white"], - "region": "South America" - }, - { - "name": "Hong Kong", - "code": "HK", - "languages": ["cantonese", "english"], - "flag_colors": ["red", "white"], - "region": "East Asia" - }, - { - "name": "Heard Island and McDonald Islands", - "code": "HM", - "languages": [], - "flag_colors": ["blue", "white", "red"], - "region": "Oceania" - }, - { - "name": "Honduras", - "code": "HN", - "languages": ["spanish"], - "flag_colors": ["blue", "white"], - "region": "Central America" - }, - { - "name": "Croatia", - "code": "HR", - "languages": ["croatian"], - "flag_colors": ["red", "white", "blue"], - "region": "Southern Europe" - }, - { - "name": "Haiti", - "code": "HT", - "languages": ["haitian creole", "french"], - "flag_colors": ["blue", "red", "white"], - "region": "Caribbean" - }, - { - "name": "Hungary", - "code": "HU", - "languages": ["hungarian"], - "flag_colors": ["red", "white", "green"], - "region": "Eastern Europe" - }, - { - "name": "Indonesia", - "code": "ID", - "languages": ["indonesian"], - "flag_colors": ["red", "white"], - "region": "Southeast Asia" - }, - { - "name": "Ireland", - "code": "IE", - "languages": ["english", "irish"], - "flag_colors": ["green", "white", "orange"], - "region": "Western Europe" - }, - { - "name": "Israel", - "code": "IL", - "languages": ["hebrew", "arabic"], - "flag_colors": ["blue", "white"], - "region": "Middle East" - }, - { - "name": "Isle of Man", - "code": "IM", - "languages": ["english", "manx"], - "flag_colors": ["red", "yellow"], - "region": "Western Europe" - }, - { - "name": "India", - "code": "IN", - "languages": ["hindi", "english"], - "flag_colors": ["orange", "white", "green", "blue"], - "region": "South Asia" - }, - { - "name": "British Indian Ocean Territory", - "code": "IO", - "languages": ["english"], - "flag_colors": ["blue", "white", "red", "yellow"], - "region": "South Asia" - }, - { - "name": "Iraq", - "code": "IQ", - "languages": ["arabic", "kurdish"], - "flag_colors": ["red", "white", "black", "green"], - "region": "Middle East" - }, - { - "name": "Iran", - "code": "IR", - "languages": ["persian"], - "flag_colors": ["green", "white", "red"], - "region": "Middle East" - }, - { - "name": "Iceland", - "code": "IS", - "languages": ["icelandic"], - "flag_colors": ["blue", "white", "red"], - "region": "Northern Europe" - }, - { - "name": "Italy", - "code": "IT", - "languages": ["italian"], - "flag_colors": ["green", "white", "red"], - "region": "Southern Europe" - }, - { - "name": "Jersey", - "code": "JE", - "languages": ["english", "french"], - "flag_colors": ["white", "red", "yellow"], - "region": "Western Europe" - }, - { - "name": "Jamaica", - "code": "JM", - "languages": ["english"], - "flag_colors": ["green", "yellow", "black"], - "region": "Caribbean" - }, - { - "name": "Jordan", - "code": "JO", - "languages": ["arabic"], - "flag_colors": ["black", "white", "green", "red"], - "region": "Middle East" - }, - { - "name": "Japan", - "code": "JP", - "languages": ["japanese"], - "flag_colors": ["red", "white"], - "region": "East Asia" - }, - { - "name": "Kenya", - "code": "KE", - "languages": ["english", "swahili"], - "flag_colors": ["black", "red", "green", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Kyrgyzstan", - "code": "KG", - "languages": ["kyrgyz", "russian"], - "flag_colors": ["red", "yellow"], - "region": "Central Asia" - }, - { - "name": "Cambodia", - "code": "KH", - "languages": ["khmer"], - "flag_colors": ["blue", "red", "white"], - "region": "Southeast Asia" - }, - { - "name": "Kiribati", - "code": "KI", - "languages": ["english", "kiribati"], - "flag_colors": ["red", "blue", "white", "yellow"], - "region": "Oceania" - }, - { - "name": "Comoros", - "code": "KM", - "languages": ["comorian", "arabic", "french"], - "flag_colors": ["green", "yellow", "white", "red", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Saint Kitts and Nevis", - "code": "KN", - "languages": ["english"], - "flag_colors": ["green", "red", "black", "yellow", "white"], - "region": "Caribbean" - }, - { - "name": "North Korea", - "code": "KP", - "languages": ["korean"], - "flag_colors": ["red", "blue", "white"], - "region": "East Asia" - }, - { - "name": "South Korea", - "code": "KR", - "languages": ["korean"], - "flag_colors": ["white", "red", "blue", "black"], - "region": "East Asia" - }, - { - "name": "Kuwait", - "code": "KW", - "languages": ["arabic"], - "flag_colors": ["green", "white", "red", "black"], - "region": "Middle East" - }, - { - "name": "Cayman Islands", - "code": "KY", - "languages": ["english"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Kazakhstan", - "code": "KZ", - "languages": ["kazakh", "russian"], - "flag_colors": ["blue", "yellow"], - "region": "Central Asia" - }, - { - "name": "Laos", - "code": "LA", - "languages": ["lao"], - "flag_colors": ["red", "blue", "white"], - "region": "Southeast Asia" - }, - { - "name": "Lebanon", - "code": "LB", - "languages": ["arabic"], - "flag_colors": ["red", "white", "green"], - "region": "Middle East" - }, - { - "name": "Saint Lucia", - "code": "LC", - "languages": ["english"], - "flag_colors": ["blue", "yellow", "black", "white"], - "region": "Caribbean" - }, - { - "name": "Liechtenstein", - "code": "LI", - "languages": ["german"], - "flag_colors": ["blue", "red", "yellow"], - "region": "Western Europe" - }, - { - "name": "Sri Lanka", - "code": "LK", - "languages": ["sinhala", "tamil"], - "flag_colors": ["yellow", "green", "orange", "red"], - "region": "South Asia" - }, - { - "name": "Liberia", - "code": "LR", - "languages": ["english"], - "flag_colors": ["red", "white", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Lesotho", - "code": "LS", - "languages": ["sesotho", "english"], - "flag_colors": ["blue", "white", "green", "black"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Lithuania", - "code": "LT", - "languages": ["lithuanian"], - "flag_colors": ["yellow", "green", "red"], - "region": "Northern Europe" - }, - { - "name": "Luxembourg", - "code": "LU", - "languages": ["luxembourgish", "french", "german"], - "flag_colors": ["red", "white", "blue"], - "region": "Western Europe" - }, - { - "name": "Latvia", - "code": "LV", - "languages": ["latvian"], - "flag_colors": ["red", "white"], - "region": "Northern Europe" - }, - { - "name": "Libya", - "code": "LY", - "languages": ["arabic"], - "flag_colors": ["red", "black", "green", "white"], - "region": "North Africa" - }, - { - "name": "Morocco", - "code": "MA", - "languages": ["arabic", "tamazight"], - "flag_colors": ["red", "green"], - "region": "North Africa" - }, - { - "name": "Monaco", - "code": "MC", - "languages": ["french"], - "flag_colors": ["red", "white"], - "region": "Western Europe" - }, - { - "name": "Moldova", - "code": "MD", - "languages": ["romanian"], - "flag_colors": ["blue", "yellow", "red"], - "region": "Eastern Europe" - }, - { - "name": "Montenegro", - "code": "ME", - "languages": ["montenegrin"], - "flag_colors": ["red", "yellow"], - "region": "Southern Europe" - }, - { - "name": "Saint Martin", - "code": "MF", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Madagascar", - "code": "MG", - "languages": ["malagasy", "french"], - "flag_colors": ["white", "red", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Marshall Islands", - "code": "MH", - "languages": ["marshallese", "english"], - "flag_colors": ["blue", "white", "orange"], - "region": "Oceania" - }, - { - "name": "North Macedonia", - "code": "MK", - "languages": ["macedonian", "albanian"], - "flag_colors": ["red", "yellow"], - "region": "Southern Europe" - }, - { - "name": "Mali", - "code": "ML", - "languages": ["french"], - "flag_colors": ["green", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Myanmar", - "code": "MM", - "languages": ["burmese"], - "flag_colors": ["yellow", "green", "red", "white"], - "region": "Southeast Asia" - }, - { - "name": "Mongolia", - "code": "MN", - "languages": ["mongolian"], - "flag_colors": ["red", "blue", "yellow"], - "region": "East Asia" - }, - { - "name": "Macao", - "code": "MO", - "languages": ["cantonese", "portuguese"], - "flag_colors": ["green", "white", "yellow"], - "region": "East Asia" - }, - { - "name": "Northern Mariana Islands", - "code": "MP", - "languages": ["english", "chamorro", "carolinian"], - "flag_colors": ["blue", "white"], - "region": "Oceania" - }, - { - "name": "Martinique", - "code": "MQ", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "Mauritania", - "code": "MR", - "languages": ["arabic"], - "flag_colors": ["green", "yellow", "red"], - "region": "North Africa" - }, - { - "name": "Montserrat", - "code": "MS", - "languages": ["english"], - "flag_colors": ["blue", "green", "white", "yellow", "red"], - "region": "Caribbean" - }, - { - "name": "Malta", - "code": "MT", - "languages": ["maltese", "english"], - "flag_colors": ["white", "red"], - "region": "Southern Europe" - }, - { - "name": "Mauritius", - "code": "MU", - "languages": ["english", "french", "mauritian creole"], - "flag_colors": ["red", "blue", "yellow", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Maldives", - "code": "MV", - "languages": ["divehi"], - "flag_colors": ["red", "green", "white"], - "region": "South Asia" - }, - { - "name": "Malawi", - "code": "MW", - "languages": ["english", "chichewa"], - "flag_colors": ["black", "red", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Mexico", - "code": "MX", - "languages": ["spanish"], - "flag_colors": ["green", "white", "red"], - "region": "North America" - }, - { - "name": "Malaysia", - "code": "MY", - "languages": ["malay"], - "flag_colors": ["red", "white", "blue", "yellow"], - "region": "Southeast Asia" - }, - { - "name": "Mozambique", - "code": "MZ", - "languages": ["portuguese"], - "flag_colors": ["green", "black", "yellow", "white", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Namibia", - "code": "NA", - "languages": ["english"], - "flag_colors": ["blue", "red", "green", "white", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "New Caledonia", - "code": "NC", - "languages": ["french"], - "flag_colors": ["blue", "red", "green", "yellow", "black"], - "region": "Oceania" - }, - { - "name": "Niger", - "code": "NE", - "languages": ["french"], - "flag_colors": ["orange", "white", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Norfolk Island", - "code": "NF", - "languages": ["english", "norfuk"], - "flag_colors": ["green", "white"], - "region": "Oceania" - }, - { - "name": "Nigeria", - "code": "NG", - "languages": ["english"], - "flag_colors": ["green", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Nicaragua", - "code": "NI", - "languages": ["spanish"], - "flag_colors": ["blue", "white"], - "region": "Central America" - }, - { - "name": "Netherlands", - "code": "NL", - "languages": ["dutch"], - "flag_colors": ["red", "white", "blue"], - "region": "Western Europe" - }, - { - "name": "Norway", - "code": "NO", - "languages": ["norwegian"], - "flag_colors": ["red", "white", "blue"], - "region": "Northern Europe" - }, - { - "name": "Nepal", - "code": "NP", - "languages": ["nepali"], - "flag_colors": ["red", "blue", "white"], - "region": "South Asia" - }, - { - "name": "Nauru", - "code": "NR", - "languages": ["nauruan", "english"], - "flag_colors": ["blue", "yellow", "white"], - "region": "Oceania" - }, - { - "name": "Niue", - "code": "NU", - "languages": ["niuean", "english"], - "flag_colors": ["yellow", "blue", "white", "red"], - "region": "Oceania" - }, - { - "name": "New Zealand", - "code": "NZ", - "languages": ["english", "maori"], - "flag_colors": ["blue", "red", "white"], - "region": "Oceania" - }, - { - "name": "Oman", - "code": "OM", - "languages": ["arabic"], - "flag_colors": ["red", "white", "green"], - "region": "Middle East" - }, - { - "name": "Panama", - "code": "PA", - "languages": ["spanish"], - "flag_colors": ["red", "white", "blue"], - "region": "Central America" - }, - { - "name": "Peru", - "code": "PE", - "languages": ["spanish", "quechua", "aymara"], - "flag_colors": ["red", "white"], - "region": "South America" - }, - { - "name": "French Polynesia", - "code": "PF", - "languages": ["french", "tahitian"], - "flag_colors": ["red", "white"], - "region": "Oceania" - }, - { - "name": "Papua New Guinea", - "code": "PG", - "languages": ["english", "tok pisin", "hiri motu"], - "flag_colors": ["red", "black", "yellow", "white"], - "region": "Oceania" - }, - { - "name": "Philippines", - "code": "PH", - "languages": ["filipino", "english"], - "flag_colors": ["blue", "red", "white", "yellow"], - "region": "Southeast Asia" - }, - { - "name": "Pakistan", - "code": "PK", - "languages": ["urdu", "english"], - "flag_colors": ["green", "white"], - "region": "South Asia" - }, - { - "name": "Poland", - "code": "PL", - "languages": ["polish"], - "flag_colors": ["white", "red"], - "region": "Eastern Europe" - }, - { - "name": "Saint Pierre and Miquelon", - "code": "PM", - "languages": ["french"], - "flag_colors": ["blue", "white", "red", "yellow", "black"], - "region": "North America" - }, - { - "name": "Pitcairn Islands", - "code": "PN", - "languages": ["english", "pitkern"], - "flag_colors": ["blue", "green", "yellow", "red", "white"], - "region": "Oceania" - }, - { - "name": "Puerto Rico", - "code": "PR", - "languages": ["spanish", "english"], - "flag_colors": ["red", "white", "blue"], - "region": "Caribbean" - }, - { - "name": "Palestine", - "code": "PS", - "languages": ["arabic"], - "flag_colors": ["red", "green", "white", "black"], - "region": "Middle East" - }, - { - "name": "Portugal", - "code": "PT", - "languages": ["portuguese"], - "flag_colors": ["green", "red", "yellow"], - "region": "Southern Europe" - }, - { - "name": "Palau", - "code": "PW", - "languages": ["palauan", "english"], - "flag_colors": ["blue", "yellow"], - "region": "Oceania" - }, - { - "name": "Paraguay", - "code": "PY", - "languages": ["spanish", "guarani"], - "flag_colors": ["red", "white", "blue", "yellow"], - "region": "South America" - }, - { - "name": "Qatar", - "code": "QA", - "languages": ["arabic"], - "flag_colors": ["red", "white"], - "region": "Middle East" - }, - { - "name": "Reunion", - "code": "RE", - "languages": ["french", "reunion creole"], - "flag_colors": ["blue", "white", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Romania", - "code": "RO", - "languages": ["romanian"], - "flag_colors": ["blue", "yellow", "red"], - "region": "Eastern Europe" - }, - { - "name": "Serbia", - "code": "RS", - "languages": ["serbian"], - "flag_colors": ["red", "blue", "white", "yellow"], - "region": "Eastern Europe" - }, - { - "name": "Russia", - "code": "RU", - "languages": ["russian"], - "flag_colors": ["white", "blue", "red"], - "region": "Eastern Europe" - }, - { - "name": "Rwanda", - "code": "RW", - "languages": ["kinyarwanda", "french", "english"], - "flag_colors": ["blue", "yellow", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Saudi Arabia", - "code": "SA", - "languages": ["arabic"], - "flag_colors": ["green", "white"], - "region": "Middle East" - }, - { - "name": "Solomon Islands", - "code": "SB", - "languages": ["english"], - "flag_colors": ["blue", "green", "yellow", "white"], - "region": "Oceania" - }, - { - "name": "Seychelles", - "code": "SC", - "languages": ["seychellois creole", "english", "french"], - "flag_colors": ["blue", "yellow", "red", "white", "green"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Sudan", - "code": "SD", - "languages": ["arabic", "english"], - "flag_colors": ["red", "white", "black", "green"], - "region": "North Africa" - }, - { - "name": "Sweden", - "code": "SE", - "languages": ["swedish"], - "flag_colors": ["blue", "yellow"], - "region": "Northern Europe" - }, - { - "name": "Singapore", - "code": "SG", - "languages": ["english", "malay", "mandarin", "tamil"], - "flag_colors": ["red", "white"], - "region": "Southeast Asia" - }, - { - "name": "Saint Helena", - "code": "SH", - "languages": ["english"], - "flag_colors": ["blue", "red", "white", "yellow", "green"], - "region": "Atlantic Ocean" - }, - { - "name": "Slovenia", - "code": "SI", - "languages": ["slovenian"], - "flag_colors": ["white", "blue", "red"], - "region": "Southern Europe" - }, - { - "name": "Svalbard and Jan Mayen", - "code": "SJ", - "languages": ["norwegian"], - "flag_colors": ["red", "white", "blue"], - "region": "Northern Europe" - }, - { - "name": "Slovakia", - "code": "SK", - "languages": ["slovak"], - "flag_colors": ["white", "blue", "red"], - "region": "Eastern Europe" - }, - { - "name": "Sierra Leone", - "code": "SL", - "languages": ["english"], - "flag_colors": ["green", "white", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "San Marino", - "code": "SM", - "languages": ["italian"], - "flag_colors": ["white", "blue"], - "region": "Southern Europe" - }, - { - "name": "Senegal", - "code": "SN", - "languages": ["french"], - "flag_colors": ["green", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Somalia", - "code": "SO", - "languages": ["somali", "arabic"], - "flag_colors": ["blue", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Suriname", - "code": "SR", - "languages": ["dutch"], - "flag_colors": ["green", "white", "red", "yellow"], - "region": "South America" - }, - { - "name": "South Sudan", - "code": "SS", - "languages": ["english"], - "flag_colors": ["black", "red", "green", "blue", "white", "yellow"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Sao Tome and Principe", - "code": "ST", - "languages": ["portuguese"], - "flag_colors": ["green", "yellow", "red", "black"], - "region": "Sub-Saharan Africa" - }, - { - "name": "El Salvador", - "code": "SV", - "languages": ["spanish"], - "flag_colors": ["blue", "white"], - "region": "Central America" - }, - { - "name": "Sint Maarten", - "code": "SX", - "languages": ["dutch", "english"], - "flag_colors": ["red", "white", "blue"], - "region": "Caribbean" - }, - { - "name": "Syria", - "code": "SY", - "languages": ["arabic"], - "flag_colors": ["red", "white", "black", "green"], - "region": "Middle East" - }, - { - "name": "Eswatini", - "code": "SZ", - "languages": ["swazi", "english"], - "flag_colors": ["blue", "red", "yellow", "black", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Turks and Caicos Islands", - "code": "TC", - "languages": ["english"], - "flag_colors": ["blue", "red", "white", "yellow", "green"], - "region": "Caribbean" - }, - { - "name": "Chad", - "code": "TD", - "languages": ["french", "arabic"], - "flag_colors": ["blue", "yellow", "red"], - "region": "Sub-Saharan Africa" - }, - { - "name": "French Southern Territories", - "code": "TF", - "languages": ["french"], - "flag_colors": ["blue", "white", "red"], - "region": "Oceania" - }, - { - "name": "Togo", - "code": "TG", - "languages": ["french"], - "flag_colors": ["green", "yellow", "red", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Thailand", - "code": "TH", - "languages": ["thai"], - "flag_colors": ["red", "white", "blue"], - "region": "Southeast Asia" - }, - { - "name": "Tajikistan", - "code": "TJ", - "languages": ["tajik"], - "flag_colors": ["red", "white", "green", "yellow"], - "region": "Central Asia" - }, - { - "name": "Tokelau", - "code": "TK", - "languages": ["tokelauan", "english"], - "flag_colors": ["blue", "yellow", "white"], - "region": "Oceania" - }, - { - "name": "Timor-Leste", - "code": "TL", - "languages": ["tetum", "portuguese"], - "flag_colors": ["red", "yellow", "black", "white"], - "region": "Southeast Asia" - }, - { - "name": "Turkmenistan", - "code": "TM", - "languages": ["turkmen"], - "flag_colors": ["green", "red", "white"], - "region": "Central Asia" - }, - { - "name": "Tunisia", - "code": "TN", - "languages": ["arabic"], - "flag_colors": ["red", "white"], - "region": "North Africa" - }, - { - "name": "Tonga", - "code": "TO", - "languages": ["tongan", "english"], - "flag_colors": ["red", "white"], - "region": "Oceania" - }, - { - "name": "Turkey", - "code": "TR", - "languages": ["turkish"], - "flag_colors": ["red", "white"], - "region": "Middle East" - }, - { - "name": "Trinidad and Tobago", - "code": "TT", - "languages": ["english"], - "flag_colors": ["red", "black", "white"], - "region": "Caribbean" - }, - { - "name": "Tuvalu", - "code": "TV", - "languages": ["tuvaluan", "english"], - "flag_colors": ["blue", "yellow", "red", "white"], - "region": "Oceania" - }, - { - "name": "Taiwan", - "code": "TW", - "languages": ["mandarin"], - "flag_colors": ["red", "blue", "white"], - "region": "East Asia" - }, - { - "name": "Tanzania", - "code": "TZ", - "languages": ["swahili", "english"], - "flag_colors": ["green", "yellow", "black", "blue"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Ukraine", - "code": "UA", - "languages": ["ukrainian"], - "flag_colors": ["blue", "yellow"], - "region": "Eastern Europe" - }, - { - "name": "Uganda", - "code": "UG", - "languages": ["english", "swahili"], - "flag_colors": ["black", "yellow", "red", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "United States", - "code": "US", - "languages": ["english"], - "flag_colors": ["red", "white", "blue"], - "region": "North America" - }, - { - "name": "Uruguay", - "code": "UY", - "languages": ["spanish"], - "flag_colors": ["white", "blue", "yellow"], - "region": "South America" - }, - { - "name": "Uzbekistan", - "code": "UZ", - "languages": ["uzbek"], - "flag_colors": ["blue", "white", "green", "red"], - "region": "Central Asia" - }, - { - "name": "Vatican City", - "code": "VA", - "languages": ["italian", "latin"], - "flag_colors": ["yellow", "white"], - "region": "Southern Europe" - }, - { - "name": "Saint Vincent and the Grenadines", - "code": "VC", - "languages": ["english"], - "flag_colors": ["blue", "yellow", "green", "white"], - "region": "Caribbean" - }, - { - "name": "Venezuela", - "code": "VE", - "languages": ["spanish"], - "flag_colors": ["yellow", "blue", "red", "white"], - "region": "South America" - }, - { - "name": "British Virgin Islands", - "code": "VG", - "languages": ["english"], - "flag_colors": ["blue", "white", "red"], - "region": "Caribbean" - }, - { - "name": "U.S. Virgin Islands", - "code": "VI", - "languages": ["english"], - "flag_colors": ["red", "white", "blue", "yellow"], - "region": "Caribbean" - }, - { - "name": "Vietnam", - "code": "VN", - "languages": ["vietnamese"], - "flag_colors": ["red", "yellow"], - "region": "Southeast Asia" - }, - { - "name": "Vanuatu", - "code": "VU", - "languages": ["bislama", "english", "french"], - "flag_colors": ["red", "green", "black", "yellow"], - "region": "Oceania" - }, - { - "name": "Wallis and Futuna", - "code": "WF", - "languages": ["french", "wallisian", "futunan"], - "flag_colors": ["red", "white", "blue"], - "region": "Oceania" - }, - { - "name": "Samoa", - "code": "WS", - "languages": ["samoan", "english"], - "flag_colors": ["red", "blue", "white"], - "region": "Oceania" - }, - { - "name": "Kosovo", - "code": "XK", - "languages": ["albanian", "serbian"], - "flag_colors": ["blue", "yellow", "white"], - "region": "Eastern Europe" - }, - { - "name": "Yemen", - "code": "YE", - "languages": ["arabic"], - "flag_colors": ["red", "white", "black"], - "region": "Middle East" - }, - { - "name": "Mayotte", - "code": "YT", - "languages": ["french", "shimaore", "kibushi"], - "flag_colors": ["white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "South Africa", - "code": "ZA", - "languages": ["zulu", "xhosa", "afrikaans", "english"], - "flag_colors": ["red", "blue", "green", "yellow", "black", "white"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Zambia", - "code": "ZM", - "languages": ["english"], - "flag_colors": ["green", "red", "black", "orange"], - "region": "Sub-Saharan Africa" - }, - { - "name": "Zimbabwe", - "code": "ZW", - "languages": ["english", "shona", "ndebele"], - "flag_colors": ["green", "yellow", "red", "black", "white"], - "region": "Sub-Saharan Africa" - } -]; +export const REGIONS = [
+ { id: "Western Europe", aliases: ["west europe"] },
+ { id: "Eastern Europe", aliases: ["east europe"] },
+ { id: "Northern Europe", aliases: ["north europe"] },
+ { id: "Southern Europe", aliases: ["south europe"] },
+ { id: "South Asia", aliases: ["southern asia"] },
+ { id: "Southeast Asia", aliases: ["south east asia"] },
+ { id: "East Asia", aliases: ["eastern asia", "far east"] },
+ { id: "Central Asia", aliases: [] },
+ { id: "Middle East", aliases: ["west asia"] },
+ { id: "North Africa", aliases: ["northern africa"] },
+ { id: "Sub-Saharan Africa", aliases: ["subsaharan africa"] },
+ { id: "North America", aliases: ["northern america"] },
+ { id: "South America", aliases: ["southern america", "latin america"] },
+ { id: "Central America", aliases: ["central am"] },
+ { id: "Caribbean", aliases: ["caribbean islands"] },
+ { id: "Oceania", aliases: ["pacific", "pacific islands"] },
+ { id: "Antarctica", aliases: [] },
+ { id: "Atlantic Ocean", aliases: ["atlantic"] },
+ { id: "North Atlantic", aliases: [] },
+] as const;
+
+export type RegionId = (typeof REGIONS)[number]["id"];
+
+export const REGION_GROUPS: Partial<Record<string, RegionId[]>> = {
+ europe: ["Western Europe", "Eastern Europe", "Northern Europe", "Southern Europe"],
+ asia: ["South Asia", "Southeast Asia", "East Asia", "Central Asia"],
+ africa: ["Sub-Saharan Africa", "North Africa"],
+ america: ["North America", "South America", "Central America", "Caribbean"],
+};
+
+export interface CountryRecord {
+ name: string;
+ code: string;
+ languages?: string[];
+ flag_colors?: string[];
+ region?: RegionId;
+}
+
+export const COUNTRIES: CountryRecord[] = [
+ {
+ "name": "Andorra",
+ "code": "AD",
+ "languages": ["catalan"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Arab Emirates",
+ "code": "AE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Afghanistan",
+ "code": "AF",
+ "languages": ["dari", "pashto"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Antigua and Barbuda",
+ "code": "AG",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "blue", "white", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Anguilla",
+ "code": "AI",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Albania",
+ "code": "AL",
+ "languages": ["albanian"],
+ "flag_colors": ["red", "black"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Armenia",
+ "code": "AM",
+ "languages": ["armenian"],
+ "flag_colors": ["red", "blue", "orange"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Angola",
+ "code": "AO",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "black", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Antarctica",
+ "code": "AQ",
+ "languages": [],
+ "flag_colors": [],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Argentina",
+ "code": "AR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "American Samoa",
+ "code": "AS",
+ "languages": ["english", "samoan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Austria",
+ "code": "AT",
+ "languages": ["german"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Australia",
+ "code": "AU",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Aruba",
+ "code": "AW",
+ "languages": ["dutch", "papiamento"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Aland Islands",
+ "code": "AX",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Azerbaijan",
+ "code": "AZ",
+ "languages": ["azerbaijani"],
+ "flag_colors": ["blue", "red", "green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Bosnia and Herzegovina",
+ "code": "BA",
+ "languages": ["bosnian", "croatian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Barbados",
+ "code": "BB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bangladesh",
+ "code": "BD",
+ "languages": ["bengali"],
+ "flag_colors": ["green", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Belgium",
+ "code": "BE",
+ "languages": ["french", "dutch", "german"],
+ "flag_colors": ["black", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Burkina Faso",
+ "code": "BF",
+ "languages": ["french"],
+ "flag_colors": ["red", "green", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Bulgaria",
+ "code": "BG",
+ "languages": ["bulgarian"],
+ "flag_colors": ["white", "green", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Bahrain",
+ "code": "BH",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Burundi",
+ "code": "BI",
+ "languages": ["kirundi", "french", "english"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Benin",
+ "code": "BJ",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Barthelemy",
+ "code": "BL",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bermuda",
+ "code": "BM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "North Atlantic"
+ },
+ {
+ "name": "Brunei",
+ "code": "BN",
+ "languages": ["malay"],
+ "flag_colors": ["yellow", "white", "black", "red"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Bolivia",
+ "code": "BO",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "South America"
+ },
+ {
+ "name": "Caribbean Netherlands",
+ "code": "BQ",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Brazil",
+ "code": "BR",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "blue", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Bahamas",
+ "code": "BS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bhutan",
+ "code": "BT",
+ "languages": ["dzongkha"],
+ "flag_colors": ["yellow", "orange", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Bouvet Island",
+ "code": "BV",
+ "languages": [],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Botswana",
+ "code": "BW",
+ "languages": ["english", "tswana"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Belarus",
+ "code": "BY",
+ "languages": ["belarusian", "russian"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Belize",
+ "code": "BZ",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Canada",
+ "code": "CA",
+ "languages": ["english", "french"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Cocos (Keeling) Islands",
+ "code": "CC",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "yellow", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Democratic Republic of the Congo",
+ "code": "CD",
+ "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Central African Republic",
+ "code": "CF",
+ "languages": ["french", "sango"],
+ "flag_colors": ["blue", "white", "green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Republic of the Congo",
+ "code": "CG",
+ "languages": ["french", "lingala", "kituba"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Switzerland",
+ "code": "CH",
+ "languages": ["german", "french", "italian"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Cote d'Ivoire",
+ "code": "CI",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Cook Islands",
+ "code": "CK",
+ "languages": ["english", "cook islands maori"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Chile",
+ "code": "CL",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "South America"
+ },
+ {
+ "name": "Cameroon",
+ "code": "CM",
+ "languages": ["english", "french"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "China",
+ "code": "CN",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Colombia",
+ "code": "CO",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Costa Rica",
+ "code": "CR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Central America"
+ },
+ {
+ "name": "Cuba",
+ "code": "CU",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Cape Verde",
+ "code": "CV",
+ "languages": ["portuguese"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Curacao",
+ "code": "CW",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Christmas Island",
+ "code": "CX",
+ "languages": ["english"],
+ "flag_colors": ["green", "blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Cyprus",
+ "code": "CY",
+ "languages": ["greek", "turkish"],
+ "flag_colors": ["white", "orange", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Czechia",
+ "code": "CZ",
+ "languages": ["czech"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Germany",
+ "code": "DE",
+ "languages": ["german"],
+ "flag_colors": ["black", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Djibouti",
+ "code": "DJ",
+ "languages": ["arabic", "french", "afar", "somali"],
+ "flag_colors": ["blue", "green", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Denmark",
+ "code": "DK",
+ "languages": ["danish"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Dominica",
+ "code": "DM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Dominican Republic",
+ "code": "DO",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Algeria",
+ "code": "DZ",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Ecuador",
+ "code": "EC",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Estonia",
+ "code": "EE",
+ "languages": ["estonian"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Egypt",
+ "code": "EG",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "yellow"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Western Sahara",
+ "code": "EH",
+ "languages": ["arabic", "spanish"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Eritrea",
+ "code": "ER",
+ "languages": ["tigrinya", "arabic", "english"],
+ "flag_colors": ["red", "green", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Spain",
+ "code": "ES",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Ethiopia",
+ "code": "ET",
+ "languages": ["amharic", "oromo"],
+ "flag_colors": ["green", "yellow", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "European Union",
+ "code": "EU",
+ "languages": ["english", "french", "german"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Finland",
+ "code": "FI",
+ "languages": ["finnish", "swedish"],
+ "flag_colors": ["white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Fiji",
+ "code": "FJ",
+ "languages": ["english", "fijian", "hindi"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Falkland Islands",
+ "code": "FK",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Micronesia",
+ "code": "FM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Faroe Islands",
+ "code": "FO",
+ "languages": ["faroese", "danish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "France",
+ "code": "FR",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Gabon",
+ "code": "GA",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "England",
+ "code": "GB-ENG",
+ "languages": ["english"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Northern Ireland",
+ "code": "GB-NIR",
+ "languages": ["english", "irish"],
+ "flag_colors": ["white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Scotland",
+ "code": "GB-SCT",
+ "languages": ["english", "scottish gaelic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Wales",
+ "code": "GB-WLS",
+ "languages": ["english", "welsh"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Kingdom",
+ "code": "GB",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Grenada",
+ "code": "GD",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Georgia",
+ "code": "GE",
+ "languages": ["georgian"],
+ "flag_colors": ["white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "French Guiana",
+ "code": "GF",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Guernsey",
+ "code": "GG",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Ghana",
+ "code": "GH",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Gibraltar",
+ "code": "GI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Greenland",
+ "code": "GL",
+ "languages": ["kalaallisut", "danish"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Gambia",
+ "code": "GM",
+ "languages": ["english"],
+ "flag_colors": ["red", "blue", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guinea",
+ "code": "GN",
+ "languages": ["french"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guadeloupe",
+ "code": "GP",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Equatorial Guinea",
+ "code": "GQ",
+ "languages": ["spanish", "french", "portuguese"],
+ "flag_colors": ["green", "white", "red", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Greece",
+ "code": "GR",
+ "languages": ["greek"],
+ "flag_colors": ["blue", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "South Georgia and the South Sandwich Islands",
+ "code": "GS",
+ "languages": [],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Guatemala",
+ "code": "GT",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Guam",
+ "code": "GU",
+ "languages": ["english", "chamorro"],
+ "flag_colors": ["blue", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Guinea-Bissau",
+ "code": "GW",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guyana",
+ "code": "GY",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Hong Kong",
+ "code": "HK",
+ "languages": ["cantonese", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Heard Island and McDonald Islands",
+ "code": "HM",
+ "languages": [],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Honduras",
+ "code": "HN",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Croatia",
+ "code": "HR",
+ "languages": ["croatian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Haiti",
+ "code": "HT",
+ "languages": ["haitian creole", "french"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Hungary",
+ "code": "HU",
+ "languages": ["hungarian"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Indonesia",
+ "code": "ID",
+ "languages": ["indonesian"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Ireland",
+ "code": "IE",
+ "languages": ["english", "irish"],
+ "flag_colors": ["green", "white", "orange"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Israel",
+ "code": "IL",
+ "languages": ["hebrew", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Isle of Man",
+ "code": "IM",
+ "languages": ["english", "manx"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "India",
+ "code": "IN",
+ "languages": ["hindi", "english"],
+ "flag_colors": ["orange", "white", "green", "blue"],
+ "region": "South Asia"
+ },
+ {
+ "name": "British Indian Ocean Territory",
+ "code": "IO",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Iraq",
+ "code": "IQ",
+ "languages": ["arabic", "kurdish"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iran",
+ "code": "IR",
+ "languages": ["persian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iceland",
+ "code": "IS",
+ "languages": ["icelandic"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Italy",
+ "code": "IT",
+ "languages": ["italian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Jersey",
+ "code": "JE",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Jamaica",
+ "code": "JM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Jordan",
+ "code": "JO",
+ "languages": ["arabic"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Japan",
+ "code": "JP",
+ "languages": ["japanese"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kenya",
+ "code": "KE",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Kyrgyzstan",
+ "code": "KG",
+ "languages": ["kyrgyz", "russian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Cambodia",
+ "code": "KH",
+ "languages": ["khmer"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Kiribati",
+ "code": "KI",
+ "languages": ["english", "kiribati"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Comoros",
+ "code": "KM",
+ "languages": ["comorian", "arabic", "french"],
+ "flag_colors": ["green", "yellow", "white", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Kitts and Nevis",
+ "code": "KN",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "North Korea",
+ "code": "KP",
+ "languages": ["korean"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "South Korea",
+ "code": "KR",
+ "languages": ["korean"],
+ "flag_colors": ["white", "red", "blue", "black"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kuwait",
+ "code": "KW",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white", "red", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Cayman Islands",
+ "code": "KY",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Kazakhstan",
+ "code": "KZ",
+ "languages": ["kazakh", "russian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Laos",
+ "code": "LA",
+ "languages": ["lao"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Lebanon",
+ "code": "LB",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Saint Lucia",
+ "code": "LC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Liechtenstein",
+ "code": "LI",
+ "languages": ["german"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Sri Lanka",
+ "code": "LK",
+ "languages": ["sinhala", "tamil"],
+ "flag_colors": ["yellow", "green", "orange", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Liberia",
+ "code": "LR",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lesotho",
+ "code": "LS",
+ "languages": ["sesotho", "english"],
+ "flag_colors": ["blue", "white", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lithuania",
+ "code": "LT",
+ "languages": ["lithuanian"],
+ "flag_colors": ["yellow", "green", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Luxembourg",
+ "code": "LU",
+ "languages": ["luxembourgish", "french", "german"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Latvia",
+ "code": "LV",
+ "languages": ["latvian"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Libya",
+ "code": "LY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "black", "green", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Morocco",
+ "code": "MA",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["red", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Monaco",
+ "code": "MC",
+ "languages": ["french"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Moldova",
+ "code": "MD",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Montenegro",
+ "code": "ME",
+ "languages": ["montenegrin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Martin",
+ "code": "MF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Madagascar",
+ "code": "MG",
+ "languages": ["malagasy", "french"],
+ "flag_colors": ["white", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Marshall Islands",
+ "code": "MH",
+ "languages": ["marshallese", "english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Oceania"
+ },
+ {
+ "name": "North Macedonia",
+ "code": "MK",
+ "languages": ["macedonian", "albanian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mali",
+ "code": "ML",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Myanmar",
+ "code": "MM",
+ "languages": ["burmese"],
+ "flag_colors": ["yellow", "green", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mongolia",
+ "code": "MN",
+ "languages": ["mongolian"],
+ "flag_colors": ["red", "blue", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Macao",
+ "code": "MO",
+ "languages": ["cantonese", "portuguese"],
+ "flag_colors": ["green", "white", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Northern Mariana Islands",
+ "code": "MP",
+ "languages": ["english", "chamorro", "carolinian"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Martinique",
+ "code": "MQ",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Mauritania",
+ "code": "MR",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Montserrat",
+ "code": "MS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "white", "yellow", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Malta",
+ "code": "MT",
+ "languages": ["maltese", "english"],
+ "flag_colors": ["white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mauritius",
+ "code": "MU",
+ "languages": ["english", "french", "mauritian creole"],
+ "flag_colors": ["red", "blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Maldives",
+ "code": "MV",
+ "languages": ["divehi"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Malawi",
+ "code": "MW",
+ "languages": ["english", "chichewa"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Mexico",
+ "code": "MX",
+ "languages": ["spanish"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North America"
+ },
+ {
+ "name": "Malaysia",
+ "code": "MY",
+ "languages": ["malay"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mozambique",
+ "code": "MZ",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "black", "yellow", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Namibia",
+ "code": "NA",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "green", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "New Caledonia",
+ "code": "NC",
+ "languages": ["french"],
+ "flag_colors": ["blue", "red", "green", "yellow", "black"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niger",
+ "code": "NE",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Norfolk Island",
+ "code": "NF",
+ "languages": ["english", "norfuk"],
+ "flag_colors": ["green", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Nigeria",
+ "code": "NG",
+ "languages": ["english"],
+ "flag_colors": ["green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Nicaragua",
+ "code": "NI",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Netherlands",
+ "code": "NL",
+ "languages": ["dutch"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Norway",
+ "code": "NO",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Nepal",
+ "code": "NP",
+ "languages": ["nepali"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Nauru",
+ "code": "NR",
+ "languages": ["nauruan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niue",
+ "code": "NU",
+ "languages": ["niuean", "english"],
+ "flag_colors": ["yellow", "blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "New Zealand",
+ "code": "NZ",
+ "languages": ["english", "maori"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Oman",
+ "code": "OM",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Panama",
+ "code": "PA",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Central America"
+ },
+ {
+ "name": "Peru",
+ "code": "PE",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "French Polynesia",
+ "code": "PF",
+ "languages": ["french", "tahitian"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Papua New Guinea",
+ "code": "PG",
+ "languages": ["english", "tok pisin", "hiri motu"],
+ "flag_colors": ["red", "black", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Philippines",
+ "code": "PH",
+ "languages": ["filipino", "english"],
+ "flag_colors": ["blue", "red", "white", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Pakistan",
+ "code": "PK",
+ "languages": ["urdu", "english"],
+ "flag_colors": ["green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Poland",
+ "code": "PL",
+ "languages": ["polish"],
+ "flag_colors": ["white", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Saint Pierre and Miquelon",
+ "code": "PM",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red", "yellow", "black"],
+ "region": "North America"
+ },
+ {
+ "name": "Pitcairn Islands",
+ "code": "PN",
+ "languages": ["english", "pitkern"],
+ "flag_colors": ["blue", "green", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Puerto Rico",
+ "code": "PR",
+ "languages": ["spanish", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Palestine",
+ "code": "PS",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Portugal",
+ "code": "PT",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Palau",
+ "code": "PW",
+ "languages": ["palauan", "english"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Paraguay",
+ "code": "PY",
+ "languages": ["spanish", "guarani"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Qatar",
+ "code": "QA",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Reunion",
+ "code": "RE",
+ "languages": ["french", "reunion creole"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Romania",
+ "code": "RO",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Serbia",
+ "code": "RS",
+ "languages": ["serbian"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Russia",
+ "code": "RU",
+ "languages": ["russian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Rwanda",
+ "code": "RW",
+ "languages": ["kinyarwanda", "french", "english"],
+ "flag_colors": ["blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saudi Arabia",
+ "code": "SA",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Solomon Islands",
+ "code": "SB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Seychelles",
+ "code": "SC",
+ "languages": ["seychellois creole", "english", "french"],
+ "flag_colors": ["blue", "yellow", "red", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sudan",
+ "code": "SD",
+ "languages": ["arabic", "english"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Sweden",
+ "code": "SE",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Singapore",
+ "code": "SG",
+ "languages": ["english", "malay", "mandarin", "tamil"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Saint Helena",
+ "code": "SH",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Slovenia",
+ "code": "SI",
+ "languages": ["slovenian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Svalbard and Jan Mayen",
+ "code": "SJ",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Slovakia",
+ "code": "SK",
+ "languages": ["slovak"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Sierra Leone",
+ "code": "SL",
+ "languages": ["english"],
+ "flag_colors": ["green", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "San Marino",
+ "code": "SM",
+ "languages": ["italian"],
+ "flag_colors": ["white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Senegal",
+ "code": "SN",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Somalia",
+ "code": "SO",
+ "languages": ["somali", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Suriname",
+ "code": "SR",
+ "languages": ["dutch"],
+ "flag_colors": ["green", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "South Sudan",
+ "code": "SS",
+ "languages": ["english"],
+ "flag_colors": ["black", "red", "green", "blue", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sao Tome and Principe",
+ "code": "ST",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "red", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "El Salvador",
+ "code": "SV",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Sint Maarten",
+ "code": "SX",
+ "languages": ["dutch", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Syria",
+ "code": "SY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Eswatini",
+ "code": "SZ",
+ "languages": ["swazi", "english"],
+ "flag_colors": ["blue", "red", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Turks and Caicos Islands",
+ "code": "TC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Chad",
+ "code": "TD",
+ "languages": ["french", "arabic"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "French Southern Territories",
+ "code": "TF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Togo",
+ "code": "TG",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Thailand",
+ "code": "TH",
+ "languages": ["thai"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Tajikistan",
+ "code": "TJ",
+ "languages": ["tajik"],
+ "flag_colors": ["red", "white", "green", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tokelau",
+ "code": "TK",
+ "languages": ["tokelauan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Timor-Leste",
+ "code": "TL",
+ "languages": ["tetum", "portuguese"],
+ "flag_colors": ["red", "yellow", "black", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Turkmenistan",
+ "code": "TM",
+ "languages": ["turkmen"],
+ "flag_colors": ["green", "red", "white"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tunisia",
+ "code": "TN",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Tonga",
+ "code": "TO",
+ "languages": ["tongan", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Turkey",
+ "code": "TR",
+ "languages": ["turkish"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Trinidad and Tobago",
+ "code": "TT",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Tuvalu",
+ "code": "TV",
+ "languages": ["tuvaluan", "english"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Taiwan",
+ "code": "TW",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Tanzania",
+ "code": "TZ",
+ "languages": ["swahili", "english"],
+ "flag_colors": ["green", "yellow", "black", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Ukraine",
+ "code": "UA",
+ "languages": ["ukrainian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Uganda",
+ "code": "UG",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "United States",
+ "code": "US",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "North America"
+ },
+ {
+ "name": "Uruguay",
+ "code": "UY",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Uzbekistan",
+ "code": "UZ",
+ "languages": ["uzbek"],
+ "flag_colors": ["blue", "white", "green", "red"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Vatican City",
+ "code": "VA",
+ "languages": ["italian", "latin"],
+ "flag_colors": ["yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Vincent and the Grenadines",
+ "code": "VC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "green", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Venezuela",
+ "code": "VE",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "British Virgin Islands",
+ "code": "VG",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "U.S. Virgin Islands",
+ "code": "VI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Vietnam",
+ "code": "VN",
+ "languages": ["vietnamese"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Vanuatu",
+ "code": "VU",
+ "languages": ["bislama", "english", "french"],
+ "flag_colors": ["red", "green", "black", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Wallis and Futuna",
+ "code": "WF",
+ "languages": ["french", "wallisian", "futunan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Samoa",
+ "code": "WS",
+ "languages": ["samoan", "english"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Kosovo",
+ "code": "XK",
+ "languages": ["albanian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Yemen",
+ "code": "YE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Mayotte",
+ "code": "YT",
+ "languages": ["french", "shimaore", "kibushi"],
+ "flag_colors": ["white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "South Africa",
+ "code": "ZA",
+ "languages": ["zulu", "xhosa", "afrikaans", "english"],
+ "flag_colors": ["red", "blue", "green", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zambia",
+ "code": "ZM",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "orange"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zimbabwe",
+ "code": "ZW",
+ "languages": ["english", "shona", "ndebele"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ }
+];
diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts index b0a298cb..830a1129 100644 --- a/apps/web/src/lib/stickers/providers/index.ts +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -1,24 +1,24 @@ -import { stickersRegistry } from "../registry"; -import type { StickerProvider } from "@/lib/stickers/types"; -import { flagsProvider } from "./flags"; -import { logosProvider } from "./logos"; -import { shapesProvider } from "./shapes"; - -const defaultProviders: StickerProvider[] = [ - logosProvider, - flagsProvider, - shapesProvider, -]; - -export function registerDefaultStickerProviders({ - providersToRegister = defaultProviders, -}: { - providersToRegister?: StickerProvider[]; -} = {}): void { - for (const provider of providersToRegister) { - if (stickersRegistry.has(provider.id)) { - continue; - } - stickersRegistry.register(provider.id, provider); - } -} +import { stickersRegistry } from "../registry";
+import type { StickerProvider } from "@/lib/stickers/types";
+import { flagsProvider } from "./flags";
+import { logosProvider } from "./logos";
+import { shapesProvider } from "./shapes";
+
+const defaultProviders: StickerProvider[] = [
+ logosProvider,
+ flagsProvider,
+ shapesProvider,
+];
+
+export function registerDefaultStickerProviders({
+ providersToRegister = defaultProviders,
+}: {
+ providersToRegister?: StickerProvider[];
+} = {}): void {
+ for (const provider of providersToRegister) {
+ if (stickersRegistry.has(provider.id)) {
+ continue;
+ }
+ stickersRegistry.register(provider.id, provider);
+ }
+}
diff --git a/apps/web/src/lib/stickers/providers/logos.ts b/apps/web/src/lib/stickers/providers/logos.ts index 5be2e9a2..474c64da 100644 --- a/apps/web/src/lib/stickers/providers/logos.ts +++ b/apps/web/src/lib/stickers/providers/logos.ts @@ -1,31 +1,31 @@ -import type { - StickerBrowseResult, - StickerItem, - StickerProvider, - StickerSearchResult, -} from "../types"; - -const LOGOS_PROVIDER_ID = "logos"; - -const EMPTY_SEARCH_RESULT: StickerSearchResult = { - items: [] as StickerItem[], - total: 0, - hasMore: false, -}; - -const EMPTY_BROWSE_RESULT: StickerBrowseResult = { - sections: [], -}; - -export const logosProvider: StickerProvider = { - id: LOGOS_PROVIDER_ID, - async search(): Promise<StickerSearchResult> { - return EMPTY_SEARCH_RESULT; - }, - async browse(): Promise<StickerBrowseResult> { - return EMPTY_BROWSE_RESULT; - }, - resolveUrl({ stickerId }: { stickerId: string }): string { - return stickerId; - }, -}; +import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const LOGOS_PROVIDER_ID = "logos";
+
+const EMPTY_SEARCH_RESULT: StickerSearchResult = {
+ items: [] as StickerItem[],
+ total: 0,
+ hasMore: false,
+};
+
+const EMPTY_BROWSE_RESULT: StickerBrowseResult = {
+ sections: [],
+};
+
+export const logosProvider: StickerProvider = {
+ id: LOGOS_PROVIDER_ID,
+ async search(): Promise<StickerSearchResult> {
+ return EMPTY_SEARCH_RESULT;
+ },
+ async browse(): Promise<StickerBrowseResult> {
+ return EMPTY_BROWSE_RESULT;
+ },
+ resolveUrl({ stickerId }: { stickerId: string }): string {
+ return stickerId;
+ },
+};
diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts index 8cb9364b..cb56db4c 100644 --- a/apps/web/src/lib/stickers/sticker-id.ts +++ b/apps/web/src/lib/stickers/sticker-id.ts @@ -1,34 +1,34 @@ -export function parseStickerId({ stickerId }: { stickerId: string }): { - providerId: string; - providerValue: string; -} { - const normalizedStickerId = stickerId.trim(); - if (!normalizedStickerId) { - throw new Error("Sticker ID must be a non-empty string"); - } - - const separatorIndex = normalizedStickerId.indexOf(":"); - if ( - separatorIndex <= 0 || - separatorIndex === normalizedStickerId.length - 1 - ) { - throw new Error( - `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`, - ); - } - - const providerId = normalizedStickerId.slice(0, separatorIndex).trim(); - const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim(); - - return { providerId, providerValue }; -} - -export function buildStickerId({ - providerId, - providerValue, -}: { - providerId: string; - providerValue: string; -}): string { - return `${providerId}:${providerValue}`; -} +export function parseStickerId({ stickerId }: { stickerId: string }): {
+ providerId: string;
+ providerValue: string;
+} {
+ const normalizedStickerId = stickerId.trim();
+ if (!normalizedStickerId) {
+ throw new Error("Sticker ID must be a non-empty string");
+ }
+
+ const separatorIndex = normalizedStickerId.indexOf(":");
+ if (
+ separatorIndex <= 0 ||
+ separatorIndex === normalizedStickerId.length - 1
+ ) {
+ throw new Error(
+ `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`,
+ );
+ }
+
+ const providerId = normalizedStickerId.slice(0, separatorIndex).trim();
+ const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim();
+
+ return { providerId, providerValue };
+}
+
+export function buildStickerId({
+ providerId,
+ providerValue,
+}: {
+ providerId: string;
+ providerValue: string;
+}): string {
+ return `${providerId}:${providerValue}`;
+}
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts index 44b2b1ff..d2a4a84d 100644 --- a/apps/web/src/lib/text/measure-element.ts +++ b/apps/web/src/lib/text/measure-element.ts @@ -1,132 +1,132 @@ -import { - CORNER_RADIUS_MIN, - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import type { TextBackground, TextElement } from "@/lib/timeline"; -import { - measureTextBlock, - setCanvasLetterSpacing, - getTextVisualRect, - type TextBlockMeasurement, -} from "./layout"; - -export interface ResolvedTextBackground extends TextBackground { - paddingX: number; - paddingY: number; - offsetX: number; - offsetY: number; - cornerRadius: number; -} - -export interface MeasuredTextElement { - scaledFontSize: number; - fontString: string; - letterSpacing: number; - lineHeightPx: number; - lines: string[]; - lineMetrics: TextMetrics[]; - block: TextBlockMeasurement; - fontSizeRatio: number; - resolvedBackground: ResolvedTextBackground; - visualRect: { left: number; top: number; width: number; height: number }; -} - -/** - * Shared text measurement used by both the renderer and preview bounds. - * Accepts the canvas context to measure on so callers can reuse an existing - * context (e.g. the renderer's) rather than creating a throwaway canvas. - * The context state is preserved via save/restore. - */ -export function measureTextElement({ - element, - canvasHeight, - localTime, - ctx, -}: { - element: TextElement; - canvasHeight: number; - localTime: number; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; -}): MeasuredTextElement { - const scaledFontSize = - element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; - const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - const letterSpacing = element.letterSpacing ?? 0; - const lineHeightPx = - scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; - const lines = element.content.split("\n"); - - ctx.save(); - ctx.font = fontString; - ctx.textBaseline = "middle"; - setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - const lineMetrics = lines.map((line) => ctx.measureText(line)); - ctx.restore(); - - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize: scaledFontSize, - }); - - const bg = element.background; - const resolvedBackground: ResolvedTextBackground = { - ...bg, - paddingX: resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }), - paddingY: resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }), - offsetX: resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }), - offsetY: resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }), - cornerRadius: resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }), - }; - - const visualRect = getTextVisualRect({ - textAlign: element.textAlign, - block, - background: resolvedBackground, - fontSizeRatio, - }); - - return { - scaledFontSize, - fontString, - letterSpacing, - lineHeightPx, - lines, - lineMetrics, - block, - fontSizeRatio, - resolvedBackground, - visualRect, - }; -} +import {
+ CORNER_RADIUS_MIN,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { TextBackground, TextElement } from "@/lib/timeline";
+import {
+ measureTextBlock,
+ setCanvasLetterSpacing,
+ getTextVisualRect,
+ type TextBlockMeasurement,
+} from "./layout";
+
+export interface ResolvedTextBackground extends TextBackground {
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export interface MeasuredTextElement {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+ fontSizeRatio: number;
+ resolvedBackground: ResolvedTextBackground;
+ visualRect: { left: number; top: number; width: number; height: number };
+}
+
+/**
+ * Shared text measurement used by both the renderer and preview bounds.
+ * Accepts the canvas context to measure on so callers can reuse an existing
+ * context (e.g. the renderer's) rather than creating a throwaway canvas.
+ * The context state is preserved via save/restore.
+ */
+export function measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+}: {
+ element: TextElement;
+ canvasHeight: number;
+ localTime: number;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+}): MeasuredTextElement {
+ const scaledFontSize =
+ element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
+ const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
+ const letterSpacing = element.letterSpacing ?? 0;
+ const lineHeightPx =
+ scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const lines = element.content.split("\n");
+
+ ctx.save();
+ ctx.font = fontString;
+ ctx.textBaseline = "middle";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+ ctx.restore();
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+
+ const bg = element.background;
+ const resolvedBackground: ResolvedTextBackground = {
+ ...bg,
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
+
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+
+ return {
+ scaledFontSize,
+ fontString,
+ letterSpacing,
+ lineHeightPx,
+ lines,
+ lineMetrics,
+ block,
+ fontSizeRatio,
+ resolvedBackground,
+ visualRect,
+ };
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index a6602566..aff42bee 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -207,7 +207,8 @@ export function getSnappedSeekTime({ fps: number; }): number { const snappedTime = snapTimeToFrame({ time: rawTime, fps }); - return Math.max(0, Math.min(duration, snappedTime)); + const lastFrame = getLastFrameTime({ duration, fps }); + return Math.max(0, Math.min(lastFrame, snappedTime)); } export function getLastFrameTime({ diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts index 44592df4..f5bb8a3e 100644 --- a/apps/web/src/lib/timeline/audio-constants.ts +++ b/apps/web/src/lib/timeline/audio-constants.ts @@ -1,2 +1,2 @@ -export const VOLUME_DB_MIN = -60; -export const VOLUME_DB_MAX = 20; +export const VOLUME_DB_MIN = -60;
+export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts index 4bc3a7e1..28e381c2 100644 --- a/apps/web/src/lib/timeline/audio-state.ts +++ b/apps/web/src/lib/timeline/audio-state.ts @@ -1,100 +1,100 @@ -import { hasKeyframesForPath } from "@/lib/animation/keyframe-query"; -import { resolveNumberAtTime } from "@/lib/animation/resolve"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; -import type { TimelineElement } from "./types"; -const DEFAULT_STEP_SECONDS = 1 / 60; - -export type AudioCapableElement = Extract< - TimelineElement, - { type: "audio" | "video" } ->; - -export function clampDb(value: number): number { - if (!Number.isFinite(value)) { - return 0; - } - - return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value)); -} - -export function dBToLinear(db: number): number { - return 10 ** (clampDb(db) / 20); -} - -export function hasAnimatedVolume({ - element, -}: { - element: AudioCapableElement; -}): boolean { - return hasKeyframesForPath({ - animations: element.animations, - propertyPath: "volume", - }); -} - -export function resolveEffectiveAudioGain({ - element, - trackMuted = false, - localTime, -}: { - element: AudioCapableElement; - trackMuted?: boolean; - localTime: number; -}): number { - if (trackMuted || element.muted === true) { - return 0; - } - - const resolvedDb = resolveNumberAtTime({ - baseValue: element.volume ?? 0, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - - return dBToLinear(resolvedDb); -} - -export function buildAudioGainAutomation({ - element, - trackMuted = false, - fromLocalTime, - toLocalTime, - stepSeconds = DEFAULT_STEP_SECONDS, -}: { - element: AudioCapableElement; - trackMuted?: boolean; - fromLocalTime: number; - toLocalTime: number; - stepSeconds?: number; -}): Array<{ localTime: number; gain: number }> { - const startTime = Math.max(0, fromLocalTime); - const endTime = Math.max(startTime, toLocalTime); - const safeStep = - Number.isFinite(stepSeconds) && stepSeconds > 0 - ? stepSeconds - : DEFAULT_STEP_SECONDS; - const points: Array<{ localTime: number; gain: number }> = []; - - for (let localTime = startTime; localTime < endTime; localTime += safeStep) { - points.push({ - localTime, - gain: resolveEffectiveAudioGain({ - element, - trackMuted, - localTime, - }), - }); - } - - points.push({ - localTime: endTime, - gain: resolveEffectiveAudioGain({ - element, - trackMuted, - localTime: endTime, - }), - }); - - return points; -} +import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
+import { resolveNumberAtTime } from "@/lib/animation/resolve";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+import type { TimelineElement } from "./types";
+const DEFAULT_STEP_SECONDS = 1 / 60;
+
+export type AudioCapableElement = Extract<
+ TimelineElement,
+ { type: "audio" | "video" }
+>;
+
+export function clampDb(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
+}
+
+export function dBToLinear(db: number): number {
+ return 10 ** (clampDb(db) / 20);
+}
+
+export function hasAnimatedVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "volume",
+ });
+}
+
+export function resolveEffectiveAudioGain({
+ element,
+ trackMuted = false,
+ localTime,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ localTime: number;
+}): number {
+ if (trackMuted || element.muted === true) {
+ return 0;
+ }
+
+ const resolvedDb = resolveNumberAtTime({
+ baseValue: element.volume ?? 0,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+
+ return dBToLinear(resolvedDb);
+}
+
+export function buildAudioGainAutomation({
+ element,
+ trackMuted = false,
+ fromLocalTime,
+ toLocalTime,
+ stepSeconds = DEFAULT_STEP_SECONDS,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ fromLocalTime: number;
+ toLocalTime: number;
+ stepSeconds?: number;
+}): Array<{ localTime: number; gain: number }> {
+ const startTime = Math.max(0, fromLocalTime);
+ const endTime = Math.max(startTime, toLocalTime);
+ const safeStep =
+ Number.isFinite(stepSeconds) && stepSeconds > 0
+ ? stepSeconds
+ : DEFAULT_STEP_SECONDS;
+ const points: Array<{ localTime: number; gain: number }> = [];
+
+ for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
+ points.push({
+ localTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime,
+ }),
+ });
+ }
+
+ points.push({
+ localTime: endTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime: endTime,
+ }),
+ });
+
+ return points;
+}
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts index f5c034c7..31bf73af 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,149 +1,149 @@ -import type { Bookmark } from "@/lib/timeline"; -import { roundToFrame } from "@/lib/time"; - -export const BOOKMARK_TIME_EPSILON = 0.001; - -function bookmarkTimeEqual({ - bookmarkTime, - frameTime, -}: { - bookmarkTime: number; - frameTime: number; -}): boolean { - return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; -} - -export function findBookmarkIndex({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): number { - return bookmarks.findIndex((bookmark) => - bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function isBookmarkAtTime({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): boolean { - return bookmarks.some((bookmark) => - bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function toggleBookmarkInArray({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark[] { - const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); - - if (bookmarkIndex !== -1) { - return bookmarks.filter((_, index) => index !== bookmarkIndex); - } - - const newBookmarks = [...bookmarks, { time: frameTime }]; - return newBookmarks.slice().sort((a, b) => a.time - b.time); -} - -export function removeBookmarkFromArray({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark[] { - return bookmarks.filter( - (bookmark) => - !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), - ); -} - -export function updateBookmarkInArray({ - bookmarks, - frameTime, - updates, -}: { - bookmarks: Bookmark[]; - frameTime: number; - updates: Partial<Omit<Bookmark, "time">>; -}): Bookmark[] { - const index = findBookmarkIndex({ bookmarks, frameTime }); - if (index === -1) { - return bookmarks; - } - - const updated = { ...bookmarks[index], ...updates }; - const result = [...bookmarks]; - result[index] = updated; - return result; -} - -export function moveBookmarkInArray({ - bookmarks, - fromTime, - toTime, -}: { - bookmarks: Bookmark[]; - fromTime: number; - toTime: number; -}): Bookmark[] { - const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); - if (index === -1) { - return bookmarks; - } - - const updated = { ...bookmarks[index], time: toTime }; - const result = [...bookmarks]; - result[index] = updated; - return result.slice().sort((a, b) => a.time - b.time); -} - -export function getFrameTime({ - time, - fps, -}: { - time: number; - fps: number; -}): number { - return roundToFrame({ time, fps }); -} - -export function getBookmarkAtTime({ - bookmarks, - frameTime, -}: { - bookmarks: Bookmark[]; - frameTime: number; -}): Bookmark | null { - const index = findBookmarkIndex({ bookmarks, frameTime }); - return index === -1 ? null : bookmarks[index]; -} - -export function getBookmarksActiveAtTime({ - bookmarks, - time, -}: { - bookmarks: Bookmark[]; - time: number; -}): Bookmark[] { - return bookmarks.filter((bookmark) => { - const start = bookmark.time; - const end = - bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration - : start; - return ( - time >= start - BOOKMARK_TIME_EPSILON && - time <= end + BOOKMARK_TIME_EPSILON - ); - }); -} +import type { Bookmark } from "@/lib/timeline";
+import { roundToFrame } from "@/lib/time";
+
+export const BOOKMARK_TIME_EPSILON = 0.001;
+
+function bookmarkTimeEqual({
+ bookmarkTime,
+ frameTime,
+}: {
+ bookmarkTime: number;
+ frameTime: number;
+}): boolean {
+ return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
+}
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, index) => index !== bookmarkIndex);
+ }
+
+ const newBookmarks = [...bookmarks, { time: frameTime }];
+ return newBookmarks.slice().sort((a, b) => a.time - b.time);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ return bookmarks.filter(
+ (bookmark) =>
+ !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], ...updates };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result;
+}
+
+export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+}: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], time: toTime };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result.slice().sort((a, b) => a.time - b.time);
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return roundToFrame({ time, fps });
+}
+
+export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark | null {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ return index === -1 ? null : bookmarks[index];
+}
+
+export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+}): Bookmark[] {
+ return bookmarks.filter((bookmark) => {
+ const start = bookmark.time;
+ const end =
+ bookmark.duration != null && bookmark.duration > 0
+ ? start + bookmark.duration
+ : start;
+ return (
+ time >= start - BOOKMARK_TIME_EPSILON &&
+ time <= end + BOOKMARK_TIME_EPSILON
+ );
+ });
+}
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts index 7f52905f..01dbe912 100644 --- a/apps/web/src/lib/timeline/defaults.ts +++ b/apps/web/src/lib/timeline/defaults.ts @@ -1,77 +1,77 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import type { TTimelineViewState } from "@/lib/project/types"; -import type { BlendMode, Transform } from "@/lib/rendering"; -import type { TextElement } from "./types"; - -const defaultTransform: Transform = { - scaleX: 1, - scaleY: 1, - position: { x: 0, y: 0 }, - rotate: 0, -}; - -const defaultOpacity = 1; -const defaultBlendMode: BlendMode = "normal"; -const defaultVolume = 0; - -const defaultTextLetterSpacing = 0; -const defaultTextLineHeight = 1.2; - -const defaultTextBackground = { - enabled: false, - color: "#000000", - cornerRadius: 0, - paddingX: 30, - paddingY: 42, - offsetX: 0, - offsetY: 0, -}; - -const defaultTextElement: Omit<TextElement, "id"> = { - type: "text", - name: "Text", - content: "Default text", - fontSize: 15, - fontFamily: "Arial", - color: "#ffffff", - background: { ...defaultTextBackground }, - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, - duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - ...defaultTransform, - position: { ...defaultTransform.position }, - }, - opacity: defaultOpacity, -}; - -const defaultTimelineViewState: TTimelineViewState = { - zoomLevel: 1, - scrollLeft: 0, - playheadTime: 0, -}; - -export const DEFAULTS = { - element: { - transform: defaultTransform, - opacity: defaultOpacity, - blendMode: defaultBlendMode, - volume: defaultVolume, - }, - text: { - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, - background: defaultTextBackground, - element: defaultTextElement, - }, - timeline: { - viewState: defaultTimelineViewState, - }, -}; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { TTimelineViewState } from "@/lib/project/types";
+import type { BlendMode, Transform } from "@/lib/rendering";
+import type { TextElement } from "./types";
+
+const defaultTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+const defaultOpacity = 1;
+const defaultBlendMode: BlendMode = "normal";
+const defaultVolume = 0;
+
+const defaultTextLetterSpacing = 0;
+const defaultTextLineHeight = 1.2;
+
+const defaultTextBackground = {
+ enabled: false,
+ color: "#000000",
+ cornerRadius: 0,
+ paddingX: 30,
+ paddingY: 42,
+ offsetX: 0,
+ offsetY: 0,
+};
+
+const defaultTextElement: Omit<TextElement, "id"> = {
+ type: "text",
+ name: "Text",
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ background: { ...defaultTextBackground },
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...defaultTransform,
+ position: { ...defaultTransform.position },
+ },
+ opacity: defaultOpacity,
+};
+
+const defaultTimelineViewState: TTimelineViewState = {
+ zoomLevel: 1,
+ scrollLeft: 0,
+ playheadTime: 0,
+};
+
+export const DEFAULTS = {
+ element: {
+ transform: defaultTransform,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
+ volume: defaultVolume,
+ },
+ text: {
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ background: defaultTextBackground,
+ element: defaultTextElement,
+ },
+ timeline: {
+ viewState: defaultTimelineViewState,
+ },
+};
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts index 43d896ae..cf59a20d 100644 --- a/apps/web/src/lib/timeline/drag-utils.ts +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -1,19 +1,19 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export function getMouseTimeFromClientX({ - clientX, - containerRect, - zoomLevel, - scrollLeft, -}: { - clientX: number; - containerRect: DOMRect; - zoomLevel: number; - scrollLeft: number; -}): number { - const mouseX = clientX - containerRect.left + scrollLeft; - return Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ); -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ return Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ );
+}
diff --git a/apps/web/src/lib/timeline/drag.ts b/apps/web/src/lib/timeline/drag.ts index 89b1593f..6e74e17c 100644 --- a/apps/web/src/lib/timeline/drag.ts +++ b/apps/web/src/lib/timeline/drag.ts @@ -1,42 +1,42 @@ -import type { MaskableElement, VisualElement } from "./types"; -import type { ParamValues } from "@/lib/params"; - -interface BaseDragData { - id: string; - name: string; -} - -export interface MediaDragData extends BaseDragData { - type: "media"; - mediaType: "image" | "video" | "audio"; - targetElementTypes?: MaskableElement["type"][]; -} - -export interface TextDragData extends BaseDragData { - type: "text"; - content: string; -} - -export interface StickerDragData extends BaseDragData { - type: "sticker"; - stickerId: string; -} - -export interface GraphicDragData extends BaseDragData { - type: "graphic"; - definitionId: string; - params: Partial<ParamValues>; -} - -export interface EffectDragData extends BaseDragData { - type: "effect"; - effectType: string; - targetElementTypes: VisualElement["type"][]; -} - -export type TimelineDragData = - | MediaDragData - | TextDragData - | StickerDragData - | GraphicDragData - | EffectDragData; +import type { MaskableElement, VisualElement } from "./types";
+import type { ParamValues } from "@/lib/params";
+
+interface BaseDragData {
+ id: string;
+ name: string;
+}
+
+export interface MediaDragData extends BaseDragData {
+ type: "media";
+ mediaType: "image" | "video" | "audio";
+ targetElementTypes?: MaskableElement["type"][];
+}
+
+export interface TextDragData extends BaseDragData {
+ type: "text";
+ content: string;
+}
+
+export interface StickerDragData extends BaseDragData {
+ type: "sticker";
+ stickerId: string;
+}
+
+export interface GraphicDragData extends BaseDragData {
+ type: "graphic";
+ definitionId: string;
+ params: Partial<ParamValues>;
+}
+
+export interface EffectDragData extends BaseDragData {
+ type: "effect";
+ effectType: string;
+ targetElementTypes: VisualElement["type"][];
+}
+
+export type TimelineDragData =
+ | MediaDragData
+ | TextDragData
+ | StickerDragData
+ | GraphicDragData
+ | EffectDragData;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index d8206474..cba8c65e 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,314 +1,314 @@ -import type { - TimelineTrack, - ElementType, - TimelineElement, -} from "@/lib/timeline"; -import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; -import { wouldElementOverlap } from "./element-utils"; -import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; -import { - canElementGoOnTrack, - isMainTrack, - enforceMainTrackStart, -} from "./track-utils"; - -function findElementAtPosition({ - mouseX, - tracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, -}: { - mouseX: number; - tracks: TimelineTrack[]; - trackIndex: number; - targetElementTypes: string[]; - pixelsPerSecond: number; - zoomLevel: number; -}): { elementId: string; trackId: string } | null { - const time = mouseX / (pixelsPerSecond * zoomLevel); - const track = tracks[trackIndex]; - if (!track || !("elements" in track)) return null; - - const hit = track.elements.find( - (element: TimelineElement) => - targetElementTypes.includes(element.type) && - element.startTime <= time && - time < element.startTime + element.duration, - ); - if (!hit) return null; - return { elementId: hit.id, trackId: track.id }; -} - -function getTrackAtY({ - mouseY, - tracks, - verticalDragDirection, -}: { - mouseY: number; - tracks: TimelineTrack[]; - verticalDragDirection?: "up" | "down" | null; -}): { trackIndex: number; relativeY: number } | null { - let cumulativeHeight = 0; - - for (let i = 0; i < tracks.length; i++) { - const trackHeight = TRACK_CONFIG[tracks[i].type].height; - const trackTop = cumulativeHeight; - const trackBottom = trackTop + trackHeight; - - if (mouseY >= trackTop && mouseY < trackBottom) { - return { - trackIndex: i, - relativeY: mouseY - trackTop, - }; - } - - if (i < tracks.length - 1 && verticalDragDirection) { - const gapTop = trackBottom; - const gapBottom = gapTop + TRACK_GAP; - if (mouseY >= gapTop && mouseY < gapBottom) { - const isDraggingUp = verticalDragDirection === "up"; - return { - trackIndex: isDraggingUp ? i : i + 1, - relativeY: isDraggingUp ? trackHeight - 1 : 0, - }; - } - } - - cumulativeHeight += trackHeight + TRACK_GAP; - } - - return null; -} - -function isCompatible({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TimelineTrack["type"]; -}): boolean { - return canElementGoOnTrack({ elementType, trackType }); -} - -function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number { - return tracks.findIndex((track) => isMainTrack(track)); -} - -function findInsertIndex({ - elementType, - tracks, - preferredIndex, - insertAbove, -}: { - elementType: ElementType; - tracks: TimelineTrack[]; - preferredIndex: number; - insertAbove: boolean; -}): { index: number; position: "above" | "below" } { - const mainTrackIndex = getMainTrackIndex({ tracks }); - - if (elementType === "audio") { - if (preferredIndex <= mainTrackIndex) { - return { index: mainTrackIndex + 1, position: "below" }; - } - return { - index: insertAbove ? preferredIndex : preferredIndex + 1, - position: insertAbove ? "above" : "below", - }; - } - - const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1; - - if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) { - return { index: mainTrackIndex, position: "above" }; - } - - return { - index: overlayInsertIndex, - position: insertAbove ? "above" : "below", - }; -} - -const EMPTY_TARGET_ELEMENT = null; - -export function computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks, - playheadTime, - isExternalDrop, - elementDuration, - pixelsPerSecond, - zoomLevel, - verticalDragDirection, - startTimeOverride, - excludeElementId, - targetElementTypes, -}: ComputeDropTargetParams): DropTarget { - const xPosition = - typeof startTimeOverride === "number" - ? startTimeOverride - : isExternalDrop - ? playheadTime - : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); - - const mainTrackIndex = getMainTrackIndex({ tracks }); - - if (tracks.length === 0) { - if (elementType === "audio") { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: null, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); - - if (!trackAtMouse) { - const isAboveAllTracks = mouseY < 0; - - if (elementType === "audio") { - return { - trackIndex: tracks.length, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - if (isAboveAllTracks) { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: "above", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - return { - trackIndex: Math.max(0, mainTrackIndex), - isNewTrack: true, - insertPosition: "above", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - const { trackIndex, relativeY } = trackAtMouse; - const track = tracks[trackIndex]; - - if ( - targetElementTypes && - targetElementTypes.length > 0 - ) { - const targetElement = findElementAtPosition({ - mouseX, - tracks, - trackIndex, - targetElementTypes, - pixelsPerSecond, - zoomLevel, - }); - if (targetElement) { - return { - trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition, - targetElement, - }; - } - } - - const trackHeight = TRACK_CONFIG[track.type].height; - const isInUpperHalf = relativeY < trackHeight / 2; - - const isTrackCompatible = isCompatible({ - elementType, - trackType: track.type, - }); - - const endTime = xPosition + elementDuration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: xPosition, - endTime, - excludeElementId, - }); - - if (isTrackCompatible && !hasOverlap) { - const targetTrack = tracks[trackIndex]; - // safe: snap to 0 only happens when element becomes the new earliest, - // meaning the space before the current earliest is empty - const adjustedXPosition = enforceMainTrackStart({ - tracks, - targetTrackId: targetTrack.id, - requestedStartTime: xPosition, - excludeElementId, - }); - - return { - trackIndex, - isNewTrack: false, - insertPosition: null, - xPosition: adjustedXPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - let insertAbove = isInUpperHalf; - if (!isTrackCompatible && verticalDragDirection) { - insertAbove = verticalDragDirection === "up"; - } - - const { index, position } = findInsertIndex({ - elementType, - tracks, - preferredIndex: trackIndex, - insertAbove, - }); - - return { - trackIndex: index, - isNewTrack: true, - insertPosition: position, - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; -} - -export function getDropLineY({ - dropTarget, - tracks, -}: { - dropTarget: DropTarget; - tracks: TimelineTrack[]; -}): number { - const safeTrackIndex = Math.min( - Math.max(dropTarget.trackIndex, 0), - tracks.length, - ); - let y = 0; - - for (let i = 0; i < safeTrackIndex; i++) { - y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP; - } - - return y; -} +import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/lib/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
+import { wouldElementOverlap } from "./element-utils";
+import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
+import {
+ canElementGoOnTrack,
+ isMainTrack,
+ enforceMainTrackStart,
+} from "./track-utils";
+
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
+function getTrackAtY({
+ mouseY,
+ tracks,
+ verticalDragDirection,
+}: {
+ mouseY: number;
+ tracks: TimelineTrack[];
+ verticalDragDirection?: "up" | "down" | null;
+}): { trackIndex: number; relativeY: number } | null {
+ let cumulativeHeight = 0;
+
+ for (let i = 0; i < tracks.length; i++) {
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
+ const trackTop = cumulativeHeight;
+ const trackBottom = trackTop + trackHeight;
+
+ if (mouseY >= trackTop && mouseY < trackBottom) {
+ return {
+ trackIndex: i,
+ relativeY: mouseY - trackTop,
+ };
+ }
+
+ if (i < tracks.length - 1 && verticalDragDirection) {
+ const gapTop = trackBottom;
+ const gapBottom = gapTop + TRACK_GAP;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TRACK_GAP;
+ }
+
+ return null;
+}
+
+function isCompatible({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TimelineTrack["type"];
+}): boolean {
+ return canElementGoOnTrack({ elementType, trackType });
+}
+
+function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
+ return tracks.findIndex((track) => isMainTrack(track));
+}
+
+function findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex,
+ insertAbove,
+}: {
+ elementType: ElementType;
+ tracks: TimelineTrack[];
+ preferredIndex: number;
+ insertAbove: boolean;
+}): { index: number; position: "above" | "below" } {
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (elementType === "audio") {
+ if (preferredIndex <= mainTrackIndex) {
+ return { index: mainTrackIndex + 1, position: "below" };
+ }
+ return {
+ index: insertAbove ? preferredIndex : preferredIndex + 1,
+ position: insertAbove ? "above" : "below",
+ };
+ }
+
+ const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
+
+ if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
+ return { index: mainTrackIndex, position: "above" };
+ }
+
+ return {
+ index: overlayInsertIndex,
+ position: insertAbove ? "above" : "below",
+ };
+}
+
+const EMPTY_TARGET_ELEMENT = null;
+
+export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+ targetElementTypes,
+}: ComputeDropTargetParams): DropTarget {
+ const xPosition =
+ typeof startTimeOverride === "number"
+ ? startTimeOverride
+ : isExternalDrop
+ ? playheadTime
+ : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
+
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (tracks.length === 0) {
+ if (elementType === "audio") {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
+
+ if (!trackAtMouse) {
+ const isAboveAllTracks = mouseY < 0;
+
+ if (elementType === "audio") {
+ return {
+ trackIndex: tracks.length,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ if (isAboveAllTracks) {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ return {
+ trackIndex: Math.max(0, mainTrackIndex),
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const { trackIndex, relativeY } = trackAtMouse;
+ const track = tracks[trackIndex];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
+ const isInUpperHalf = relativeY < trackHeight / 2;
+
+ const isTrackCompatible = isCompatible({
+ elementType,
+ trackType: track.type,
+ });
+
+ const endTime = xPosition + elementDuration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: xPosition,
+ endTime,
+ excludeElementId,
+ });
+
+ if (isTrackCompatible && !hasOverlap) {
+ const targetTrack = tracks[trackIndex];
+ // safe: snap to 0 only happens when element becomes the new earliest,
+ // meaning the space before the current earliest is empty
+ const adjustedXPosition = enforceMainTrackStart({
+ tracks,
+ targetTrackId: targetTrack.id,
+ requestedStartTime: xPosition,
+ excludeElementId,
+ });
+
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ let insertAbove = isInUpperHalf;
+ if (!isTrackCompatible && verticalDragDirection) {
+ insertAbove = verticalDragDirection === "up";
+ }
+
+ const { index, position } = findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex: trackIndex,
+ insertAbove,
+ });
+
+ return {
+ trackIndex: index,
+ isNewTrack: true,
+ insertPosition: position,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+}
+
+export function getDropLineY({
+ dropTarget,
+ tracks,
+}: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
+}): number {
+ const safeTrackIndex = Math.min(
+ Math.max(dropTarget.trackIndex, 0),
+ tracks.length,
+ );
+ let y = 0;
+
+ for (let i = 0; i < safeTrackIndex; i++) {
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index c44ae044..19b821be 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,435 +1,435 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { - MASKABLE_ELEMENT_TYPES, - RETIMABLE_ELEMENT_TYPES, - VISUAL_ELEMENT_TYPES, - type CreateEffectElement, - type CreateGraphicElement, - type CreateTimelineElement, - type CreateVideoElement, - type CreateImageElement, - type CreateStickerElement, - type CreateUploadAudioElement, - type CreateLibraryAudioElement, - type TextBackground, - type TextElement, - type TimelineElement, - type TimelineTrack, - type AudioElement, - type VideoElement, - type ImageElement, - type MaskableElement, - type RetimableElement, - type VisualElement, - type UploadAudioElement, -} from "@/lib/timeline"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import type { MediaType } from "@/lib/media/types"; -import { buildDefaultEffectInstance } from "@/lib/effects"; -import { buildDefaultGraphicInstance } from "@/lib/graphics"; -import type { ParamValues } from "@/lib/params"; -import { capitalizeFirstLetter } from "@/utils/string"; - -export function canElementHaveAudio( - element: TimelineElement, -): element is AudioElement | VideoElement { - return element.type === "audio" || element.type === "video"; -} - -export function isVisualElement( - element: TimelineElement, -): element is VisualElement { - return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function isMaskableElement( - element: TimelineElement, -): element is MaskableElement { - return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function isRetimableElement( - element: TimelineElement, -): element is RetimableElement { - return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function canElementBeHidden( - element: TimelineElement, -): element is VisualElement { - return isVisualElement(element); -} - -export function hasElementEffects({ - element, -}: { - element: TimelineElement; -}): boolean { - return isVisualElement(element) && (element.effects?.length ?? 0) > 0; -} - -export function hasMediaId( - element: TimelineElement, -): element is UploadAudioElement | VideoElement | ImageElement { - return "mediaId" in element; -} - -export function requiresMediaId({ - element, -}: { - element: CreateTimelineElement; -}): boolean { - return ( - element.type === "video" || - element.type === "image" || - (element.type === "audio" && element.sourceType === "upload") - ); -} - -export function wouldElementOverlap({ - elements, - startTime, - endTime, - excludeElementId, -}: { - elements: TimelineElement[]; - startTime: number; - endTime: number; - excludeElementId?: string; -}): boolean { - return elements.some((element) => { - if (excludeElementId && element.id === excludeElementId) return false; - const elementEnd = element.startTime + element.duration; - return startTime < elementEnd && endTime > element.startTime; - }); -} - -function buildTextBackground( - raw: Partial<TextBackground> | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; -} - -export function buildTextElement({ - raw, - startTime, -}: { - raw: Partial<Omit<TextElement, "type" | "id">>; - startTime: number; -}): CreateTimelineElement { - const t = raw as Partial<TextElement>; - - return { - type: "text", - name: t.name ?? DEFAULTS.text.element.name, - content: t.content ?? DEFAULTS.text.element.content, - duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, - fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, - color: t.color ?? DEFAULTS.text.element.color, - background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, - fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, - fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, - transform: t.transform ?? DEFAULTS.text.element.transform, - opacity: t.opacity ?? DEFAULTS.text.element.opacity, - blendMode: t.blendMode ?? DEFAULTS.element.blendMode, - }; -} - -export function buildEffectElement({ - effectType, - startTime, - duration, -}: { - effectType: string; - startTime: number; - duration?: number; -}): CreateEffectElement { - const instance = buildDefaultEffectInstance({ effectType }); - return { - type: "effect", - name: capitalizeFirstLetter({ string: instance.type }), - effectType, - params: instance.params, - duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - }; -} - -export function buildStickerElement({ - stickerId, - name, - startTime, - intrinsicWidth, - intrinsicHeight, -}: { - stickerId: string; - name?: string; - startTime: number; - intrinsicWidth?: number; - intrinsicHeight?: number; -}): CreateStickerElement { - const stickerNameFromId = - stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; - return { - type: "sticker", - name: name ?? stickerNameFromId, - stickerId, - intrinsicWidth, - intrinsicHeight, - duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -export function buildGraphicElement({ - definitionId, - name, - startTime, - params, -}: { - definitionId: string; - name?: string; - startTime: number; - params?: Partial<ParamValues>; -}): CreateGraphicElement { - const instance = buildDefaultGraphicInstance({ definitionId }); - return { - type: "graphic", - name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), - definitionId: instance.definitionId, - params: { ...instance.params, ...(params ?? {}) } as ParamValues, - duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildVideoElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateVideoElement { - return { - type: "video", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - muted: false, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, - }; -} - -function buildImageElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateImageElement { - return { - type: "image", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateUploadAudioElement { - const element: CreateUploadAudioElement = { - type: "audio", - sourceType: "upload", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function buildElementFromMedia({ - mediaId, - mediaType, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - mediaType: MediaType; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateTimelineElement { - switch (mediaType) { - case "audio": - return buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, - }); - case "video": - return buildVideoElement({ mediaId, name, duration, startTime }); - case "image": - return buildImageElement({ mediaId, name, duration, startTime }); - } -} - -export function buildLibraryAudioElement({ - sourceUrl, - name, - duration, - startTime, - buffer, -}: { - sourceUrl: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateLibraryAudioElement { - const element: CreateLibraryAudioElement = { - type: "audio", - sourceType: "library", - sourceUrl, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function getElementsAtTime({ - tracks, - time, -}: { - tracks: TimelineTrack[]; - time: number; -}): { trackId: string; elementId: string }[] { - const result: { trackId: string; elementId: string }[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - const elementStart = element.startTime; - const elementEnd = element.startTime + element.duration; - - if (time > elementStart && time < elementEnd) { - result.push({ trackId: track.id, elementId: element.id }); - } - } - } - - return result; -} - -export function getElementFontFamilies({ - tracks, -}: { - tracks: TimelineTrack[]; -}): string[] { - const families = new Set<string>(); - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); - } - } - } - return [...families]; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ MASKABLE_ELEMENT_TYPES,
+ RETIMABLE_ELEMENT_TYPES,
+ VISUAL_ELEMENT_TYPES,
+ type CreateEffectElement,
+ type CreateGraphicElement,
+ type CreateTimelineElement,
+ type CreateVideoElement,
+ type CreateImageElement,
+ type CreateStickerElement,
+ type CreateUploadAudioElement,
+ type CreateLibraryAudioElement,
+ type TextBackground,
+ type TextElement,
+ type TimelineElement,
+ type TimelineTrack,
+ type AudioElement,
+ type VideoElement,
+ type ImageElement,
+ type MaskableElement,
+ type RetimableElement,
+ type VisualElement,
+ type UploadAudioElement,
+} from "@/lib/timeline";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { MediaType } from "@/lib/media/types";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { buildDefaultGraphicInstance } from "@/lib/graphics";
+import type { ParamValues } from "@/lib/params";
+import { capitalizeFirstLetter } from "@/utils/string";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function isVisualElement(
+ element: TimelineElement,
+): element is VisualElement {
+ return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isMaskableElement(
+ element: TimelineElement,
+): element is MaskableElement {
+ return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isRetimableElement(
+ element: TimelineElement,
+): element is RetimableElement {
+ return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VisualElement {
+ return isVisualElement(element);
+}
+
+export function hasElementEffects({
+ element,
+}: {
+ element: TimelineElement;
+}): boolean {
+ return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
+}
+
+export function hasMediaId(
+ element: TimelineElement,
+): element is UploadAudioElement | VideoElement | ImageElement {
+ return "mediaId" in element;
+}
+
+export function requiresMediaId({
+ element,
+}: {
+ element: CreateTimelineElement;
+}): boolean {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ (element.type === "audio" && element.sourceType === "upload")
+ );
+}
+
+export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((element) => {
+ if (excludeElementId && element.id === excludeElementId) return false;
+ const elementEnd = element.startTime + element.duration;
+ return startTime < elementEnd && endTime > element.startTime;
+ });
+}
+
+function buildTextBackground(
+ raw: Partial<TextBackground> | undefined,
+): TextBackground {
+ const color = raw?.color ?? DEFAULTS.text.element.background.color;
+ const enabled = raw?.enabled ?? color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
+export function buildTextElement({
+ raw,
+ startTime,
+}: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
+}): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
+
+ return {
+ type: "text",
+ name: t.name ?? DEFAULTS.text.element.name,
+ content: t.content ?? DEFAULTS.text.element.content,
+ duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
+ color: t.color ?? DEFAULTS.text.element.color,
+ background: buildTextBackground(t.background),
+ textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
+ letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
+ lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
+ transform: t.transform ?? DEFAULTS.text.element.transform,
+ opacity: t.opacity ?? DEFAULTS.text.element.opacity,
+ blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
+export function buildStickerElement({
+ stickerId,
+ name,
+ startTime,
+ intrinsicWidth,
+ intrinsicHeight,
+}: {
+ stickerId: string;
+ name?: string;
+ startTime: number;
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+}): CreateStickerElement {
+ const stickerNameFromId =
+ stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
+ return {
+ type: "sticker",
+ name: name ?? stickerNameFromId,
+ stickerId,
+ intrinsicWidth,
+ intrinsicHeight,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildGraphicElement({
+ definitionId,
+ name,
+ startTime,
+ params,
+}: {
+ definitionId: string;
+ name?: string;
+ startTime: number;
+ params?: Partial<ParamValues>;
+}): CreateGraphicElement {
+ const instance = buildDefaultGraphicInstance({ definitionId });
+ return {
+ type: "graphic",
+ name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
+ definitionId: instance.definitionId,
+ params: { ...instance.params, ...(params ?? {}) } as ParamValues,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateVideoElement {
+ return {
+ type: "video",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ muted: false,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ volume: DEFAULTS.element.volume,
+ };
+}
+
+function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateImageElement {
+ return {
+ type: "image",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateUploadAudioElement {
+ const element: CreateUploadAudioElement = {
+ type: "audio",
+ sourceType: "upload",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateTimelineElement {
+ switch (mediaType) {
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ });
+ case "video":
+ return buildVideoElement({ mediaId, name, duration, startTime });
+ case "image":
+ return buildImageElement({ mediaId, name, duration, startTime });
+ }
+}
+
+export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateLibraryAudioElement {
+ const element: CreateLibraryAudioElement = {
+ type: "audio",
+ sourceType: "library",
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: TimelineTrack[];
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
+
+export function getElementFontFamilies({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): string[] {
+ const families = new Set<string>();
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "text" && element.fontFamily) {
+ families.add(element.fontFamily);
+ }
+ }
+ }
+ return [...families];
+}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 29414722..d4337833 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,28 +1,28 @@ -import type { TimelineTrack } from "./types"; - -export * from "./types"; -export * from "./drag"; -export * from "./track-utils"; -export * from "./track-element-update"; -export * from "./element-utils"; -export * from "./zoom-utils"; -export * from "./ruler-utils"; -export * from "./ripple-utils"; -export * from "./pixel-utils"; - -export function calculateTotalDuration({ - tracks, -}: { - tracks: TimelineTrack[]; -}): number { - if (tracks.length === 0) return 0; - - const trackEndTimes = tracks.map((track) => - track.elements.reduce((maxEnd, element) => { - const elementEnd = element.startTime + element.duration; - return Math.max(maxEnd, elementEnd); - }, 0), - ); - - return Math.max(...trackEndTimes, 0); -} +import type { TimelineTrack } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-utils";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./ripple-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): number {
+ if (tracks.length === 0) return 0;
+
+ const trackEndTimes = tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd = element.startTime + element.duration;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
+
+ return Math.max(...trackEndTimes, 0);
+}
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts index 4c0c243c..c4635e52 100644 --- a/apps/web/src/lib/timeline/pixel-utils.ts +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -1,79 +1,79 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; - -function getDevicePixelRatio({ - devicePixelRatio, -}: { - devicePixelRatio?: number; -}): number { - if ( - typeof devicePixelRatio === "number" && - Number.isFinite(devicePixelRatio) && - devicePixelRatio > 0 - ) { - return devicePixelRatio; - } - - if (typeof window === "undefined") { - return 1; - } - - if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { - return window.devicePixelRatio; - } - - return 1; -} - -export function getTimelinePixelsPerSecond({ - zoomLevel, -}: { - zoomLevel: number; -}): number { - return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; -} - -export function timelineTimeToPixels({ - time, - zoomLevel, -}: { - time: number; - zoomLevel: number; -}): number { - return time * getTimelinePixelsPerSecond({ zoomLevel }); -} - -export function snapPixelToDeviceGrid({ - pixel, - devicePixelRatio, -}: { - pixel: number; - devicePixelRatio?: number; -}): number { - const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); - return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; -} - -export function timelineTimeToSnappedPixels({ - time, - zoomLevel, - devicePixelRatio, -}: { - time: number; - zoomLevel: number; - devicePixelRatio?: number; -}): number { - const rawPixel = timelineTimeToPixels({ time, zoomLevel }); - return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); -} - -export function getCenteredLineLeft({ - centerPixel, - lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, -}: { - centerPixel: number; - lineWidthPx?: number; -}): number { - return centerPixel - lineWidthPx / 2; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
+
+function getDevicePixelRatio({
+ devicePixelRatio,
+}: {
+ devicePixelRatio?: number;
+}): number {
+ if (
+ typeof devicePixelRatio === "number" &&
+ Number.isFinite(devicePixelRatio) &&
+ devicePixelRatio > 0
+ ) {
+ return devicePixelRatio;
+ }
+
+ if (typeof window === "undefined") {
+ return 1;
+ }
+
+ if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
+ return window.devicePixelRatio;
+ }
+
+ return 1;
+}
+
+export function getTimelinePixelsPerSecond({
+ zoomLevel,
+}: {
+ zoomLevel: number;
+}): number {
+ return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+}
+
+export function timelineTimeToPixels({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}): number {
+ return time * getTimelinePixelsPerSecond({ zoomLevel });
+}
+
+export function snapPixelToDeviceGrid({
+ pixel,
+ devicePixelRatio,
+}: {
+ pixel: number;
+ devicePixelRatio?: number;
+}): number {
+ const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
+ return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
+}
+
+export function timelineTimeToSnappedPixels({
+ time,
+ zoomLevel,
+ devicePixelRatio,
+}: {
+ time: number;
+ zoomLevel: number;
+ devicePixelRatio?: number;
+}): number {
+ const rawPixel = timelineTimeToPixels({ time, zoomLevel });
+ return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
+}
+
+export function getCenteredLineLeft({
+ centerPixel,
+ lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
+}: {
+ centerPixel: number;
+ lineWidthPx?: number;
+}): number {
+ return centerPixel - lineWidthPx / 2;
+}
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts index 369759f3..5e17e9a3 100644 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -1,17 +1,17 @@ -import type { TimelineElement } from "@/lib/timeline"; - -export function rippleShiftElements({ - elements, - afterTime, - shiftAmount, -}: { - elements: TimelineElement[]; - afterTime: number; - shiftAmount: number; -}): TimelineElement[] { - return elements.map((element) => - element.startTime >= afterTime - ? { ...element, startTime: element.startTime - shiftAmount } - : element, - ); -} +import type { TimelineElement } from "@/lib/timeline";
+
+export function rippleShiftElements({
+ elements,
+ afterTime,
+ shiftAmount,
+}: {
+ elements: TimelineElement[];
+ afterTime: number;
+ shiftAmount: number;
+}): TimelineElement[] {
+ return elements.map((element) =>
+ element.startTime >= afterTime
+ ? { ...element, startTime: element.startTime - shiftAmount }
+ : element,
+ );
+}
diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts index ac527600..3c0f597e 100644 --- a/apps/web/src/lib/timeline/selection-hit-testing.ts +++ b/apps/web/src/lib/timeline/selection-hit-testing.ts @@ -1,133 +1,133 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; -import type { TimelineTrack } from "@/lib/timeline"; - -type TimelineElementRef = { trackId: string; elementId: string }; - -interface SelectionRectangle { - left: number; - top: number; - right: number; - bottom: number; -} - -function getNormalizedRectangle({ - startPos, - endPos, -}: { - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - return { - left: Math.min(startPos.x, endPos.x), - top: Math.min(startPos.y, endPos.y), - right: Math.max(startPos.x, endPos.x), - bottom: Math.max(startPos.y, endPos.y), - }; -} - -function getSelectionRectangleInContent({ - container, - scrollContainer, - startPos, - endPos, -}: { - container: HTMLElement; - scrollContainer: HTMLDivElement | null; - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - const containerRect = container.getBoundingClientRect(); - const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - - const adjustedStart = { - x: startPos.x - containerRect.left + scrollLeft, - y: startPos.y - scrollRect.top + scrollTop, - }; - const adjustedEnd = { - x: endPos.x - containerRect.left + scrollLeft, - y: endPos.y - scrollRect.top + scrollTop, - }; - - return getNormalizedRectangle({ - startPos: adjustedStart, - endPos: adjustedEnd, - }); -} - -function isRectangleIntersecting({ - elementRectangle, - selectionRectangle, -}: { - elementRectangle: SelectionRectangle; - selectionRectangle: SelectionRectangle; -}): boolean { - return !( - elementRectangle.right < selectionRectangle.left || - elementRectangle.left > selectionRectangle.right || - elementRectangle.bottom < selectionRectangle.top || - elementRectangle.top > selectionRectangle.bottom - ); -} - -export function resolveTimelineElementIntersections({ - container, - scrollContainer, - tracks, - zoomLevel, - startPos, - currentPos, -}: { - container: HTMLElement; - scrollContainer: HTMLDivElement | null; - tracks: TimelineTrack[]; - zoomLevel: number; - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; -}): TimelineElementRef[] { - const selectionRectangle = getSelectionRectangleInContent({ - container, - scrollContainer, - startPos, - endPos: currentPos, - }); - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const selectedElements: TimelineElementRef[] = []; - - for (const [trackIndex, track] of tracks.entries()) { - const trackTop = getCumulativeHeightBefore({ - tracks, - trackIndex, - }); - const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; - const elementBottom = elementTop + trackHeight; - - for (const element of track.elements) { - const elementLeft = element.startTime * pixelsPerSecond; - const elementRight = elementLeft + element.duration * pixelsPerSecond; - const elementRectangle = { - left: elementLeft, - top: elementTop, - right: elementRight, - bottom: elementBottom, - }; - - if ( - isRectangleIntersecting({ - elementRectangle, - selectionRectangle, - }) - ) { - selectedElements.push({ - trackId: track.id, - elementId: element.id, - }); - } - } - } - - return selectedElements; -} +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
+
+type TimelineElementRef = { trackId: string; elementId: string };
+
+interface SelectionRectangle {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function getNormalizedRectangle({
+ startPos,
+ endPos,
+}: {
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ return {
+ left: Math.min(startPos.x, endPos.x),
+ top: Math.min(startPos.y, endPos.y),
+ right: Math.max(startPos.x, endPos.x),
+ bottom: Math.max(startPos.y, endPos.y),
+ };
+}
+
+function getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ const containerRect = container.getBoundingClientRect();
+ const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+
+ const adjustedStart = {
+ x: startPos.x - containerRect.left + scrollLeft,
+ y: startPos.y - scrollRect.top + scrollTop,
+ };
+ const adjustedEnd = {
+ x: endPos.x - containerRect.left + scrollLeft,
+ y: endPos.y - scrollRect.top + scrollTop,
+ };
+
+ return getNormalizedRectangle({
+ startPos: adjustedStart,
+ endPos: adjustedEnd,
+ });
+}
+
+function isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+}: {
+ elementRectangle: SelectionRectangle;
+ selectionRectangle: SelectionRectangle;
+}): boolean {
+ return !(
+ elementRectangle.right < selectionRectangle.left ||
+ elementRectangle.left > selectionRectangle.right ||
+ elementRectangle.bottom < selectionRectangle.top ||
+ elementRectangle.top > selectionRectangle.bottom
+ );
+}
+
+export function resolveTimelineElementIntersections({
+ container,
+ scrollContainer,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ tracks: TimelineTrack[];
+ zoomLevel: number;
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}): TimelineElementRef[] {
+ const selectionRectangle = getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos: currentPos,
+ });
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const selectedElements: TimelineElementRef[] = [];
+
+ for (const [trackIndex, track] of tracks.entries()) {
+ const trackTop = getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ });
+ const trackHeight = getTrackHeight({ type: track.type });
+ const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
+ const elementBottom = elementTop + trackHeight;
+
+ for (const element of track.elements) {
+ const elementLeft = element.startTime * pixelsPerSecond;
+ const elementRight = elementLeft + element.duration * pixelsPerSecond;
+ const elementRectangle = {
+ left: elementLeft,
+ top: elementTop,
+ right: elementRight,
+ bottom: elementBottom,
+ };
+
+ if (
+ isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+ })
+ ) {
+ selectedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ return selectedElements;
+}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index ce2e9b1f..2efe24e4 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,172 +1,172 @@ -import type { Bookmark, TimelineTrack } from "@/lib/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { getElementKeyframes } from "@/lib/animation"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -const DEFAULT_SNAP_THRESHOLD_PX = 10; - -export function findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks = [], - excludeBookmarkTime, - enableElementSnapping = true, - enablePlayheadSnapping = true, - enableBookmarkSnapping = true, - enableKeyframeSnapping = true, -}: { - tracks: Array<TimelineTrack>; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array<Bookmark>; - excludeBookmarkTime?: number; - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; - enableBookmarkSnapping?: boolean; - enableKeyframeSnapping?: boolean; -}): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; - - if (enableElementSnapping) { - snapPoints.push( - { - time: element.startTime, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: element.startTime + element.duration, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - } - - if (enableKeyframeSnapping) { - for (const keyframe of getElementKeyframes({ - animations: element.animations, - })) { - snapPoints.push({ - time: element.startTime + keyframe.time, - type: "keyframe", - elementId: element.id, - trackId: track.id, - }); - } - } - } - } - - if (enablePlayheadSnapping) { - snapPoints.push({ time: playheadTime, type: "playhead" }); - } - - if (enableBookmarkSnapping) { - for (const bookmark of bookmarks) { - if ( - excludeBookmarkTime != null && - Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON - ) { - continue; - } - snapPoints.push({ time: bookmark.time, type: "bookmark" }); - } - } - - return snapPoints; -} - -export function snapToNearestPoint({ - targetTime, - snapPoints, - zoomLevel, - snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, -}: { - targetTime: number; - snapPoints: Array<SnapPoint>; - zoomLevel: number; - snapThreshold?: number; -}): SnapResult { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - for (const snapPoint of snapPoints) { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - } - - return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; -} - -export function snapElementEdge({ - targetTime, - elementDuration, - tracks, - playheadTime, - zoomLevel, - excludeElementId, - snapToStart = true, - bookmarks = [], -}: { - targetTime: number; - elementDuration: number; - tracks: Array<TimelineTrack>; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array<Bookmark>; -}): SnapResult { - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks, - }); - - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - - const snapResult = snapToNearestPoint({ - targetTime: effectiveTargetTime, - snapPoints, - zoomLevel, - }); - - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; -} +import type { Bookmark, TimelineTrack } from "@/lib/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
+import { getElementKeyframes } from "@/lib/animation";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+const DEFAULT_SNAP_THRESHOLD_PX = 10;
+
+export function findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
+}: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+
+ if (enableKeyframeSnapping) {
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({ time: playheadTime, type: "playhead" });
+ }
+
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({ time: bookmark.time, type: "bookmark" });
+ }
+ }
+
+ return snapPoints;
+}
+
+export function snapToNearestPoint({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
+}: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ snapThreshold?: number;
+}): SnapResult {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
+
+export function snapElementEdge({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ bookmarks = [],
+}: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
+}): SnapResult {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 5230d4e7..19765923 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,33 +1,33 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; - -export function updateElementInTracks({ - tracks, - trackId, - elementId, - update, - elementPredicate, -}: { - tracks: TimelineTrack[]; - trackId: string; - elementId: string; - update: (element: TimelineElement) => TimelineElement; - elementPredicate?: (element: TimelineElement) => boolean; -}): TimelineTrack[] { - return tracks.map((track) => { - if (track.id !== trackId) { - return track; - } - - const nextElements = track.elements.map((element) => { - if (element.id !== elementId) { - return element; - } - if (elementPredicate && !elementPredicate(element)) { - return element; - } - return update(element); - }); - - return { ...track, elements: nextElements } as TimelineTrack; - }); -} +import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+
+export function updateElementInTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: TimelineTrack[];
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): TimelineTrack[] {
+ return tracks.map((track) => {
+ if (track.id !== trackId) {
+ return track;
+ }
+
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
+ }
+ return update(element);
+ });
+
+ return { ...track, elements: nextElements } as TimelineTrack;
+ });
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index cde2c814..0034c72e 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -1,297 +1,297 @@ -import type { - TrackType, - TimelineTrack, - ElementType, - VideoTrack, - AudioTrack, - GraphicTrack, - TextTrack, - EffectTrack, - TimelineElement, -} from "@/lib/timeline"; -import { - ELEMENT_TRACK_MAP, - TRACK_CONFIG, - ELEMENT_TYPE_CONFIG, - TRACK_GAP, -} from "@/constants/timeline-constants"; -import { generateUUID } from "@/utils/id"; - -export function canTracktHaveAudio( - track: TimelineTrack, -): track is VideoTrack | AudioTrack { - return track.type === "audio" || track.type === "video"; -} - -export function canTrackBeHidden( - track: TimelineTrack, -): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { - return track.type !== "audio"; -} - -export function getElementClasses({ type }: { type: TrackType }) { - return ELEMENT_TYPE_CONFIG[type].background.trim(); -} - -export function getTrackHeight({ type }: { type: TrackType }): number { - return TRACK_CONFIG[type].height; -} - -export function getCumulativeHeightBefore({ - tracks, - trackIndex, -}: { - tracks: Array<{ type: TrackType }>; - trackIndex: number; -}): number { - return tracks - .slice(0, trackIndex) - .reduce( - (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP, - 0, - ); -} - -export function getTotalTracksHeight({ - tracks, -}: { - tracks: Array<{ type: TrackType }>; -}): number { - const tracksHeight = tracks.reduce( - (sum, track) => sum + getTrackHeight({ type: track.type }), - 0, - ); - const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP; - return tracksHeight + gapsHeight; -} - -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: TrackType; - name?: string; -}): TimelineTrack { - const trackName = name ?? TRACK_CONFIG[type].defaultName; - - switch (type) { - case "video": - return { - id, - name: trackName, - type: "video", - elements: [], - hidden: false, - muted: false, - isMain: false, - }; - case "text": - return { - id, - name: trackName, - type: "text", - elements: [], - hidden: false, - }; - case "graphic": - return { - id, - name: trackName, - type: "graphic", - elements: [], - hidden: false, - }; - case "audio": - return { - id, - name: trackName, - type: "audio", - elements: [], - muted: false, - }; - case "effect": - return { - id, - name: trackName, - type: "effect", - elements: [], - hidden: false, - }; - default: - throw new Error(`Unsupported track type: ${type}`); - } -} - -export function getDefaultInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - if (trackType === "audio") { - return tracks.length; - } - - if (trackType === "effect") { - return 0; - } - - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex( - (track) => track.type === "audio", - ); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; -} - -export function getHighestInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - - if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; - } - - return 0; -} - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - const hasMainTrack = tracks.some((track) => isMainTrack(track)); - - if (!hasMainTrack) { - const mainTrack: TimelineTrack = { - id: generateUUID(), - name: "Main Track", - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }; - return [mainTrack, ...tracks]; - } - - return tracks; -} - -export function canElementGoOnTrack({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TrackType; -}): boolean { - return ELEMENT_TRACK_MAP[elementType] === trackType; -} - -export function validateElementTrackCompatibility({ - element, - track, -}: { - element: { type: ElementType }; - track: { type: TrackType }; -}): { isValid: boolean; errorMessage?: string } { - const isValid = canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }); - - if (!isValid) { - return { - isValid: false, - errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`, - }; - } - - return { isValid: true }; -} - -export function getEarliestMainTrackElement({ - tracks, - excludeElementId, -}: { - tracks: TimelineTrack[]; - excludeElementId?: string; -}): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - - const elements = mainTrack.elements.filter( - (element) => !excludeElementId || element.id !== excludeElementId, - ); - - if (elements.length === 0) { - return null; - } - - return elements.reduce((earliest, element) => - element.startTime < earliest.startTime ? element : earliest, - ); -} - -export function enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime, - excludeElementId, -}: { - tracks: TimelineTrack[]; - targetTrackId: string; - requestedStartTime: number; - excludeElementId?: string; -}): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { - return requestedStartTime; - } - - const earliestElement = getEarliestMainTrackElement({ - tracks, - excludeElementId, - }); - - if (!earliestElement) { - return 0; - } - - // main track must always start at time 0; if this element would - // become the earliest, pin it to the start - if (requestedStartTime <= earliestElement.startTime) { - return 0; - } - - return requestedStartTime; -} +import type {
+ TrackType,
+ TimelineTrack,
+ ElementType,
+ VideoTrack,
+ AudioTrack,
+ GraphicTrack,
+ TextTrack,
+ EffectTrack,
+ TimelineElement,
+} from "@/lib/timeline";
+import {
+ ELEMENT_TRACK_MAP,
+ TRACK_CONFIG,
+ ELEMENT_TYPE_CONFIG,
+ TRACK_GAP,
+} from "@/constants/timeline-constants";
+import { generateUUID } from "@/utils/id";
+
+export function canTracktHaveAudio(
+ track: TimelineTrack,
+): track is VideoTrack | AudioTrack {
+ return track.type === "audio" || track.type === "video";
+}
+
+export function canTrackBeHidden(
+ track: TimelineTrack,
+): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
+ return track.type !== "audio";
+}
+
+export function getElementClasses({ type }: { type: TrackType }) {
+ return ELEMENT_TYPE_CONFIG[type].background.trim();
+}
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TRACK_CONFIG[type].height;
+}
+
+export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
+}): number {
+ return tracks
+ .slice(0, trackIndex)
+ .reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
+ 0,
+ );
+}
+
+export function getTotalTracksHeight({
+ tracks,
+}: {
+ tracks: Array<{ type: TrackType }>;
+}): number {
+ const tracksHeight = tracks.reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }),
+ 0,
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
+ return tracksHeight + gapsHeight;
+}
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack {
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
+
+ switch (type) {
+ case "video":
+ return {
+ id,
+ name: trackName,
+ type: "video",
+ elements: [],
+ hidden: false,
+ muted: false,
+ isMain: false,
+ };
+ case "text":
+ return {
+ id,
+ name: trackName,
+ type: "text",
+ elements: [],
+ hidden: false,
+ };
+ case "graphic":
+ return {
+ id,
+ name: trackName,
+ type: "graphic",
+ elements: [],
+ hidden: false,
+ };
+ case "audio":
+ return {
+ id,
+ name: trackName,
+ type: "audio",
+ elements: [],
+ muted: false,
+ };
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
+ default:
+ throw new Error(`Unsupported track type: ${type}`);
+ }
+}
+
+export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.length;
+ }
+
+ if (trackType === "effect") {
+ return 0;
+ }
+
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+ if (mainTrackIndex >= 0) {
+ return mainTrackIndex;
+ }
+
+ const firstAudioTrackIndex = tracks.findIndex(
+ (track) => track.type === "audio",
+ );
+ if (firstAudioTrackIndex >= 0) {
+ return firstAudioTrackIndex;
+ }
+
+ return tracks.length;
+}
+
+export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+
+ if (trackType === "audio") {
+ return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
+ }
+
+ return 0;
+}
+
+export function isMainTrack(track: TimelineTrack): track is VideoTrack {
+ return track.type === "video" && track.isMain === true;
+}
+
+export function getMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack | null {
+ return tracks.find((track) => isMainTrack(track)) ?? null;
+}
+
+export function ensureMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack[] {
+ const hasMainTrack = tracks.some((track) => isMainTrack(track));
+
+ if (!hasMainTrack) {
+ const mainTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Main Track",
+ type: "video",
+ elements: [],
+ muted: false,
+ isMain: true,
+ hidden: false,
+ };
+ return [mainTrack, ...tracks];
+ }
+
+ return tracks;
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ return ELEMENT_TRACK_MAP[elementType] === trackType;
+}
+
+export function validateElementTrackCompatibility({
+ element,
+ track,
+}: {
+ element: { type: ElementType };
+ track: { type: TrackType };
+}): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ });
+
+ if (!isValid) {
+ return {
+ isValid: false,
+ errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+export function getEarliestMainTrackElement({
+ tracks,
+ excludeElementId,
+}: {
+ tracks: TimelineTrack[];
+ excludeElementId?: string;
+}): TimelineElement | null {
+ const mainTrack = getMainTrack({ tracks });
+ if (!mainTrack) {
+ return null;
+ }
+
+ const elements = mainTrack.elements.filter(
+ (element) => !excludeElementId || element.id !== excludeElementId,
+ );
+
+ if (elements.length === 0) {
+ return null;
+ }
+
+ return elements.reduce((earliest, element) =>
+ element.startTime < earliest.startTime ? element : earliest,
+ );
+}
+
+export function enforceMainTrackStart({
+ tracks,
+ targetTrackId,
+ requestedStartTime,
+ excludeElementId,
+}: {
+ tracks: TimelineTrack[];
+ targetTrackId: string;
+ requestedStartTime: number;
+ excludeElementId?: string;
+}): number {
+ const mainTrack = getMainTrack({ tracks });
+ if (!mainTrack || mainTrack.id !== targetTrackId) {
+ return requestedStartTime;
+ }
+
+ const earliestElement = getEarliestMainTrackElement({
+ tracks,
+ excludeElementId,
+ });
+
+ if (!earliestElement) {
+ return 0;
+ }
+
+ // main track must always start at time 0; if this element would
+ // become the earliest, pin it to the start
+ if (requestedStartTime <= earliestElement.startTime) {
+ return 0;
+ }
+
+ return requestedStartTime;
+}
diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index 16e06bf1..69d363c7 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -1,304 +1,304 @@ -import type { ElementAnimations } from "@/lib/animation/types"; -import type { Effect } from "@/lib/effects/types"; -import type { Mask } from "@/lib/masks/types"; -import type { ParamValues } from "@/lib/params"; -import type { BlendMode, Transform } from "@/lib/rendering"; - -export type ElementRef = { - trackId: string; - elementId: string; -}; - -export interface Bookmark { - time: number; - note?: string; - color?: string; - duration?: number; -} - -export interface TScene { - id: string; - name: string; - isMain: boolean; - tracks: TimelineTrack[]; - bookmarks: Bookmark[]; - createdAt: Date; - updatedAt: Date; -} - -export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; - -interface BaseTrack { - id: string; - name: string; -} - -export interface VideoTrack extends BaseTrack { - type: "video"; - elements: (VideoElement | ImageElement)[]; - isMain: boolean; - muted: boolean; - hidden: boolean; -} - -export interface TextTrack extends BaseTrack { - type: "text"; - elements: TextElement[]; - hidden: boolean; -} - -export interface AudioTrack extends BaseTrack { - type: "audio"; - elements: AudioElement[]; - muted: boolean; -} - -export interface GraphicTrack extends BaseTrack { - type: "graphic"; - elements: (StickerElement | GraphicElement)[]; - hidden: boolean; -} - -export interface EffectTrack extends BaseTrack { - type: "effect"; - elements: EffectElement[]; - hidden: boolean; -} - -export type TimelineTrack = - | VideoTrack - | TextTrack - | AudioTrack - | GraphicTrack - | EffectTrack; - -export interface RetimeConfig { - rate: number; - maintainPitch?: boolean; -} - -interface BaseAudioElement extends BaseTimelineElement { - type: "audio"; - volume: number; - muted?: boolean; - buffer?: AudioBuffer; - retime?: RetimeConfig; -} - -export interface UploadAudioElement extends BaseAudioElement { - sourceType: "upload"; - mediaId: string; -} - -export interface LibraryAudioElement extends BaseAudioElement { - sourceType: "library"; - sourceUrl: string; -} - -export type AudioElement = UploadAudioElement | LibraryAudioElement; - -interface BaseTimelineElement { - id: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; - animations?: ElementAnimations; -} - -export interface VideoElement extends BaseTimelineElement { - type: "video"; - mediaId: string; - volume?: number; - muted?: boolean; - hidden?: boolean; - retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface ImageElement extends BaseTimelineElement { - type: "image"; - mediaId: string; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface TextBackground { - enabled: boolean; - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; -} - -export interface TextElement extends BaseTimelineElement { - type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface StickerElement extends BaseTimelineElement { - type: "sticker"; - stickerId: string; - /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ - intrinsicWidth?: number; - intrinsicHeight?: number; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; -} - -export interface GraphicElement extends BaseTimelineElement { - type: "graphic"; - definitionId: string; - params: ParamValues; - hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; - effects?: Effect[]; - masks?: Mask[]; -} - -export interface EffectElement extends BaseTimelineElement { - type: "effect"; - effectType: string; - params: ParamValues; -} - -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; - -export type TimelineElement = - | AudioElement - | VideoElement - | ImageElement - | TextElement - | StickerElement - | GraphicElement - | EffectElement; - -export type ElementType = TimelineElement["type"]; - -function elementTypes<T extends ElementType[]>(...types: T): T { - return types; -} - -export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); - -export type MaskableElement = Extract< - TimelineElement, - { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } ->; - -export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); - -export type RetimableElement = Extract< - TimelineElement, - { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } ->; - -export const VISUAL_ELEMENT_TYPES = elementTypes( - "video", - "image", - "text", - "sticker", - "graphic", -); - -export type VisualElement = Extract< - TimelineElement, - { type: (typeof VISUAL_ELEMENT_TYPES)[number] } ->; - -export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">; -export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">; -export type CreateAudioElement = - | CreateUploadAudioElement - | CreateLibraryAudioElement; -export type CreateVideoElement = Omit<VideoElement, "id">; -export type CreateImageElement = Omit<ImageElement, "id">; -export type CreateTextElement = Omit<TextElement, "id">; -export type CreateStickerElement = Omit<StickerElement, "id">; -export type CreateGraphicElement = Omit<GraphicElement, "id">; -export type CreateEffectElement = Omit<EffectElement, "id">; -export type CreateTimelineElement = - | CreateAudioElement - | CreateVideoElement - | CreateImageElement - | CreateTextElement - | CreateStickerElement - | CreateGraphicElement - | CreateEffectElement; - -export interface ElementDragState { - isDragging: boolean; - elementId: string | null; - trackId: string | null; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - currentMouseY: number; -} - -export interface DropTarget { - trackIndex: number; - isNewTrack: boolean; - insertPosition: "above" | "below" | null; - xPosition: number; - targetElement: { elementId: string; trackId: string } | null; -} - -export interface ComputeDropTargetParams { - elementType: ElementType; - mouseX: number; - mouseY: number; - tracks: TimelineTrack[]; - playheadTime: number; - isExternalDrop: boolean; - elementDuration: number; - pixelsPerSecond: number; - zoomLevel: number; - verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; - excludeElementId?: string; - targetElementTypes?: string[]; -} - -export interface ClipboardItem { - trackId: string; - trackType: TrackType; - element: CreateTimelineElement; -} +import type { ElementAnimations } from "@/lib/animation/types";
+import type { Effect } from "@/lib/effects/types";
+import type { Mask } from "@/lib/masks/types";
+import type { ParamValues } from "@/lib/params";
+import type { BlendMode, Transform } from "@/lib/rendering";
+
+export type ElementRef = {
+ trackId: string;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: number;
+ note?: string;
+ color?: string;
+ duration?: number;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: TimelineTrack[];
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ isMain: boolean;
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
+}
+
+export interface VideoElement extends BaseTimelineElement {
+ type: "video";
+ mediaId: string;
+ volume?: number;
+ muted?: boolean;
+ hidden?: boolean;
+ retime?: RetimeConfig;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface ImageElement extends BaseTimelineElement {
+ type: "image";
+ mediaId: string;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: TextBackground;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes<T extends ElementType[]>(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | CreateGraphicElement
+ | CreateEffectElement;
+
+export interface ElementDragState {
+ isDragging: boolean;
+ elementId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ currentMouseY: number;
+}
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: TimelineTrack[];
+ playheadTime: number;
+ isExternalDrop: boolean;
+ elementDuration: number;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: number;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
diff --git a/apps/web/src/lib/transcription/caption.ts b/apps/web/src/lib/transcription/caption.ts index c7c1b7b4..6f73ddec 100644 --- a/apps/web/src/lib/transcription/caption.ts +++ b/apps/web/src/lib/transcription/caption.ts @@ -1,49 +1,49 @@ -import type { TranscriptionSegment, CaptionChunk } from "@/lib/transcription/types"; -import { - DEFAULT_WORDS_PER_CAPTION, - MIN_CAPTION_DURATION_SECONDS, -} from "@/constants/transcription-constants"; - -export function buildCaptionChunks({ - segments, - wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, - minDuration = MIN_CAPTION_DURATION_SECONDS, -}: { - segments: TranscriptionSegment[]; - wordsPerChunk?: number; - minDuration?: number; -}): CaptionChunk[] { - const captions: CaptionChunk[] = []; - let globalEndTime = 0; - - for (const segment of segments) { - const words = segment.text.trim().split(/\s+/); - if (words.length === 0 || (words.length === 1 && words[0] === "")) continue; - - const segmentDuration = segment.end - segment.start; - const wordsPerSecond = words.length / segmentDuration; - - const chunks: string[] = []; - for (let i = 0; i < words.length; i += wordsPerChunk) { - chunks.push(words.slice(i, i + wordsPerChunk).join(" ")); - } - - let chunkStartTime = segment.start; - for (const chunk of chunks) { - const chunkWords = chunk.split(/\s+/).length; - const chunkDuration = Math.max(minDuration, chunkWords / wordsPerSecond); - const adjustedStartTime = Math.max(chunkStartTime, globalEndTime); - - captions.push({ - text: chunk, - startTime: adjustedStartTime, - duration: chunkDuration, - }); - - globalEndTime = adjustedStartTime + chunkDuration; - chunkStartTime += chunkDuration; - } - } - - return captions; -} +import type { TranscriptionSegment, CaptionChunk } from "@/lib/transcription/types";
+import {
+ DEFAULT_WORDS_PER_CAPTION,
+ MIN_CAPTION_DURATION_SECONDS,
+} from "@/constants/transcription-constants";
+
+export function buildCaptionChunks({
+ segments,
+ wordsPerChunk = DEFAULT_WORDS_PER_CAPTION,
+ minDuration = MIN_CAPTION_DURATION_SECONDS,
+}: {
+ segments: TranscriptionSegment[];
+ wordsPerChunk?: number;
+ minDuration?: number;
+}): CaptionChunk[] {
+ const captions: CaptionChunk[] = [];
+ let globalEndTime = 0;
+
+ for (const segment of segments) {
+ const words = segment.text.trim().split(/\s+/);
+ if (words.length === 0 || (words.length === 1 && words[0] === "")) continue;
+
+ const segmentDuration = segment.end - segment.start;
+ const wordsPerSecond = words.length / segmentDuration;
+
+ const chunks: string[] = [];
+ for (let i = 0; i < words.length; i += wordsPerChunk) {
+ chunks.push(words.slice(i, i + wordsPerChunk).join(" "));
+ }
+
+ let chunkStartTime = segment.start;
+ for (const chunk of chunks) {
+ const chunkWords = chunk.split(/\s+/).length;
+ const chunkDuration = Math.max(minDuration, chunkWords / wordsPerSecond);
+ const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
+
+ captions.push({
+ text: chunk,
+ startTime: adjustedStartTime,
+ duration: chunkDuration,
+ });
+
+ globalEndTime = adjustedStartTime + chunkDuration;
+ chunkStartTime += chunkDuration;
+ }
+ }
+
+ return captions;
+}
diff --git a/apps/web/src/lib/transcription/types.ts b/apps/web/src/lib/transcription/types.ts index 9724ad70..c4a87aa8 100644 --- a/apps/web/src/lib/transcription/types.ts +++ b/apps/web/src/lib/transcription/types.ts @@ -1,47 +1,47 @@ -import type { LanguageCode } from "@/constants/language-constants"; - -export type TranscriptionLanguage = LanguageCode | "auto"; - -export interface TranscriptionSegment { - text: string; - start: number; - end: number; -} - -export interface TranscriptionResult { - text: string; - segments: TranscriptionSegment[]; - language: string; -} - -export type TranscriptionStatus = - | "idle" - | "loading-model" - | "transcribing" - | "complete" - | "error"; - -export interface TranscriptionProgress { - status: TranscriptionStatus; - progress: number; - message?: string; -} - -export type TranscriptionModelId = - | "whisper-tiny" - | "whisper-small" - | "whisper-medium" - | "whisper-large-v3-turbo"; - -export interface TranscriptionModel { - id: TranscriptionModelId; - name: string; - huggingFaceId: string; - description: string; -} - -export interface CaptionChunk { - text: string; - startTime: number; - duration: number; -} +import type { LanguageCode } from "@/constants/language-constants";
+
+export type TranscriptionLanguage = LanguageCode | "auto";
+
+export interface TranscriptionSegment {
+ text: string;
+ start: number;
+ end: number;
+}
+
+export interface TranscriptionResult {
+ text: string;
+ segments: TranscriptionSegment[];
+ language: string;
+}
+
+export type TranscriptionStatus =
+ | "idle"
+ | "loading-model"
+ | "transcribing"
+ | "complete"
+ | "error";
+
+export interface TranscriptionProgress {
+ status: TranscriptionStatus;
+ progress: number;
+ message?: string;
+}
+
+export type TranscriptionModelId =
+ | "whisper-tiny"
+ | "whisper-small"
+ | "whisper-medium"
+ | "whisper-large-v3-turbo";
+
+export interface TranscriptionModel {
+ id: TranscriptionModelId;
+ name: string;
+ huggingFaceId: string;
+ description: string;
+}
+
+export interface CaptionChunk {
+ text: string;
+ startTime: number;
+ duration: number;
+}
|
