From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/actions/definitions.ts | 410 +-- apps/web/src/lib/actions/keybinding.ts | 158 +- apps/web/src/lib/actions/types.ts | 88 +- apps/web/src/lib/animation/color-channel.ts | 38 +- .../web/src/lib/animation/graphic-param-channel.ts | 154 +- apps/web/src/lib/animation/index.ts | 142 +- apps/web/src/lib/animation/keyframe-query.ts | 144 +- apps/web/src/lib/animation/number-channel.ts | 40 +- apps/web/src/lib/animation/property-groups.ts | 78 +- apps/web/src/lib/animation/property-registry.ts | 738 ++-- apps/web/src/lib/animation/resolve.ts | 270 +- apps/web/src/lib/animation/target-resolver.ts | 554 +-- apps/web/src/lib/animation/types.ts | 238 +- apps/web/src/lib/animation/vector-channel.ts | 144 +- apps/web/src/lib/auth/client.ts | 12 +- apps/web/src/lib/auth/server.ts | 86 +- apps/web/src/lib/blog/query.ts | 256 +- apps/web/src/lib/blog/types.ts | 184 +- apps/web/src/lib/cancel-interaction.ts | 48 +- apps/web/src/lib/commands/media/add-media-asset.ts | 34 + .../src/lib/commands/media/remove-media-asset.ts | 198 +- apps/web/src/lib/commands/preview-tracker.ts | 46 +- .../commands/project/update-project-settings.ts | 92 +- apps/web/src/lib/commands/scene/create-scene.ts | 80 +- apps/web/src/lib/commands/scene/delete-scene.ts | 118 +- apps/web/src/lib/commands/scene/index.ts | 14 +- apps/web/src/lib/commands/scene/move-bookmark.ts | 118 +- apps/web/src/lib/commands/scene/remove-bookmark.ts | 118 +- apps/web/src/lib/commands/scene/rename-scene.ts | 92 +- apps/web/src/lib/commands/scene/toggle-bookmark.ts | 104 +- apps/web/src/lib/commands/scene/update-bookmark.ts | 110 +- .../src/lib/commands/timeline/clipboard/paste.ts | 586 ++-- .../commands/timeline/element/delete-elements.ts | 158 +- .../timeline/element/duplicate-elements.ts | 256 +- .../timeline/element/effects/add-effect.ts | 148 +- .../lib/commands/timeline/element/effects/index.ts | 10 +- .../timeline/element/effects/remove-effect.ts | 130 +- .../timeline/element/effects/reorder-effect.ts | 146 +- .../timeline/element/effects/toggle-effect.ts | 134 +- .../web/src/lib/commands/timeline/element/index.ts | 30 +- .../commands/timeline/element/insert-element.ts | 716 ++-- .../commands/timeline/element/keyframes/index.ts | 10 +- .../keyframes/remove-effect-param-keyframe.ts | 136 +- .../timeline/element/keyframes/remove-keyframe.ts | 276 +- .../timeline/element/keyframes/retime-keyframe.ts | 148 +- .../keyframes/upsert-effect-param-keyframe.ts | 168 +- .../timeline/element/keyframes/upsert-keyframe.ts | 190 +- .../lib/commands/timeline/element/masks/index.ts | 4 +- .../commands/timeline/element/masks/remove-mask.ts | 128 +- .../timeline/element/masks/toggle-mask-inverted.ts | 150 +- .../lib/commands/timeline/element/move-elements.ts | 322 +- .../lib/commands/timeline/element/retime/index.ts | 2 +- .../element/retime/update-element-retime.ts | 228 +- .../timeline/element/toggle-elements-muted.ts | 114 +- .../timeline/element/toggle-elements-visibility.ts | 94 +- .../timeline/element/update-element-duration.ts | 114 +- .../timeline/element/update-element-start-time.ts | 138 +- .../commands/timeline/element/update-element.ts | 94 +- apps/web/src/lib/commands/timeline/index.ts | 10 +- .../src/lib/commands/timeline/track/add-track.ts | 106 +- .../lib/commands/timeline/track/remove-track.ts | 70 +- .../commands/timeline/track/toggle-track-mute.ts | 78 +- .../timeline/track/toggle-track-visibility.ts | 80 +- .../src/lib/commands/timeline/tracks-snapshot.ts | 40 +- apps/web/src/lib/drag-data.ts | 96 +- apps/web/src/lib/effects/effect.vert.glsl | 14 +- apps/web/src/lib/effects/registry.ts | 20 +- apps/web/src/lib/effects/types.ts | 84 +- apps/web/src/lib/fonts/types.ts | 48 +- apps/web/src/lib/gradients/parser.ts | 1304 +++---- apps/web/src/lib/graphics/definitions/ellipse.ts | 146 +- apps/web/src/lib/graphics/definitions/index.ts | 58 +- apps/web/src/lib/graphics/definitions/polygon.ts | 422 +-- apps/web/src/lib/graphics/definitions/rectangle.ts | 170 +- apps/web/src/lib/graphics/definitions/shared.ts | 32 +- apps/web/src/lib/graphics/definitions/star.ts | 218 +- apps/web/src/lib/graphics/index.ts | 250 +- apps/web/src/lib/graphics/registry.ts | 20 +- apps/web/src/lib/graphics/stroke.ts | 204 +- apps/web/src/lib/graphics/types.ts | 46 +- apps/web/src/lib/guides/definitions/custom.tsx | 48 +- apps/web/src/lib/guides/definitions/platforms.tsx | 130 +- .../src/lib/guides/definitions/tiktok-layout.tsx | 670 ++-- apps/web/src/lib/guides/index.ts | 26 +- apps/web/src/lib/guides/registry.tsx | 56 +- apps/web/src/lib/guides/types.ts | 40 +- apps/web/src/lib/masks/__tests__/snap.test.ts | 470 +-- apps/web/src/lib/masks/definitions/box-like.ts | 540 +-- apps/web/src/lib/masks/definitions/ellipse.ts | 144 +- apps/web/src/lib/masks/definitions/index.ts | 78 +- apps/web/src/lib/masks/definitions/rectangle.ts | 188 +- apps/web/src/lib/masks/definitions/split.ts | 694 ++-- apps/web/src/lib/masks/geometry.ts | 224 +- apps/web/src/lib/masks/handle-positions.ts | 764 ++-- apps/web/src/lib/masks/index.ts | 36 +- apps/web/src/lib/masks/param-update.ts | 118 +- .../src/lib/masks/shaders/jfa-distance.frag.glsl | 72 +- apps/web/src/lib/masks/snap.ts | 670 ++-- apps/web/src/lib/masks/types.ts | 230 +- apps/web/src/lib/masks/utils.ts | 128 +- apps/web/src/lib/media/audio-mastering.ts | 206 +- apps/web/src/lib/media/media-utils.ts | 64 +- apps/web/src/lib/media/mediabunny.ts | 316 +- apps/web/src/lib/media/types.ts | 18 +- apps/web/src/lib/media/upload-toast.ts | 102 +- apps/web/src/lib/params.ts | 100 +- apps/web/src/lib/preview/element-bounds.ts | 654 ++-- apps/web/src/lib/preview/preview-snap.ts | 1078 +++--- apps/web/src/lib/project/__tests__/fps.test.ts | 65 + apps/web/src/lib/project/fps.ts | 39 + apps/web/src/lib/project/types.ts | 106 +- apps/web/src/lib/rate-limit.ts | 42 +- apps/web/src/lib/registry.ts | 80 +- apps/web/src/lib/rendering.ts | 56 +- apps/web/src/lib/retime/__tests__/resolve.test.ts | 122 +- apps/web/src/lib/retime/__tests__/split.test.ts | 62 +- apps/web/src/lib/retime/audio-stretch.ts | 364 +- apps/web/src/lib/retime/index.ts | 8 +- apps/web/src/lib/retime/presets.ts | 24 +- apps/web/src/lib/retime/resolve.ts | 96 +- apps/web/src/lib/retime/split.ts | 68 +- apps/web/src/lib/scenes.ts | 206 +- apps/web/src/lib/selection/constants.ts | 2 +- apps/web/src/lib/selection/context.tsx | 104 +- apps/web/src/lib/selection/hit-testing.ts | 100 +- apps/web/src/lib/selection/hooks/use-box-select.ts | 340 +- .../src/lib/selection/hooks/use-selection-scope.ts | 56 +- apps/web/src/lib/selection/index.ts | 8 +- apps/web/src/lib/selection/scope.ts | 58 +- apps/web/src/lib/selection/selectable-item.tsx | 214 +- apps/web/src/lib/selection/selectable-surface.tsx | 576 ++-- apps/web/src/lib/selection/selection-box.tsx | 100 +- apps/web/src/lib/selection/state.ts | 330 +- apps/web/src/lib/selection/types.ts | 78 +- apps/web/src/lib/shaders/jfa-init.frag.glsl | 50 +- apps/web/src/lib/shaders/jfa-step.frag.glsl | 120 +- apps/web/src/lib/sounds/types.ts | 78 +- .../src/lib/stickers/__tests__/sticker-id.test.ts | 68 +- apps/web/src/lib/stickers/index.ts | 634 ++-- .../src/lib/stickers/providers/countries-data.ts | 3638 ++++++++++---------- apps/web/src/lib/stickers/providers/index.ts | 48 +- apps/web/src/lib/stickers/providers/logos.ts | 62 +- apps/web/src/lib/stickers/sticker-id.ts | 68 +- apps/web/src/lib/text/measure-element.ts | 264 +- apps/web/src/lib/time.ts | 3 +- apps/web/src/lib/timeline/audio-constants.ts | 4 +- apps/web/src/lib/timeline/audio-state.ts | 200 +- apps/web/src/lib/timeline/bookmarks.ts | 298 +- apps/web/src/lib/timeline/defaults.ts | 154 +- apps/web/src/lib/timeline/drag-utils.ts | 38 +- apps/web/src/lib/timeline/drag.ts | 84 +- apps/web/src/lib/timeline/drop-utils.ts | 628 ++-- apps/web/src/lib/timeline/element-utils.ts | 870 ++--- apps/web/src/lib/timeline/index.ts | 56 +- apps/web/src/lib/timeline/pixel-utils.ts | 158 +- apps/web/src/lib/timeline/ripple-utils.ts | 34 +- apps/web/src/lib/timeline/selection-hit-testing.ts | 266 +- apps/web/src/lib/timeline/snap-utils.ts | 344 +- apps/web/src/lib/timeline/track-element-update.ts | 66 +- apps/web/src/lib/timeline/track-utils.ts | 594 ++-- apps/web/src/lib/timeline/types.ts | 608 ++-- apps/web/src/lib/transcription/caption.ts | 98 +- apps/web/src/lib/transcription/types.ts | 94 +- 163 files changed, 16993 insertions(+), 16854 deletions(-) create mode 100644 apps/web/src/lib/project/__tests__/fps.test.ts create mode 100644 apps/web/src/lib/project/fps.ts (limited to 'apps/web/src/lib') 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; -} - -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; - -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>; - -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record -> = 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; +} + +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; + +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>; + +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< + Record +> = 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 = { - [K in keyof T]: undefined extends T[K] ? K : never; -}[keyof T]; - -export type TActionWithArgs = keyof TActionArgsMap; - -export type TActionWithOptionalArgs = - | TActionWithNoArgs - | TKeysWithValueUndefined; - -export type TActionWithNoArgs = Exclude; - -export type TArgOfAction = A extends TActionWithArgs - ? TActionArgsMap[A] - : undefined; - -export type TActionFunc = A extends TActionWithArgs - ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void - : (_?: undefined, trigger?: TInvocationTrigger) => void; - -export type TInvocationTrigger = "keypress" | "mouseclick"; - -export type TBoundActionList = { - [A in TAction]?: Array>; -}; - -export type TActionHandlerOptions = - | MutableRefObject - | 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 = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | 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>; - -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 {} - -export interface ColorKeyframe - extends BaseAnimationKeyframe {} - -export interface DiscreteKeyframe - extends BaseAnimationKeyframe {} - -export interface VectorKeyframe - extends BaseAnimationKeyframe {} - -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>; + +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 {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe {} + +export interface VectorKeyframe + extends BaseAnimationKeyframe {} + +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({ - endpoint, - fallback, -}: { - endpoint: string; - fallback: T; -}): Promise { - 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({ - endpoint: "posts", - fallback: EMPTY_POSTS, - }); -} - -export async function getTags() { - return fetchFromMarble({ - endpoint: "tags", - fallback: EMPTY_TAGS, - }); -} - -export async function getSinglePost({ slug }: { slug: string }) { - return fetchFromMarble({ - endpoint: `posts/${slug}`, - fallback: null, - }); -} - -export async function getCategories() { - return fetchFromMarble({ - endpoint: "categories", - fallback: EMPTY_CATEGORIES, - }); -} - -export async function getAuthors() { - return fetchFromMarble({ - endpoint: "authors", - fallback: EMPTY_AUTHORS, - }); -} - -export async function processHtmlContent({ - html, -}: { - html: string; -}): Promise { - 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({ + endpoint, + fallback, +}: { + endpoint: string; + fallback: T; +}): Promise { + 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({ + endpoint: "posts", + fallback: EMPTY_POSTS, + }); +} + +export async function getTags() { + return fetchFromMarble({ + endpoint: "tags", + fallback: EMPTY_TAGS, + }); +} + +export async function getSinglePost({ slug }: { slug: string }) { + return fetchFromMarble({ + endpoint: `posts/${slug}`, + fallback: null, + }); +} + +export async function getCategories() { + return fetchFromMarble({ + endpoint: "categories", + fallback: EMPTY_CATEGORIES, + }); +} + +export async function getAuthors() { + return fetchFromMarble({ + endpoint: "authors", + fallback: EMPTY_AUTHORS, + }); +} + +export async function processHtmlContent({ + html, +}: { + html: string; +}): Promise { + 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(); - -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(); + +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 { - 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 { + 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) { - 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) { + 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>, - ) { - 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>, + ) { + 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 { - const groupedItems = new Map(); - - 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 { + const groupedItems = new Map(); + + 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 => 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 => 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 = (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 = (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; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial; - }) { - 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; + + constructor({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial; + }) { + 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 { - 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 { + 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; -} - -export interface WebGLEffectPass { - fragmentShader: string; - uniforms(params: { - effectParams: ParamValues; - width: number; - height: number; - }): Record; -} - -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; +} + +export interface WebGLEffectPass { + fragmentShader: string; + uniforms(params: { + effectParams: ParamValues; + width: number; + height: number; + }): Record; +} + +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; -} +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; +} 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; - -export type Color = - | { type: "hex"; value: string } - | { type: "literal"; value: string } - | { type: "rgb"; value: Array } - | { type: "rgba"; value: Array } - | { 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; -}; - -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 => { - const ast = matchListDefinitions(); - - if (input.length > 0) { - error({ message: "Invalid input not EOF" }); - } - - return ast; -}; - -const matchListDefinitions = (): Array => - 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 = ({ - 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 - | undefined => { - const radialOrientation = matchRadialOrientation(); - if (!radialOrientation) { - return undefined; - } - - const radialOrientations: Array = [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 = ({ - matcher, -}: { - matcher: () => T | undefined; -}): Array => { - const captures = matcher(); - const result: Array = []; - - 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 = ({ - 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 => { - 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; + +export type Color = + | { type: "hex"; value: string } + | { type: "literal"; value: string } + | { type: "rgb"; value: Array } + | { type: "rgba"; value: Array } + | { 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; +}; + +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 => { + const ast = matchListDefinitions(); + + if (input.length > 0) { + error({ message: "Invalid input not EOF" }); + } + + return ast; +}; + +const matchListDefinitions = (): Array => + 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 = ({ + 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 + | undefined => { + const radialOrientation = matchRadialOrientation(); + if (!radialOrientation) { + return undefined; + } + + const radialOrientations: Array = [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 = ({ + matcher, +}: { + matcher: () => T | undefined; +}): Array => { + const captures = matcher(); + const result: Array = []; + + 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 = ({ + 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 => { + 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[] = [ - { - 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[] = [ + { + 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[] = [ - { - 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[] = [ + { + 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[] = [ - { - 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[] = [ + { + 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[] = [ - { - 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[] = [ + { + 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(); - -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 = ` - - - ${name} - - `; - 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(); + +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 = ` + + + ${name} + + `; + 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 { - constructor() { - super("graphic"); - } -} - -export const graphicsRegistry = new GraphicsRegistry(); +import { DefinitionRegistry } from "@/lib/registry"; +import type { GraphicDefinition } from "./types"; + +export class GraphicsRegistry extends DefinitionRegistry { + 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 ( -
- -
- ); -} - -export const customGuide = { - id: "custom", - label: "Custom", - renderPreview: () => , - renderTriggerIcon: () => , - renderOverlay: () => null, - renderOptions: () => , -} 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 ( +
+ +
+ ); +} + +export const customGuide = { + id: "custom", + label: "Custom", + renderPreview: () => , + renderTriggerIcon: () => , + renderOverlay: () => null, + renderOptions: () => , +} 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 ( - - ); -} - -function PlatformGuidePreview({ domain }: { domain: string }) { - return ; -} - -function platformGuide({ - id, - label, - domain, -}: { - id: string; - label: string; - domain: string; -}): GuideDefinition { - return { - id, - label, - renderPreview: () => , - renderTriggerIcon: () => , - renderOverlay: () => null, - }; -} - -export const tiktokGuide: GuideDefinition = { - ...platformGuide({ id: "tiktok", label: "TikTok", domain: "tiktok.com" }), - renderOverlay: () => , -}; -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 ( + + ); +} + +function PlatformGuidePreview({ domain }: { domain: string }) { + return ; +} + +function platformGuide({ + id, + label, + domain, +}: { + id: string; + label: string; + domain: string; +}): GuideDefinition { + return { + id, + label, + renderPreview: () => , + renderTriggerIcon: () => , + renderOverlay: () => null, + }; +} + +export const tiktokGuide: GuideDefinition = { + ...platformGuide({ id: "tiktok", label: "TikTok", domain: "tiktok.com" }), + renderOverlay: () => , +}; +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 ( - - TikTok Layout - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} +export function TikTokLayout() { + return ( + + TikTok Layout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} 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 { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - rotation: 0, - ...overrides, - }; -} - -function buildRectangleParams( - overrides: Partial = {}, -): 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 { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial = {}, +): 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): 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): 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): 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): 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 = { - 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 = { + 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({ - definition, - icon, -}: { - definition: MaskDefinition; - 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({ + definition, + icon, +}: { + definition: MaskDefinition; + 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 = { - 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 = { + 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): 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 = { - 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): 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 = { + 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 { - 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 { + 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 = { - 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 = { + 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 { - 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): 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 { + 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): 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 { - handleId: string; - startParams: TParams; - deltaX: number; - deltaY: number; - startCanvasX: number; - startCanvasY: number; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -} - -export interface MaskDefinition { - type: MaskType; - name: string; - overlayShape: MaskOverlayShape; - features: MaskFeatures; - params: ParamDefinition[]; - renderer: MaskRenderer; - buildOverlayPath?: (params: { width: number; height: number }) => string; - buildDefault(context: MaskDefaultContext): Omit; - computeParamUpdate(args: MaskParamUpdateArgs): 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 { + handleId: string; + startParams: TParams; + deltaX: number; + deltaY: number; + startCanvasX: number; + startCanvasY: number; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; +} + +export interface MaskDefinition { + type: MaskType; + name: string; + overlayShape: MaskOverlayShape; + features: MaskFeatures; + params: ParamDefinition[]; + renderer: MaskRenderer; + buildOverlayPath?: (params: { width: number; height: number }) => string; + buildDefault(context: MaskDefaultContext): Omit; + computeParamUpdate(args: MaskParamUpdateArgs): 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 { - 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 { + 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 => { - 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 => { + 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 { - file: File; - url?: string; -} +import type { MediaAssetData } from "@/services/storage/types"; + +export type MediaType = "image" | "video" | "audio"; + +export interface MediaAsset + extends Omit { + 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 { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); -} - -export async function showMediaUploadToast({ - filesCount, - promise, -}: { - filesCount: number; - promise: Promise | (() => Promise); -}) { - 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 { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +export async function showMediaUploadToast({ + filesCount, + promise, +}: { + filesCount: number; + promise: Promise | (() => Promise); +}) { + 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; - -export type ParamGroup = "stroke"; - -interface BaseParamDefinition { - key: TKey; - label: string; - group?: ParamGroup; -} - -export interface NumberParamDefinition - extends BaseParamDefinition { - 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 - extends BaseParamDefinition { - type: "boolean"; - default: boolean; -} - -export interface ColorParamDefinition - extends BaseParamDefinition { - type: "color"; - default: string; -} - -export interface SelectParamDefinition - extends BaseParamDefinition { - type: "select"; - default: string; - options: Array<{ value: string; label: string }>; -} - -export type ParamDefinition = - | NumberParamDefinition - | BooleanParamDefinition - | ColorParamDefinition - | SelectParamDefinition; - +export type ParamValues = Record; + +export type ParamGroup = "stroke"; + +interface BaseParamDefinition { + key: TKey; + label: string; + group?: ParamGroup; +} + +export interface NumberParamDefinition + extends BaseParamDefinition { + 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 + extends BaseParamDefinition { + type: "boolean"; + default: boolean; +} + +export interface ColorParamDefinition + extends BaseParamDefinition { + type: "color"; + default: string; +} + +export interface SelectParamDefinition + extends BaseParamDefinition { + type: "select"; + default: string; + options: Array<{ value: string; label: string }>; +} + +export type ParamDefinition = + | NumberParamDefinition + | BooleanParamDefinition + | ColorParamDefinition + | SelectParamDefinition; + 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({ - 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(); - - 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({ + 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(); + + 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; + +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 { - private definitions = new Map(); - 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 { + private definitions = new Map(); + 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 { - 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 { - 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 { + 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 { + 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[] { - 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[] { + 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 | React.KeyboardEvent; - id: string; - }) => void; - handleItemMouseDown: ({ - event, - id, - }: { - event: React.MouseEvent; - id: string; - }) => void; - registerItem: (id: string, element: HTMLElement | null) => void; -} - -export const SelectionContext = createContext(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 | React.KeyboardEvent; + id: string; + }) => void; + handleItemMouseDown: ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => void; + registerItem: (id: string, element: HTMLElement | null) => void; +} + +export const SelectionContext = createContext(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; -}) { - 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; +}) { + 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 extends BoxSelectionSnapshot { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; - isAdditive: boolean; -} - -export function useBoxSelect({ - containerRef, - resolveIntersections, - selectedIds, - anchorId, - onSelectionChange, - shouldStartSelection, - getIsAdditiveSelection, - isEnabled = true, -}: { - containerRef: React.RefObject; - resolveIntersections: ResolveIntersections; - selectedIds: TId[]; - anchorId: TId | null; - onSelectionChange: (state: { - intersectedIds: TId[]; - initialSelectedIds: TId[]; - initialAnchorId: TId | null; - isAdditive: boolean; - }) => void; - shouldStartSelection?: (event: React.MouseEvent) => boolean; - getIsAdditiveSelection?: (event: React.MouseEvent) => boolean; - isEnabled?: boolean; -}) { - const [selectionBox, setSelectionBox] = useState | null>( - null, - ); - const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent) => event.ctrlKey || event.metaKey); - - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { - 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) => { - 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 extends BoxSelectionSnapshot { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; + isAdditive: boolean; +} + +export function useBoxSelect({ + containerRef, + resolveIntersections, + selectedIds, + anchorId, + onSelectionChange, + shouldStartSelection, + getIsAdditiveSelection, + isEnabled = true, +}: { + containerRef: React.RefObject; + resolveIntersections: ResolveIntersections; + selectedIds: TId[]; + anchorId: TId | null; + onSelectionChange: (state: { + intersectedIds: TId[]; + initialSelectedIds: TId[]; + initialAnchorId: TId | null; + isAdditive: boolean; + }) => void; + shouldStartSelection?: (event: React.MouseEvent) => boolean; + getIsAdditiveSelection?: (event: React.MouseEvent) => boolean; + isEnabled?: boolean; +}) { + const [selectionBox, setSelectionBox] = useState | null>( + null, + ); + const justFinishedSelectingRef = useRef(false); + const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); + const getIsAdditiveSelectionCheck = + getIsAdditiveSelection ?? + ((event: React.MouseEvent) => event.ctrlKey || event.metaKey); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + 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) => { + 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({ - 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({ + 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(ref: React.ForwardedRef, value: T | null) { - if (typeof ref === "function") { - ref(value); - return; - } - - if (ref) { - ref.current = value; - } -} - -export const SelectableItem = forwardRef( - 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 ( -
{ - 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} -
- ); - }, -); +"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(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === "function") { + ref(value); + return; + } + + if (ref) { + ref.current = value; + } +} + +export const SelectableItem = forwardRef( + 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 ( +
{ + 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} +
+ ); + }, +); 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(() => - clearSelection(), - ); - const [highlightedId, setHighlightedId] = useState(null); - const containerRef = useRef(null); - const itemElementsRef = useRef>(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) => { - 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 - | React.KeyboardEvent; - 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; - id: string; - }) => { - if (event.button !== 2) { - return; - } - - selectUnselectedItem(id); - }, - [selectUnselectedItem], - ); - - const handleBoxSelectionChange = useCallback( - (change: Parameters[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) => { - 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) => { - 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 ( - -
- {children} - -
-
- ); -} +"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(() => + clearSelection(), + ); + const [highlightedId, setHighlightedId] = useState(null); + const containerRef = useRef(null); + const itemElementsRef = useRef>(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) => { + 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 + | React.KeyboardEvent; + 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; + id: string; + }) => { + if (event.button !== 2) { + return; + } + + selectUnselectedItem(id); + }, + [selectUnselectedItem], + ); + + const handleBoxSelectionChange = useCallback( + (change: Parameters[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) => { + 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) => { + 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 ( + +
+ {children} + +
+
+ ); +} 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; - 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 ( -
- ); -} +"use client"; + +import { useMemo } from "react"; + +interface SelectionBoxProps { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject; + 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 ( +
+ ); +} 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 { - initialSelectedIds: TId[]; - initialAnchorId: TId | null; -} - -export interface BoxSelectionChange - extends BoxSelectionSnapshot { - intersectedIds: TId[]; - isAdditive: boolean; -} - -export type ResolveIntersections = ({ - 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 { - id: string; - children: React.ReactNode; -} +export interface SelectionState { + selectedIds: string[]; + anchorId: string | null; +} + +export interface BoxSelectionSnapshot { + initialSelectedIds: TId[]; + initialAnchorId: TId | null; +} + +export interface BoxSelectionChange + extends BoxSelectionSnapshot { + intersectedIds: TId[]; + isAdditive: boolean; +} + +export type ResolveIntersections = ({ + 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 { + 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 { - 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 => - 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 { - 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 { - 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 { - 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 { + 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 => + 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 { + 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 { + 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 { + 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> = { - 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> = { + 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 { - return EMPTY_SEARCH_RESULT; - }, - async browse(): Promise { - 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 { + return EMPTY_SEARCH_RESULT; + }, + async browse(): Promise { + 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>; -}): 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>; +}): 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 = { - 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 = { + 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; -} - -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; +} + +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 | 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>; - startTime: number; -}): CreateTimelineElement { - const t = raw as Partial; - - 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; -}): 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(); - 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 | 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>; + startTime: number; +}): CreateTimelineElement { + const t = raw as Partial; + + 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; +}): 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(); + 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; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array; - 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; - 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; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array; -}): 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; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array; + 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; + 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; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array; +}): 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(...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; -export type CreateLibraryAudioElement = Omit; -export type CreateAudioElement = - | CreateUploadAudioElement - | CreateLibraryAudioElement; -export type CreateVideoElement = Omit; -export type CreateImageElement = Omit; -export type CreateTextElement = Omit; -export type CreateStickerElement = Omit; -export type CreateGraphicElement = Omit; -export type CreateEffectElement = Omit; -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(...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; +export type CreateLibraryAudioElement = Omit; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit; +export type CreateImageElement = Omit; +export type CreateTextElement = Omit; +export type CreateStickerElement = Omit; +export type CreateGraphicElement = Omit; +export type CreateEffectElement = Omit; +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; +} -- cgit v1.3.1