From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- apps/web/src/lib/actions/definitions.ts | 27 +- apps/web/src/lib/actions/keybinding.ts | 79 + apps/web/src/lib/actions/registry.ts | 27 +- apps/web/src/lib/actions/types.ts | 2 + .../__tests__/transform-keyframes.test.ts | 313 ---- apps/web/src/lib/animation/color-channel.ts | 2 +- apps/web/src/lib/animation/effect-param-channel.ts | 55 +- .../web/src/lib/animation/graphic-param-channel.ts | 79 + apps/web/src/lib/animation/index.ts | 32 +- apps/web/src/lib/animation/interpolation.ts | 71 +- apps/web/src/lib/animation/keyframe-query.ts | 17 +- apps/web/src/lib/animation/keyframes.ts | 231 ++- apps/web/src/lib/animation/number-channel.ts | 2 +- apps/web/src/lib/animation/property-groups.ts | 39 + apps/web/src/lib/animation/property-registry.ts | 93 +- apps/web/src/lib/animation/resolve.ts | 69 +- apps/web/src/lib/animation/target-resolver.ts | 279 +++ apps/web/src/lib/animation/types.ts | 119 ++ apps/web/src/lib/animation/vector-channel.ts | 72 + apps/web/src/lib/blog/query.ts | 2 +- apps/web/src/lib/blog/types.ts | 92 + apps/web/src/lib/cancel-interaction.ts | 24 + .../__tests__/keyframe-aware-commands.test.ts | 433 ----- apps/web/src/lib/commands/index.ts | 1 - apps/web/src/lib/commands/media/add-media-asset.ts | 34 +- .../src/lib/commands/media/remove-media-asset.ts | 34 +- .../commands/project/update-project-settings.ts | 2 +- apps/web/src/lib/commands/scene/create-scene.ts | 2 +- apps/web/src/lib/commands/scene/delete-scene.ts | 2 +- apps/web/src/lib/commands/scene/move-bookmark.ts | 2 +- apps/web/src/lib/commands/scene/remove-bookmark.ts | 2 +- apps/web/src/lib/commands/scene/rename-scene.ts | 2 +- apps/web/src/lib/commands/scene/toggle-bookmark.ts | 2 +- apps/web/src/lib/commands/scene/update-bookmark.ts | 2 +- .../src/lib/commands/timeline/clipboard/paste.ts | 2 +- .../commands/timeline/element/delete-elements.ts | 2 +- .../timeline/element/duplicate-elements.ts | 2 +- .../timeline/element/effects/add-effect.ts | 2 +- .../timeline/element/effects/remove-effect.ts | 2 +- .../timeline/element/effects/reorder-effect.ts | 2 +- .../timeline/element/effects/toggle-effect.ts | 2 +- .../element/effects/update-effect-params.ts | 22 +- .../web/src/lib/commands/timeline/element/index.ts | 2 + .../commands/timeline/element/insert-element.ts | 20 +- .../keyframes/remove-effect-param-keyframe.ts | 2 +- .../timeline/element/keyframes/remove-keyframe.ts | 45 +- .../timeline/element/keyframes/retime-keyframe.ts | 20 +- .../keyframes/upsert-effect-param-keyframe.ts | 2 +- .../timeline/element/keyframes/upsert-keyframe.ts | 30 +- .../lib/commands/timeline/element/masks/index.ts | 2 + .../commands/timeline/element/masks/remove-mask.ts | 64 + .../timeline/element/masks/toggle-mask-inverted.ts | 75 + .../lib/commands/timeline/element/move-elements.ts | 2 +- .../lib/commands/timeline/element/retime/index.ts | 1 + .../element/retime/update-element-retime.ts | 114 ++ .../commands/timeline/element/split-elements.ts | 107 +- .../timeline/element/toggle-elements-muted.ts | 2 +- .../timeline/element/toggle-elements-visibility.ts | 2 +- .../timeline/element/update-element-duration.ts | 2 +- .../timeline/element/update-element-start-time.ts | 2 +- .../timeline/element/update-element-trim.ts | 25 +- .../commands/timeline/element/update-element.ts | 2 +- .../src/lib/commands/timeline/track/add-track.ts | 2 +- .../lib/commands/timeline/track/remove-track.ts | 2 +- .../commands/timeline/track/toggle-track-mute.ts | 2 +- .../timeline/track/toggle-track-visibility.ts | 2 +- .../src/lib/commands/timeline/tracks-snapshot.ts | 2 +- apps/web/src/lib/drag-data.ts | 2 +- .../web/src/lib/effects/definitions/blur.frag.glsl | 10 +- apps/web/src/lib/effects/definitions/blur.ts | 109 +- apps/web/src/lib/effects/definitions/index.ts | 6 +- apps/web/src/lib/effects/index.ts | 45 +- apps/web/src/lib/effects/registry.ts | 33 +- apps/web/src/lib/effects/types.ts | 42 + apps/web/src/lib/export.ts | 33 +- apps/web/src/lib/fonts/google-fonts.ts | 26 +- apps/web/src/lib/fonts/types.ts | 24 + apps/web/src/lib/graphics/definitions/ellipse.ts | 73 + apps/web/src/lib/graphics/definitions/index.ts | 29 + apps/web/src/lib/graphics/definitions/polygon.ts | 211 +++ apps/web/src/lib/graphics/definitions/rectangle.ts | 85 + apps/web/src/lib/graphics/definitions/shared.ts | 16 + apps/web/src/lib/graphics/definitions/star.ts | 109 ++ apps/web/src/lib/graphics/index.ts | 125 ++ apps/web/src/lib/graphics/registry.ts | 10 + apps/web/src/lib/graphics/stroke.ts | 102 ++ apps/web/src/lib/graphics/types.ts | 23 + apps/web/src/lib/guides/definitions/custom.tsx | 24 + apps/web/src/lib/guides/definitions/grid.tsx | 125 ++ apps/web/src/lib/guides/definitions/platforms.tsx | 65 + .../src/lib/guides/definitions/tiktok-layout.tsx | 335 ++++ apps/web/src/lib/guides/index.ts | 13 + apps/web/src/lib/guides/registry.tsx | 28 + apps/web/src/lib/guides/types.ts | 20 + apps/web/src/lib/iconify-api.ts | 241 --- apps/web/src/lib/masks/__tests__/snap.test.ts | 235 +++ apps/web/src/lib/masks/definitions/box-like.ts | 270 +++ apps/web/src/lib/masks/definitions/ellipse.ts | 72 + apps/web/src/lib/masks/definitions/index.ts | 39 + apps/web/src/lib/masks/definitions/rectangle.ts | 94 + apps/web/src/lib/masks/definitions/split.ts | 347 ++++ apps/web/src/lib/masks/geometry.ts | 112 ++ apps/web/src/lib/masks/handle-positions.ts | 382 ++++ apps/web/src/lib/masks/index.ts | 18 + apps/web/src/lib/masks/param-update.ts | 59 + apps/web/src/lib/masks/registry.ts | 70 + .../src/lib/masks/shaders/jfa-distance.frag.glsl | 36 + apps/web/src/lib/masks/snap.ts | 335 ++++ apps/web/src/lib/masks/types.ts | 115 ++ apps/web/src/lib/masks/utils.ts | 64 + apps/web/src/lib/media/audio-mastering.ts | 103 ++ apps/web/src/lib/media/audio.ts | 249 ++- apps/web/src/lib/media/media-utils.ts | 2 +- apps/web/src/lib/media/mediabunny.ts | 132 +- apps/web/src/lib/media/processing.ts | 90 +- apps/web/src/lib/media/types.ts | 9 + apps/web/src/lib/media/upload-toast.ts | 51 + apps/web/src/lib/params.ts | 50 + apps/web/src/lib/preview/element-bounds.ts | 259 +-- apps/web/src/lib/preview/hit-test.ts | 54 +- apps/web/src/lib/preview/preview-coords.ts | 99 +- apps/web/src/lib/preview/preview-snap.ts | 293 +++- apps/web/src/lib/project/types.ts | 53 + apps/web/src/lib/registry.ts | 40 + apps/web/src/lib/rendering.ts | 28 + apps/web/src/lib/retime/__tests__/resolve.test.ts | 61 + apps/web/src/lib/retime/__tests__/split.test.ts | 31 + apps/web/src/lib/retime/audio-stretch.ts | 182 ++ apps/web/src/lib/retime/index.ts | 4 + apps/web/src/lib/retime/presets.ts | 12 + apps/web/src/lib/retime/resolve.ts | 48 + apps/web/src/lib/retime/split.ts | 34 + apps/web/src/lib/scenes.ts | 2 +- apps/web/src/lib/selection/constants.ts | 1 + apps/web/src/lib/selection/context.tsx | 52 + apps/web/src/lib/selection/hit-testing.ts | 50 + apps/web/src/lib/selection/hooks/use-box-select.ts | 170 ++ .../src/lib/selection/hooks/use-selection-scope.ts | 28 + apps/web/src/lib/selection/index.ts | 4 + apps/web/src/lib/selection/scope.ts | 29 + apps/web/src/lib/selection/selectable-item.tsx | 107 ++ apps/web/src/lib/selection/selectable-surface.tsx | 288 ++++ apps/web/src/lib/selection/selection-box.tsx | 50 + apps/web/src/lib/selection/state.ts | 165 ++ apps/web/src/lib/selection/types.ts | 39 + apps/web/src/lib/shaders/jfa-init.frag.glsl | 25 + apps/web/src/lib/shaders/jfa-step.frag.glsl | 60 + apps/web/src/lib/sounds/types.ts | 39 + .../src/lib/stickers/__tests__/sticker-id.test.ts | 14 +- apps/web/src/lib/stickers/index.ts | 230 ++- .../src/lib/stickers/providers/countries-data.ts | 1819 ++++++++++++++++++++ apps/web/src/lib/stickers/providers/emoji.ts | 119 -- apps/web/src/lib/stickers/providers/flags.ts | 97 +- apps/web/src/lib/stickers/providers/icons.ts | 122 -- apps/web/src/lib/stickers/providers/index.ts | 14 +- apps/web/src/lib/stickers/providers/logos.ts | 31 + apps/web/src/lib/stickers/providers/shapes.ts | 160 +- apps/web/src/lib/stickers/registry.ts | 33 +- apps/web/src/lib/stickers/resolver.ts | 8 +- apps/web/src/lib/stickers/types.ts | 79 +- apps/web/src/lib/text/layout.ts | 29 +- apps/web/src/lib/text/measure-element.ts | 132 ++ apps/web/src/lib/time.ts | 6 +- apps/web/src/lib/timeline/audio-constants.ts | 2 + apps/web/src/lib/timeline/audio-state.ts | 100 ++ apps/web/src/lib/timeline/bookmarks.ts | 2 +- apps/web/src/lib/timeline/defaults.ts | 77 + apps/web/src/lib/timeline/drag.ts | 42 + apps/web/src/lib/timeline/drop-utils.ts | 4 +- apps/web/src/lib/timeline/element-utils.ts | 241 +-- apps/web/src/lib/timeline/index.ts | 4 +- apps/web/src/lib/timeline/ripple-utils.ts | 2 +- apps/web/src/lib/timeline/selection-hit-testing.ts | 133 ++ apps/web/src/lib/timeline/snap-utils.ts | 2 +- apps/web/src/lib/timeline/track-element-update.ts | 2 +- apps/web/src/lib/timeline/track-utils.ts | 29 +- apps/web/src/lib/timeline/types.ts | 304 ++++ apps/web/src/lib/transcription/caption.ts | 2 +- apps/web/src/lib/transcription/types.ts | 47 + 179 files changed, 11577 insertions(+), 2325 deletions(-) create mode 100644 apps/web/src/lib/actions/keybinding.ts delete mode 100644 apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts create mode 100644 apps/web/src/lib/animation/graphic-param-channel.ts create mode 100644 apps/web/src/lib/animation/property-groups.ts create mode 100644 apps/web/src/lib/animation/target-resolver.ts create mode 100644 apps/web/src/lib/animation/types.ts create mode 100644 apps/web/src/lib/animation/vector-channel.ts create mode 100644 apps/web/src/lib/blog/types.ts create mode 100644 apps/web/src/lib/cancel-interaction.ts delete mode 100644 apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/index.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts create mode 100644 apps/web/src/lib/commands/timeline/element/retime/index.ts create mode 100644 apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts create mode 100644 apps/web/src/lib/effects/types.ts create mode 100644 apps/web/src/lib/fonts/types.ts create mode 100644 apps/web/src/lib/graphics/definitions/ellipse.ts create mode 100644 apps/web/src/lib/graphics/definitions/index.ts create mode 100644 apps/web/src/lib/graphics/definitions/polygon.ts create mode 100644 apps/web/src/lib/graphics/definitions/rectangle.ts create mode 100644 apps/web/src/lib/graphics/definitions/shared.ts create mode 100644 apps/web/src/lib/graphics/definitions/star.ts create mode 100644 apps/web/src/lib/graphics/index.ts create mode 100644 apps/web/src/lib/graphics/registry.ts create mode 100644 apps/web/src/lib/graphics/stroke.ts create mode 100644 apps/web/src/lib/graphics/types.ts create mode 100644 apps/web/src/lib/guides/definitions/custom.tsx create mode 100644 apps/web/src/lib/guides/definitions/grid.tsx create mode 100644 apps/web/src/lib/guides/definitions/platforms.tsx create mode 100644 apps/web/src/lib/guides/definitions/tiktok-layout.tsx create mode 100644 apps/web/src/lib/guides/index.ts create mode 100644 apps/web/src/lib/guides/registry.tsx create mode 100644 apps/web/src/lib/guides/types.ts delete mode 100644 apps/web/src/lib/iconify-api.ts create mode 100644 apps/web/src/lib/masks/__tests__/snap.test.ts create mode 100644 apps/web/src/lib/masks/definitions/box-like.ts create mode 100644 apps/web/src/lib/masks/definitions/ellipse.ts create mode 100644 apps/web/src/lib/masks/definitions/index.ts create mode 100644 apps/web/src/lib/masks/definitions/rectangle.ts create mode 100644 apps/web/src/lib/masks/definitions/split.ts create mode 100644 apps/web/src/lib/masks/geometry.ts create mode 100644 apps/web/src/lib/masks/handle-positions.ts create mode 100644 apps/web/src/lib/masks/index.ts create mode 100644 apps/web/src/lib/masks/param-update.ts create mode 100644 apps/web/src/lib/masks/registry.ts create mode 100644 apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl create mode 100644 apps/web/src/lib/masks/snap.ts create mode 100644 apps/web/src/lib/masks/types.ts create mode 100644 apps/web/src/lib/masks/utils.ts create mode 100644 apps/web/src/lib/media/audio-mastering.ts create mode 100644 apps/web/src/lib/media/types.ts create mode 100644 apps/web/src/lib/media/upload-toast.ts create mode 100644 apps/web/src/lib/params.ts create mode 100644 apps/web/src/lib/project/types.ts create mode 100644 apps/web/src/lib/registry.ts create mode 100644 apps/web/src/lib/rendering.ts create mode 100644 apps/web/src/lib/retime/__tests__/resolve.test.ts create mode 100644 apps/web/src/lib/retime/__tests__/split.test.ts create mode 100644 apps/web/src/lib/retime/audio-stretch.ts create mode 100644 apps/web/src/lib/retime/index.ts create mode 100644 apps/web/src/lib/retime/presets.ts create mode 100644 apps/web/src/lib/retime/resolve.ts create mode 100644 apps/web/src/lib/retime/split.ts create mode 100644 apps/web/src/lib/selection/constants.ts create mode 100644 apps/web/src/lib/selection/context.tsx create mode 100644 apps/web/src/lib/selection/hit-testing.ts create mode 100644 apps/web/src/lib/selection/hooks/use-box-select.ts create mode 100644 apps/web/src/lib/selection/hooks/use-selection-scope.ts create mode 100644 apps/web/src/lib/selection/index.ts create mode 100644 apps/web/src/lib/selection/scope.ts create mode 100644 apps/web/src/lib/selection/selectable-item.tsx create mode 100644 apps/web/src/lib/selection/selectable-surface.tsx create mode 100644 apps/web/src/lib/selection/selection-box.tsx create mode 100644 apps/web/src/lib/selection/state.ts create mode 100644 apps/web/src/lib/selection/types.ts create mode 100644 apps/web/src/lib/shaders/jfa-init.frag.glsl create mode 100644 apps/web/src/lib/shaders/jfa-step.frag.glsl create mode 100644 apps/web/src/lib/sounds/types.ts create mode 100644 apps/web/src/lib/stickers/providers/countries-data.ts delete mode 100644 apps/web/src/lib/stickers/providers/emoji.ts delete mode 100644 apps/web/src/lib/stickers/providers/icons.ts create mode 100644 apps/web/src/lib/stickers/providers/logos.ts create mode 100644 apps/web/src/lib/text/measure-element.ts create mode 100644 apps/web/src/lib/timeline/audio-constants.ts create mode 100644 apps/web/src/lib/timeline/audio-state.ts create mode 100644 apps/web/src/lib/timeline/defaults.ts create mode 100644 apps/web/src/lib/timeline/drag.ts create mode 100644 apps/web/src/lib/timeline/selection-hit-testing.ts create mode 100644 apps/web/src/lib/timeline/types.ts create mode 100644 apps/web/src/lib/transcription/types.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 82319216..4d67c1ef 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -1,4 +1,4 @@ -import type { ShortcutKey } from "@/types/keybinding"; +import type { ShortcutKey } from "@/lib/actions/keybinding"; export type TActionCategory = | "playback" @@ -7,7 +7,8 @@ export type TActionCategory = | "selection" | "history" | "timeline" - | "controls"; + | "controls" + | "assets"; export interface TActionDefinition { description: string; @@ -114,10 +115,14 @@ export const ACTIONS = { category: "selection", defaultShortcuts: ["ctrl+a"], }, + "cancel-interaction": { + description: "Cancel current interaction", + category: "controls", + defaultShortcuts: ["escape"], + }, "deselect-all": { description: "Deselect all elements", category: "selection", - defaultShortcuts: ["escape"], }, "duplicate-selected": { description: "Duplicate selected element", @@ -146,11 +151,25 @@ export const ACTIONS = { category: "history", defaultShortcuts: ["ctrl+shift+z", "ctrl+y"], }, + "remove-media-asset": { + description: "Remove media asset", + category: "assets", + args: { projectId: "string", assetId: "string" }, + }, + "remove-media-assets": { + description: "Remove media assets", + category: "assets", + args: { projectId: "string", assetIds: "string[]" }, + }, } as const satisfies Record; export type TAction = keyof typeof ACTIONS; -export function getActionDefinition({ action }: { action: TAction }): TActionDefinition { +export function getActionDefinition({ + action, +}: { + action: TAction; +}): TActionDefinition { return ACTIONS[action]; } diff --git a/apps/web/src/lib/actions/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts new file mode 100644 index 00000000..0a61893b --- /dev/null +++ b/apps/web/src/lib/actions/keybinding.ts @@ -0,0 +1,79 @@ +import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +export type Key = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "up" + | "down" + | "left" + | "right" + | "/" + | "?" + | "." + | "enter" + | "tab" + | "space" + | "escape" + | "esc" + | "backspace" + | "delete" + | "home" + | "end"; +/* eslint-enable */ + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/lib/actions/registry.ts b/apps/web/src/lib/actions/registry.ts index b9c84529..c825a5e4 100644 --- a/apps/web/src/lib/actions/registry.ts +++ b/apps/web/src/lib/actions/registry.ts @@ -57,5 +57,30 @@ export const invokeAction: InvokeActionFunc = ( args?: TArgOfAction, trigger?: TInvocationTrigger, ) => { - boundActions[action]?.forEach((handler) => handler(args, trigger)); + if (trigger === "keypress") { + // #region agent log + fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "initial", + hypothesisId: "H4", + location: "actions/registry.ts:invokeAction", + message: "Action invoked from keypress", + data: { + action, + handlerCount: boundActions[action]?.length ?? 0, + }, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion + } + boundActions[action]?.forEach((handler) => { + handler(args, trigger); + }); }; diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts index 508bf10e..38627b95 100644 --- a/apps/web/src/lib/actions/types.ts +++ b/apps/web/src/lib/actions/types.ts @@ -8,6 +8,8 @@ export type TActionArgsMap = { "seek-backward": { seconds: number } | undefined; "jump-forward": { seconds: number } | undefined; "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; }; type TKeysWithValueUndefined = { diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts deleted file mode 100644 index 629bd0d6..00000000 --- a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ElementAnimations } from "@/types/animation"; -import { - clampAnimationsToDuration, - getElementKeyframes, - getKeyframeAtTime, - hasKeyframesForPath, - getChannelValueAtTime, - getElementLocalTime, - resolveTransformAtTime, - splitAnimationsAtTime, -} from "@/lib/animation"; - -describe("transform keyframe evaluation", () => { - test("uses fallback value when channel is missing", () => { - const value = getChannelValueAtTime({ - channel: undefined, - time: 1, - fallbackValue: 42, - }); - expect(value).toBe(42); - }); - - test("returns boundary value when time is within epsilon of first/last keyframe", () => { - const channel = { - valueKind: "number" as const, - keyframes: [ - { id: "a", time: 0, value: 10, interpolation: "linear" as const }, - { id: "b", time: 2, value: 30, interpolation: "linear" as const }, - ], - }; - expect( - getChannelValueAtTime({ - channel, - time: 0.0008, - fallbackValue: 0, - }), - ).toBe(10); - expect( - getChannelValueAtTime({ - channel, - time: 1.9992, - fallbackValue: 0, - }), - ).toBe(30); - }); - - test("interpolates linear channels", () => { - const value = getChannelValueAtTime({ - channel: { - valueKind: "number", - keyframes: [ - { id: "a", time: 0, value: 10, interpolation: "linear" }, - { id: "b", time: 2, value: 30, interpolation: "linear" }, - ], - }, - time: 1, - fallbackValue: 0, - }); - expect(value).toBe(20); - }); - - test("clamps local time to [0, duration]", () => { - expect( - getElementLocalTime({ - timelineTime: 2, - elementStartTime: 5, - elementDuration: 4, - }), - ).toBe(0); - expect( - getElementLocalTime({ - timelineTime: 12, - elementStartTime: 5, - elementDuration: 4, - }), - ).toBe(4); - expect( - getElementLocalTime({ - timelineTime: 7, - elementStartTime: 5, - elementDuration: 4, - }), - ).toBe(2); - }); - - test("uses hold interpolation from the left keyframe", () => { - const value = getChannelValueAtTime({ - channel: { - valueKind: "number", - keyframes: [ - { id: "a", time: 0, value: 10, interpolation: "hold" }, - { id: "b", time: 2, value: 30, interpolation: "linear" }, - ], - }, - time: 1, - fallbackValue: 0, - }); - expect(value).toBe(10); - }); - - test("resolves transform by mixing animated and fallback properties", () => { - const animations: ElementAnimations = { - channels: { - "transform.position.x": { - valueKind: "number", - keyframes: [ - { id: "a", time: 0, value: 0, interpolation: "linear" }, - { id: "b", time: 4, value: 80, interpolation: "linear" }, - ], - }, - "transform.scale": { - valueKind: "number", - keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }], - }, - }, - }; - const resolvedTransform = resolveTransformAtTime({ - baseTransform: { - position: { x: 10, y: 20 }, - scale: 1, - rotate: 15, - }, - animations, - localTime: 2, - }); - expect(resolvedTransform).toEqual({ - position: { x: 40, y: 20 }, - scale: 2, - rotate: 15, - }); - }); -}); - -describe("transform keyframe mutation utilities", () => { - test("splits channels and rebases right side times", () => { - const animations: ElementAnimations = { - channels: { - "transform.scale": { - valueKind: "number", - keyframes: [ - { id: "a", time: 0, value: 1, interpolation: "linear" }, - { id: "b", time: 2, value: 2, interpolation: "linear" }, - { id: "c", time: 6, value: 4, interpolation: "linear" }, - ], - }, - }, - }; - const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ - animations, - splitTime: 4, - }); - - expect( - leftAnimations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 2, 4]); - expect( - rightAnimations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 2]); - expect( - rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value, - ).toBe(3); - }); - - test("clamps channels to updated element duration", () => { - const animations: ElementAnimations = { - channels: { - "transform.rotate": { - valueKind: "number", - keyframes: [ - { id: "a", time: 0, value: 0, interpolation: "linear" }, - { id: "b", time: 2, value: 20, interpolation: "linear" }, - { id: "c", time: 5, value: 50, interpolation: "linear" }, - ], - }, - }, - }; - const clampedAnimations = clampAnimationsToDuration({ - animations, - duration: 2, - }); - expect( - clampedAnimations?.channels["transform.rotate"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 2]); - }); -}); - -describe("typed channel interpolation", () => { - test("interpolates color channels from hex keyframes", () => { - const value = getChannelValueAtTime({ - channel: { - valueKind: "color", - keyframes: [ - { id: "a", time: 0, value: "#000000", interpolation: "linear" }, - { id: "b", time: 1, value: "#ffffff", interpolation: "linear" }, - ], - }, - time: 0.5, - fallbackValue: "#000000", - }); - expect(typeof value).toBe("string"); - expect(value).toContain("rgba("); - }); - - test("uses hold behavior for discrete channels", () => { - const value = getChannelValueAtTime({ - channel: { - valueKind: "discrete", - keyframes: [ - { id: "a", time: 0, value: "normal", interpolation: "hold" }, - { id: "b", time: 2, value: "multiply", interpolation: "hold" }, - ], - }, - time: 1.2, - fallbackValue: "normal", - }); - expect(value).toBe("normal"); - }); -}); - -describe("keyframe query helpers", () => { - test("getElementKeyframes returns flat list of all keyframes across channels", () => { - const animations: ElementAnimations = { - channels: { - "transform.position.x": { - valueKind: "number", - keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }], - }, - opacity: { - valueKind: "number", - keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }], - }, - }, - }; - - const keyframes = getElementKeyframes({ animations }); - expect(keyframes).toHaveLength(2); - expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([ - "opacity", - "transform.position.x", - ]); - }); - - test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => { - expect(getElementKeyframes({ animations: undefined })).toEqual([]); - expect( - getElementKeyframes({ - animations: { - channels: { opacity: { valueKind: "number", keyframes: [] } }, - }, - }), - ).toEqual([]); - }); - - test("hasKeyframesForPath returns true only for paths with keyframes", () => { - const animations: ElementAnimations = { - channels: { - "transform.position.x": { - valueKind: "number", - keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }], - }, - "transform.position.y": { - valueKind: "number", - keyframes: [], - }, - }, - }; - - expect( - hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }), - ).toBe(true); - expect( - hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }), - ).toBe(false); - }); - - test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => { - const animations: ElementAnimations = { - channels: { - "transform.rotate": { - valueKind: "number", - keyframes: [ - { id: "r-1", time: 1, value: 15, interpolation: "linear" }, - { id: "r-2", time: 2, value: 30, interpolation: "linear" }, - ], - }, - }, - }; - - const found = getKeyframeAtTime({ - animations, - propertyPath: "transform.rotate", - time: 1.0008, - }); - expect(found?.id).toBe("r-1"); - expect(found?.value).toBe(15); - expect(found?.propertyPath).toBe("transform.rotate"); - - expect( - getKeyframeAtTime({ - animations, - propertyPath: "transform.rotate", - time: 1.01, - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts index 2a340f94..a34b469b 100644 --- a/apps/web/src/lib/animation/color-channel.ts +++ b/apps/web/src/lib/animation/color-channel.ts @@ -2,7 +2,7 @@ import type { AnimationPropertyPath, ColorAnimationChannel, ElementAnimations, -} from "@/types/animation"; +} from "@/lib/animation/types"; export function getColorChannelForPath({ animations, diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts index 07efe4d8..323d9f54 100644 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -1,8 +1,10 @@ -import type { Effect, EffectParamValues } from "@/types/effects"; +import type { ParamValues } from "@/lib/params"; +import type { Effect } from "@/lib/effects/types"; import type { ElementAnimations, + EffectParamPath, NumberAnimationChannel, -} from "@/types/animation"; +} from "@/lib/animation/types"; import { getChannel, removeKeyframe, @@ -11,19 +13,56 @@ import { } from "./keyframes"; import { getChannelValueAtTime } from "./interpolation"; -const EFFECT_PARAM_PATH_PREFIX = "effects."; -const EFFECT_PARAM_PATH_SUFFIX = ".params."; +export const EFFECT_PARAM_PATH_PREFIX = "effects."; +export const EFFECT_PARAM_PATH_SUFFIX = ".params."; -function buildEffectParamPath({ +export function buildEffectParamPath({ effectId, paramKey, }: { effectId: string; paramKey: string; -}): string { +}): EffectParamPath { return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; } +export function isEffectParamPath({ + propertyPath, +}: { + propertyPath: string; +}): propertyPath is EffectParamPath { + return ( + propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) && + propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX) + ); +} + +export function parseEffectParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { effectId: string; paramKey: string } | null { + if (!isEffectParamPath({ propertyPath })) { + return null; + } + + const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length); + const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX); + if (separatorIndex <= 0) { + return null; + } + + const effectId = withoutPrefix.slice(0, separatorIndex); + const paramKey = withoutPrefix.slice( + separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length, + ); + if (!effectId || !paramKey) { + return null; + } + + return { effectId, paramKey }; +} + export function resolveEffectParamsAtTime({ effect, animations, @@ -32,8 +71,8 @@ export function resolveEffectParamsAtTime({ effect: Effect; animations: ElementAnimations | undefined; localTime: number; -}): EffectParamValues { - const resolved: EffectParamValues = {}; +}): ParamValues { + const resolved: ParamValues = {}; for (const [paramKey, staticValue] of Object.entries(effect.params)) { const path = buildEffectParamPath({ effectId: effect.id, paramKey }); diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts new file mode 100644 index 00000000..e77e4c73 --- /dev/null +++ b/apps/web/src/lib/animation/graphic-param-channel.ts @@ -0,0 +1,79 @@ +import type { + ElementAnimations, + GraphicParamPath, +} from "@/lib/animation/types"; +import type { ParamValues } from "@/lib/params"; +import { + getGraphicDefinition, + resolveGraphicParams, +} from "@/lib/graphics"; +import { getChannel } from "./keyframes"; +import { getChannelValueAtTime } from "./interpolation"; + +export const GRAPHIC_PARAM_PATH_PREFIX = "params."; + +export function buildGraphicParamPath({ + paramKey, +}: { + paramKey: string; +}): GraphicParamPath { + return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; +} + +export function isGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): propertyPath is GraphicParamPath { + return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); +} + +export function parseGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { paramKey: string } | null { + if (!isGraphicParamPath({ propertyPath })) { + return null; + } + + const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); + return paramKey.length > 0 ? { paramKey } : null; +} + +export function resolveGraphicParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const baseParams = resolveGraphicParams(definition, element.params); + const resolved: ParamValues = { ...baseParams }; + + for (const param of definition.params) { + const path = buildGraphicParamPath({ paramKey: param.key }); + const channel = getChannel({ + animations: element.animations, + propertyPath: path, + }); + if (!channel || channel.keyframes.length === 0) { + continue; + } + + resolved[param.key] = getChannelValueAtTime({ + channel, + time: Math.max(0, localTime), + fallbackValue: baseParams[param.key] ?? param.default, + }) as number | string | boolean; + } + + return resolved; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index c4396bb7..3dcddad3 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -1,6 +1,7 @@ export { getChannelValueAtTime, getNumberChannelValueAtTime, + getVectorChannelValueAtTime, normalizeChannel, } from "./interpolation"; @@ -13,6 +14,7 @@ export { setChannel, splitAnimationsAtTime, upsertElementKeyframe, + upsertPathKeyframe, } from "./keyframes"; export { @@ -21,7 +23,6 @@ export { resolveNumberAtTime, resolveOpacityAtTime, resolveTransformAtTime, - resolveVolumeAtTime, } from "./resolve"; export { @@ -31,6 +32,9 @@ export { getElementBaseValueForProperty, isAnimationPropertyPath, supportsAnimationProperty, + type AnimationPropertyDefinition, + type NumericSpec, + type NumericRange, withElementBaseValueForProperty, } from "./property-registry"; @@ -39,3 +43,29 @@ export { getKeyframeAtTime, hasKeyframesForPath, } from "./keyframe-query"; + +export { + buildGraphicParamPath, + isGraphicParamPath, + parseGraphicParamPath, + resolveGraphicParamsAtTime, +} from "./graphic-param-channel"; + +export { + isAnimationPath, + resolveAnimationTarget, + getParamValueKind, + getParamDefaultInterpolation, + type AnimationPathDescriptor, +} from "./target-resolver"; + +export { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + type GroupKeyframeRef, +} from "./property-groups"; + +export { + getVectorChannelForPath, + isVectorValue, +} from "./vector-channel"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index 7a6fc6cf..c5b9ea80 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -5,7 +5,10 @@ import type { DiscreteValue, DiscreteAnimationChannel, NumberAnimationChannel, -} from "@/types/animation"; + VectorAnimationChannel, + VectorValue, +} from "@/lib/animation/types"; +import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; function byTimeAscending({ @@ -59,12 +62,7 @@ function parseHexColor({ const green = parseHexChannel({ hex: `${greenHex}${greenHex}` }); const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` }); const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` }); - if ( - red === null || - green === null || - blue === null || - alpha === null - ) { + if (red === null || green === null || blue === null || alpha === null) { return null; } @@ -77,12 +75,7 @@ function parseHexColor({ const blue = parseHexChannel({ hex: rawHex.slice(4, 6) }); const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff"; const alpha = parseHexChannel({ hex: alphaHex }); - if ( - red === null || - green === null || - blue === null || - alpha === null - ) { + if (red === null || green === null || blue === null || alpha === null) { return null; } @@ -177,7 +170,10 @@ export function normalizeChannel({ } as TChannel; } -function evaluateChannelValueAtTime({ +function evaluateChannelValueAtTime< + TKeyframe extends { time: number; value: TValue }, + TValue, +>({ keyframes, time, fallbackValue, @@ -214,7 +210,11 @@ function evaluateChannelValueAtTime { + if (leftKeyframe.interpolation === "hold") { + return leftKeyframe.value; + } + + return { + x: + leftKeyframe.value.x + + (rightKeyframe.value.x - leftKeyframe.value.x) * progress, + y: + leftKeyframe.value.y + + (rightKeyframe.value.y - leftKeyframe.value.y) * progress, + }; + }, + }); +} + function getDiscreteValueAtTime({ channel, time, @@ -358,6 +388,17 @@ export function getChannelValueAtTime({ }); } + if (channel.valueKind === "vector") { + if (!isVectorValue(fallbackValue)) { + return fallbackValue; + } + return getVectorChannelValueAtTime({ + channel, + time, + fallbackValue: fallbackValue as VectorValue, + }); + } + if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { return fallbackValue; } diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 6626e3e8..753e31d0 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,9 +1,10 @@ import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import type { - AnimationPropertyPath, + AnimationPath, ElementAnimations, ElementKeyframe, -} from "@/types/animation"; +} from "@/lib/animation/types"; +import { isAnimationPath } from "./target-resolver"; export function getElementKeyframes({ animations, @@ -16,12 +17,16 @@ export function getElementKeyframes({ return Object.entries(animations.channels).flatMap( ([propertyPath, channel]) => { - if (!channel || channel.keyframes.length === 0) { + if ( + !channel || + channel.keyframes.length === 0 || + !isAnimationPath({ propertyPath }) + ) { return []; } return channel.keyframes.map((keyframe) => ({ - propertyPath: propertyPath as AnimationPropertyPath, + propertyPath, id: keyframe.id, time: keyframe.time, value: keyframe.value, @@ -36,7 +41,7 @@ export function hasKeyframesForPath({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; }): boolean { const channel = animations?.channels[propertyPath]; return Boolean(channel && channel.keyframes.length > 0); @@ -48,7 +53,7 @@ export function getKeyframeAtTime({ time, }: { animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; time: number; }): ElementKeyframe | null { const channel = animations?.channels[propertyPath]; diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 3e3613f2..5fa99fb8 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -2,6 +2,7 @@ import type { AnimationChannel, AnimationInterpolation, AnimationKeyframe, + AnimationPath, AnimationPropertyPath, AnimationValue, AnimationValueKind, @@ -9,15 +10,19 @@ import type { DiscreteAnimationChannel, ElementAnimations, NumberAnimationChannel, -} from "@/types/animation"; + VectorAnimationChannel, +} from "@/lib/animation/types"; +import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { generateUUID } from "@/utils/id"; +import { snapToStep } from "@/utils/math"; import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; import { coerceAnimationValueForProperty, getDefaultInterpolationForProperty, getAnimationPropertyDefinition, isAnimationPropertyPath, + type NumericRange, } from "./property-registry"; function isNearlySameTime({ @@ -126,6 +131,19 @@ function buildKeyframe({ }; } + if (channel.valueKind === "vector") { + if (!isVectorValue(value)) { + throw new Error("Vector channel keyframes require {x, y} values"); + } + + return { + id, + time, + value, + interpolation: interpolation === "hold" ? "hold" : "linear", + }; + } + if (typeof value !== "string" && typeof value !== "boolean") { throw new Error( "Discrete channel keyframes require boolean or string values", @@ -140,32 +158,178 @@ function buildKeyframe({ }; } -function createEmptyChannel({ - propertyPath, +function createEmptyChannelForValueKind({ + valueKind, }: { - propertyPath: AnimationPropertyPath; + valueKind: AnimationValueKind; }): AnimationChannel { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - if (propertyDefinition.valueKind === "number") { + if (valueKind === "number") { return { valueKind: "number", keyframes: [], } satisfies NumberAnimationChannel; } - if (propertyDefinition.valueKind === "color") { + if (valueKind === "color") { return { valueKind: "color", keyframes: [], } satisfies ColorAnimationChannel; } + if (valueKind === "vector") { + return { + valueKind: "vector", + keyframes: [], + } satisfies VectorAnimationChannel; + } + return { valueKind: "discrete", keyframes: [], } satisfies DiscreteAnimationChannel; } +function clampNumericRange({ + value, + numericRange, +}: { + value: number; + numericRange: NumericRange | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceAnimationValueForPath({ + value, + valueKind, + numericRange, +}: { + value: AnimationValue; + valueKind: AnimationValueKind; + numericRange?: NumericRange; +}): AnimationValue | null { + if (valueKind === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return clampNumericRange({ value, numericRange }); + } + + if (valueKind === "color") { + return typeof value === "string" ? value : null; + } + + if (valueKind === "vector") { + return isVectorValue(value) ? value : null; + } + + return typeof value === "string" || typeof value === "boolean" ? value : null; +} + +export function upsertPathKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, + valueKind, + defaultInterpolation, + numericRange, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + valueKind: AnimationValueKind; + defaultInterpolation: AnimationInterpolation; + numericRange?: NumericRange; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForPath({ + value, + valueKind, + numericRange, + }); + if (coercedValue === null) { + return animations; + } + + const channel = getChannel({ animations, propertyPath }); + const targetChannel = + channel && channel.valueKind === valueKind + ? channel + : createEmptyChannelForValueKind({ valueKind }); + const updatedChannel = upsertKeyframe({ + channel: targetChannel, + time, + value: coercedValue, + interpolation: interpolation ?? defaultInterpolation, + keyframeId, + }); + + return ( + setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }) ?? { channels: {} } + ); +} + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath, + value, + }); + if (coercedValue === null) { + return animations; + } + + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return upsertPathKeyframe({ + animations, + propertyPath, + time, + value: coercedValue, + interpolation, + keyframeId, + valueKind: propertyDefinition.valueKind, + defaultInterpolation: getDefaultInterpolationForProperty({ + propertyPath, + }), + numericRange: propertyDefinition.numericRange, + }); +} + + export function upsertKeyframe({ channel, time, @@ -516,62 +680,13 @@ export function splitAnimationsAtTime({ }; } -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const defaultInterpolation = getDefaultInterpolationForProperty({ - propertyPath, - }); - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - const channel = getChannel({ animations, propertyPath }); - const targetChannel = - channel && channel.valueKind === propertyDefinition.valueKind - ? channel - : createEmptyChannel({ propertyPath }); - const updatedChannel = upsertKeyframe({ - channel: targetChannel, - time, - value: coercedValue, - interpolation: interpolation ?? defaultInterpolation, - keyframeId, - }); - - return ( - setChannel({ - animations, - propertyPath, - channel: updatedChannel, - }) ?? { channels: {} } - ); -} - export function removeElementKeyframe({ animations, propertyPath, keyframeId, }: { animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { const channel = getChannel({ animations, propertyPath }); @@ -593,7 +708,7 @@ export function retimeElementKeyframe({ time, }: { animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; time: number; }): ElementAnimations | undefined { diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts index 67166156..859da863 100644 --- a/apps/web/src/lib/animation/number-channel.ts +++ b/apps/web/src/lib/animation/number-channel.ts @@ -2,7 +2,7 @@ import type { AnimationPropertyPath, ElementAnimations, NumberAnimationChannel, -} from "@/types/animation"; +} from "@/lib/animation/types"; export function getNumberChannelForPath({ animations, diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts new file mode 100644 index 00000000..61b4fc4a --- /dev/null +++ b/apps/web/src/lib/animation/property-groups.ts @@ -0,0 +1,39 @@ +import type { + AnimationPropertyGroup, + AnimationPropertyPath, + ElementAnimations, +} from "@/lib/animation/types"; +import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types"; +import { getKeyframeAtTime } from "./keyframe-query"; + +export interface GroupKeyframeRef { + propertyPath: AnimationPropertyPath; + keyframeId: string; +} + +export function getGroupKeyframesAtTime({ + animations, + group, + time, +}: { + animations: ElementAnimations | undefined; + group: AnimationPropertyGroup; + time: number; +}): GroupKeyframeRef[] { + return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => { + const keyframe = getKeyframeAtTime({ animations, propertyPath, time }); + return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : []; + }); +} + +export function hasGroupKeyframeAtTime({ + animations, + group, + time, +}: { + animations: ElementAnimations | undefined; + group: AnimationPropertyGroup; + time: number; +}): boolean { + return getGroupKeyframesAtTime({ animations, group, time }).length > 0; +} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 361ae3ba..0b28e783 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -4,25 +4,35 @@ import type { AnimationValue, AnimationValueKind, DiscreteValue, -} from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; + VectorValue, +} from "@/lib/animation/types"; +import { isVectorValue } from "./vector-channel"; +import type { TimelineElement } from "@/lib/timeline"; import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, - DEFAULT_TEXT_BACKGROUND, } from "@/constants/text-constants"; -import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/lib/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { snapToStep } from "@/utils/math"; -interface NumericRange { +export interface NumericSpec { min?: number; max?: number; + step?: number; } -interface AnimationPropertyDefinition { +export type NumericRange = NumericSpec; + +export interface AnimationPropertyDefinition { valueKind: AnimationValueKind; defaultInterpolation: AnimationInterpolation; - numericRange?: NumericRange; + numericRange?: NumericSpec; supportsElement: ({ element }: { element: TimelineElement }) => boolean; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; setValue: ({ @@ -38,58 +48,57 @@ const ANIMATION_PROPERTY_REGISTRY: Record< AnimationPropertyPath, AnimationPropertyDefinition > = { - "transform.position.x": { - valueKind: "number", + "transform.position": { + valueKind: "vector", defaultInterpolation: "linear", supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, + isVisualElement(element) ? element.transform.position : null, setValue: ({ element, value }) => isVisualElement(element) ? { ...element, transform: { ...element.transform, - position: { ...element.transform.position, x: value as number }, + position: value as VectorValue, }, } : element, }, - "transform.position.y": { + "transform.scaleX": { valueKind: "number", defaultInterpolation: "linear", + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, + isVisualElement(element) ? element.transform.scaleX : null, setValue: ({ element, value }) => isVisualElement(element) ? { ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, + transform: { ...element.transform, scaleX: value as number }, } : element, }, - "transform.scale": { + "transform.scaleY": { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE }, + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => - isVisualElement(element) ? element.transform.scale : null, + isVisualElement(element) ? element.transform.scaleY : null, setValue: ({ element, value }) => isVisualElement(element) ? { ...element, - transform: { ...element.transform, scale: value as number }, + transform: { ...element.transform, scaleY: value as number }, } : element, }, "transform.rotate": { valueKind: "number", defaultInterpolation: "linear", + numericRange: { min: -360, max: 360, step: 1 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => isVisualElement(element) ? element.transform.rotate : null, @@ -104,7 +113,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< opacity: { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: 0, max: 1 }, + numericRange: { min: 0, max: 1, step: 0.01 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => isVisualElement(element) ? element.opacity : null, @@ -116,12 +125,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record< volume: { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: 0, max: 1 }, - supportsElement: ({ element }) => element.type === "audio", + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), getValue: ({ element }) => - element.type === "audio" ? element.volume : null, + canElementHaveAudio(element) ? element.volume ?? 0 : null, setValue: ({ element, value }) => - element.type === "audio" + canElementHaveAudio(element) ? { ...element, volume: value as number } : element, }, @@ -152,11 +161,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record< "background.paddingX": { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: 0 }, + numericRange: { min: 0, step: 1 }, supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" - ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) : null, setValue: ({ element, value }) => element.type === "text" @@ -169,11 +178,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record< "background.paddingY": { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: 0 }, + numericRange: { min: 0, step: 1 }, supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" - ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) : null, setValue: ({ element, value }) => element.type === "text" @@ -186,10 +195,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record< "background.offsetX": { valueKind: "number", defaultInterpolation: "linear", + numericRange: { step: 1 }, supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" - ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) : null, setValue: ({ element, value }) => element.type === "text" @@ -202,10 +212,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record< "background.offsetY": { valueKind: "number", defaultInterpolation: "linear", + numericRange: { step: 1 }, supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" - ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) : null, setValue: ({ element, value }) => element.type === "text" @@ -218,7 +229,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< "background.cornerRadius": { valueKind: "number", defaultInterpolation: "linear", - numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }, + numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" @@ -304,20 +315,24 @@ export function getDefaultInterpolationForProperty({ return propertyDefinition.defaultInterpolation; } -function clampNumericRange({ +function applyNumericSpec({ value, numericRange, }: { value: number; - numericRange: NumericRange | undefined; + numericRange: NumericSpec | undefined; }): number { if (!numericRange) { return value; } + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, value)); + return Math.min(maxValue, Math.max(minValue, steppedValue)); } export function coerceAnimationValueForProperty({ @@ -334,7 +349,7 @@ export function coerceAnimationValueForProperty({ return null; } - return clampNumericRange({ + return applyNumericSpec({ value, numericRange: propertyDefinition.numericRange, }); @@ -344,6 +359,10 @@ export function coerceAnimationValueForProperty({ return typeof value === "string" ? value : null; } + if (propertyDefinition.valueKind === "vector") { + return isVectorValue(value) ? value : null; + } + if (typeof value === "string" || typeof value === "boolean") { return value as DiscreteValue; } diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 147be1b3..8709627d 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,8 +1,16 @@ -import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; -import type { Transform } from "@/types/timeline"; -import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation"; +import type { + AnimationPropertyPath, + ElementAnimations, +} from "@/lib/animation/types"; +import type { Transform } from "@/lib/rendering"; +import { + getColorValueAtTime, + getNumberChannelValueAtTime, + getVectorChannelValueAtTime, +} from "./interpolation"; import { getColorChannelForPath } from "./color-channel"; import { getNumberChannelForPath } from "./number-channel"; +import { getVectorChannelForPath } from "./vector-channel"; export function getElementLocalTime({ timelineTime, @@ -36,31 +44,29 @@ export function resolveTransformAtTime({ }): Transform { const safeLocalTime = Math.max(0, localTime); return { - position: { - x: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.position.x", - }), - time: safeLocalTime, - fallbackValue: baseTransform.position.x, + position: getVectorChannelValueAtTime({ + channel: getVectorChannelForPath({ + animations, + propertyPath: "transform.position", }), - y: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.position.y", - }), - time: safeLocalTime, - fallbackValue: baseTransform.position.y, + time: safeLocalTime, + fallbackValue: baseTransform.position, + }), + scaleX: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.scaleX", }), - }, - scale: getNumberChannelValueAtTime({ + time: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: getNumberChannelValueAtTime({ channel: getNumberChannelForPath({ animations, - propertyPath: "transform.scale", + propertyPath: "transform.scaleY", }), time: safeLocalTime, - fallbackValue: baseTransform.scale, + fallbackValue: baseTransform.scaleY, }), rotate: getNumberChannelValueAtTime({ channel: getNumberChannelForPath({ @@ -127,22 +133,3 @@ export function resolveColorAtTime({ fallbackValue: baseColor, }); } - -export function resolveVolumeAtTime({ - baseVolume, - animations, - localTime, -}: { - baseVolume: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "volume", - }), - time: Math.max(0, localTime), - fallbackValue: baseVolume, - }); -} diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts new file mode 100644 index 00000000..e801bc68 --- /dev/null +++ b/apps/web/src/lib/animation/target-resolver.ts @@ -0,0 +1,279 @@ +import type { + AnimationInterpolation, + AnimationPath, + AnimationValue, + AnimationValueKind, +} from "@/lib/animation/types"; +import { + parseEffectParamPath, + isEffectParamPath, +} from "@/lib/animation/effect-param-channel"; +import { + isGraphicParamPath, + parseGraphicParamPath, +} from "@/lib/animation/graphic-param-channel"; +import type { ParamDefinition } from "@/lib/params"; +import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; +import { getGraphicDefinition } from "@/lib/graphics"; +import type { TimelineElement } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapToStep } from "@/utils/math"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + isAnimationPropertyPath, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; + +export interface AnimationPathDescriptor { + valueKind: AnimationValueKind; + defaultInterpolation: AnimationInterpolation; + numericRange?: NumericSpec; + getBaseValue(): AnimationValue | null; + setBaseValue(value: AnimationValue): TimelineElement; +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): AnimationValueKind { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): AnimationInterpolation { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): NumericSpec | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +function coerceParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: AnimationValue; +}): number | string | boolean | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + valueKind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRange: getParamNumericRange({ param }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + valueKind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRange: getParamNumericRange({ param }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function isAnimationPath({ + propertyPath, +}: { + propertyPath: string; +}): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath({ propertyPath }) || + isGraphicParamPath({ propertyPath }) || + isEffectParamPath({ propertyPath }) + ); +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath({ propertyPath: path })) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + valueKind: propertyDefinition.valueKind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRange: propertyDefinition.numericRange, + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: (value) => { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath: path, + value, + }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts new file mode 100644 index 00000000..913911d0 --- /dev/null +++ b/apps/web/src/lib/animation/types.ts @@ -0,0 +1,119 @@ +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; +export type GraphicParamPath = `params.${string}`; +export type EffectParamPath = `effects.${string}.params.${string}`; +export type AnimationPath = + | AnimationPropertyPath + | GraphicParamPath + | EffectParamPath; + +export const ANIMATION_PROPERTY_GROUPS = { + "transform.scale": ["transform.scaleX", "transform.scaleY"], +} as const satisfies Record>; + +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 new file mode 100644 index 00000000..50c62e40 --- /dev/null +++ b/apps/web/src/lib/animation/vector-channel.ts @@ -0,0 +1,72 @@ +import type { + AnimationPropertyPath, + ElementAnimations, + VectorAnimationChannel, + VectorValue, +} from "@/lib/animation/types"; + +export function isVectorValue(value: unknown): value is VectorValue { + return ( + typeof value === "object" && + value !== null && + "x" in value && + "y" in value && + typeof (value as VectorValue).x === "number" && + typeof (value as VectorValue).y === "number" + ); +} + +export function getVectorChannelForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): VectorAnimationChannel | undefined { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.valueKind !== "vector") { + return undefined; + } + return channel; +} + +export function getVectorChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: VectorAnimationChannel | undefined; + time: number; + fallbackValue: VectorValue; +}): VectorValue { + if (!channel || channel.keyframes.length === 0) { + return fallbackValue; + } + + const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time); + const first = keyframes[0]; + const last = keyframes[keyframes.length - 1]; + + if (!first || !last) return fallbackValue; + if (time <= first.time) return first.value; + if (time >= last.time) return last.value; + + for (let i = 0; i < keyframes.length - 1; i++) { + const left = keyframes[i]; + const right = keyframes[i + 1]; + if (time < left.time || time > right.time) continue; + + if (left.interpolation === "hold") return left.value; + + const span = right.time - left.time; + if (span === 0) return right.value; + + const t = (time - left.time) / span; + return { + x: left.value.x + (right.value.x - left.value.x) * t, + y: left.value.y + (right.value.y - left.value.y) * t, + }; + } + + return last.value; +} diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts index f814eeaf..5f7c7474 100644 --- a/apps/web/src/lib/blog/query.ts +++ b/apps/web/src/lib/blog/query.ts @@ -5,7 +5,7 @@ import type { MarblePost, MarblePostList, MarbleTagList, -} from "@/types/blog"; +} from "@/lib/blog/types"; import { unified } from "unified"; import rehypeParse from "rehype-parse"; import rehypeStringify from "rehype-stringify"; diff --git a/apps/web/src/lib/blog/types.ts b/apps/web/src/lib/blog/types.ts new file mode 100644 index 00000000..16795b02 --- /dev/null +++ b/apps/web/src/lib/blog/types.ts @@ -0,0 +1,92 @@ +export type Post = { + id: string; + slug: string; + title: string; + content: string; + description: string; + coverImage: string; + publishedAt: Date; + updatedAt: Date; + authors: { + id: string; + name: string; + image: string; + }[]; + category: { + id: string; + slug: string; + name: string; + }; + tags: { + id: string; + slug: string; + name: string; + }[]; + attribution: { + author: string; + url: string; + } | null; +}; + +export type Pagination = { + limit: number; + currpage: number; + nextPage: number | null; + prevPage: number | null; + totalItems: number; + totalPages: number; +}; + +export type MarblePostList = { + posts: Post[]; + pagination: Pagination; +}; + +export type MarblePost = { + post: Post; +}; + +export type Tag = { + id: string; + name: string; + slug: string; +}; + +export type MarbleTag = { + tag: Tag; +}; + +export type MarbleTagList = { + tags: Tag[]; + pagination: Pagination; +}; + +export type Category = { + id: string; + name: string; + slug: string; +}; + +export type MarbleCategory = { + category: Category; +}; + +export type MarbleCategoryList = { + categories: Category[]; + pagination: Pagination; +}; + +export type Author = { + id: string; + name: string; + image: string; +}; + +export type MarbleAuthor = { + author: Author; +}; + +export type MarbleAuthorList = { + authors: Author[]; + pagination: Pagination; +}; diff --git a/apps/web/src/lib/cancel-interaction.ts b/apps/web/src/lib/cancel-interaction.ts new file mode 100644 index 00000000..0cb07f4f --- /dev/null +++ b/apps/web/src/lib/cancel-interaction.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts deleted file mode 100644 index d2356a39..00000000 --- a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { EditorCore } from "@/core"; -import type { TimelineTrack, VideoElement } from "@/types/timeline"; -import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; -import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration"; -import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim"; -import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements"; -import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements"; -import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe"; -import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe"; -import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; - -type MockEditor = { - timeline: { - getTracks: () => TimelineTrack[]; - updateTracks: (tracks: TimelineTrack[]) => void; - }; - selection: { - getSelectedElements: () => { trackId: string; elementId: string }[]; - setSelectedElements: ({ - elements, - }: { - elements: { trackId: string; elementId: string }[]; - }) => void; - }; -}; - -const originalGetInstance = EditorCore.getInstance; - -function mockEditorCore({ editor }: { editor: MockEditor }): void { - ( - EditorCore as unknown as { - getInstance: () => EditorCore; - } - ).getInstance = () => editor as unknown as EditorCore; -} - -function restoreEditorCore(): void { - ( - EditorCore as unknown as { - getInstance: typeof EditorCore.getInstance; - } - ).getInstance = originalGetInstance; -} - -function buildVideoElement(): VideoElement { - return { - id: "element-1", - name: "Clip", - type: "video", - mediaId: "media-1", - duration: 8, - startTime: 1, - trimStart: 0, - trimEnd: 0, - transform: DEFAULT_TRANSFORM, - opacity: 1, - animations: { - channels: { - "transform.scale": { - valueKind: "number", - keyframes: [ - { id: "kf-a", time: 0, value: 1, interpolation: "linear" }, - { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" }, - { id: "kf-c", time: 6, value: 2, interpolation: "linear" }, - ], - }, - }, - }, - }; -} - -function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] { - return [ - { - id: "track-1", - name: "Main", - type: "video", - elements: [element], - isMain: true, - muted: false, - hidden: false, - }, - ]; -} - -afterEach(() => { - restoreEditorCore(); -}); - -describe("keyframe-aware timeline commands", () => { - test("duration updates clamp keyframes beyond the new duration", () => { - const tracks = buildTracks({ element: buildVideoElement() }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new UpdateElementDurationCommand({ - trackId: "track-1", - elementId: "element-1", - duration: 3, - }).execute(); - - const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations; - expect( - updatedElement?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 3]); - }); - - test("trim updates clamp keyframes when duration is changed", () => { - const tracks = buildTracks({ element: buildVideoElement() }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new UpdateElementTrimCommand({ - elementId: "element-1", - trimStart: 0, - trimEnd: 0, - startTime: 1, - duration: 2, - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - expect(updatedElement.duration).toBe(2); - expect( - updatedElement.animations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0]); - }); - - test("split rebases right-side keyframes and keeps continuity at split time", () => { - const tracks = buildTracks({ element: buildVideoElement() }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new SplitElementsCommand({ - elements: [{ trackId: "track-1", elementId: "element-1" }], - splitTime: 5, - }).execute(); - - const leftElement = updatedTracks[0].elements.find( - (element) => element.id === "element-1", - ) as VideoElement; - const rightElement = updatedTracks[0].elements.find( - (element) => element.id !== "element-1", - ) as VideoElement; - - expect( - leftElement.animations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 3, 4]); - expect( - rightElement.animations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 2]); - expect( - rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value, - ).toBeCloseTo(5 / 3, 4); - }); - - test("duplicate creates independent keyframe ids for copied element", () => { - const tracks = buildTracks({ element: buildVideoElement() }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }], - setSelectedElements: () => {}, - }, - }, - }); - - new DuplicateElementsCommand({ - elements: [{ trackId: "track-1", elementId: "element-1" }], - }).execute(); - - const originalElement = updatedTracks.find( - (track) => track.id === "track-1", - )?.elements[0] as VideoElement; - const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1"); - const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement; - - expect(duplicatedElement).toBeDefined(); - expect( - duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map( - (keyframe) => keyframe.time, - ), - ).toEqual([0, 3, 6]); - expect( - duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id, - ).not.toBe( - originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id, - ); - }); -}); - -describe("generic keyframe commands", () => { - test("upsert adds or updates keyframe at target time", () => { - const element = buildVideoElement(); - const tracks = buildTracks({ element }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new UpsertKeyframeCommand({ - trackId: "track-1", - elementId: "element-1", - propertyPath: "transform.scale", - time: 2, - value: 2.5, - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - const keyframes = - updatedElement.animations?.channels["transform.scale"]?.keyframes ?? []; - const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001); - expect(atTwo?.value).toBe(2.5); - }); - - test("remove deletes keyframe by id", () => { - const element = buildVideoElement(); - const tracks = buildTracks({ element }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new RemoveKeyframeCommand({ - trackId: "track-1", - elementId: "element-1", - propertyPath: "transform.scale", - keyframeId: "kf-b", - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - const keyframes = - updatedElement.animations?.channels["transform.scale"]?.keyframes ?? []; - expect(keyframes).toHaveLength(2); - expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined(); - expect(updatedElement.transform.scale).toBe(1); - }); - - test("remove persists value to base property when channel becomes empty", () => { - const element: VideoElement = { - ...buildVideoElement(), - transform: { - ...DEFAULT_TRANSFORM, - scale: 1, - }, - animations: { - channels: { - "transform.scale": { - valueKind: "number", - keyframes: [ - { - id: "only-scale", - time: 2, - value: 1.43, - interpolation: "linear", - }, - ], - }, - }, - }, - }; - const tracks = buildTracks({ element }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new RemoveKeyframeCommand({ - trackId: "track-1", - elementId: "element-1", - propertyPath: "transform.scale", - keyframeId: "only-scale", - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - expect(updatedElement.transform.scale).toBe(1.43); - expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined(); - }); - - test("upsert supports non-transform paths like opacity", () => { - const element = buildVideoElement(); - const tracks = buildTracks({ element }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new UpsertKeyframeCommand({ - trackId: "track-1", - elementId: "element-1", - propertyPath: "opacity", - time: 1, - value: 0.35, - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - const opacityChannel = updatedElement.animations?.channels.opacity; - expect(opacityChannel?.valueKind).toBe("number"); - expect(opacityChannel?.keyframes[0]?.value).toBe(0.35); - }); - - test("retime moves keyframe to new time", () => { - const element = buildVideoElement(); - const tracks = buildTracks({ element }); - let updatedTracks: TimelineTrack[] = tracks; - mockEditorCore({ - editor: { - timeline: { - getTracks: () => tracks, - updateTracks: (nextTracks) => { - updatedTracks = nextTracks; - }, - }, - selection: { - getSelectedElements: () => [], - setSelectedElements: () => {}, - }, - }, - }); - - new RetimeKeyframeCommand({ - trackId: "track-1", - elementId: "element-1", - propertyPath: "transform.scale", - keyframeId: "kf-b", - nextTime: 4, - }).execute(); - - const updatedElement = updatedTracks[0].elements[0] as VideoElement; - const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find( - (existingKeyframe) => existingKeyframe.id === "kf-b", - ); - expect(keyframe?.time).toBe(4); - }); -}); diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts index a2119fc1..03de07c4 100644 --- a/apps/web/src/lib/commands/index.ts +++ b/apps/web/src/lib/commands/index.ts @@ -1,6 +1,5 @@ export { Command } from "./base-command"; export { BatchCommand } from "./batch-command"; -export { PreviewTracker } from "./preview-tracker"; export * from "./timeline"; export * from "./media"; 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 ddcca426..a3dec0e6 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -1,8 +1,10 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { MediaAsset } from "@/types/assets"; +import { toast } from "sonner"; +import type { MediaAsset } from "@/lib/media/types"; import { generateUUID } from "@/utils/id"; import { storageService } from "@/services/storage/service"; +import { hasMediaId } from "@/lib/timeline/element-utils"; export class AddMediaAssetCommand extends Command { private assetId: string; @@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command { }) .catch((error) => { console.error("Failed to save media item:", error); + + const currentAssets = editor.media.getAssets(); + editor.media.setAssets({ + assets: currentAssets.filter((asset) => asset.id !== this.assetId), + }); + + const currentTracks = editor.timeline.getTracks(); + const orphanedElements: Array<{ trackId: string; elementId: string }> = + []; + + for (const track of currentTracks) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + orphanedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + if (orphanedElements.length > 0) { + editor.timeline.deleteElements({ elements: orphanedElements }); + } + + if (storageService.isQuotaExceededError({ error })) { + toast.error("Not enough browser storage", { + description: error instanceof Error ? error.message : undefined, + }); + } }); } 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 ee9ffa2d..f519baef 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -1,10 +1,10 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { MediaAsset } from "@/types/assets"; +import type { MediaAsset } from "@/lib/media/types"; import { storageService } from "@/services/storage/service"; import { videoCache } from "@/services/video-cache/service"; import { hasMediaId } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; export class RemoveMediaAssetCommand extends Command { private savedAssets: MediaAsset[] | null = null; @@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command { return; } + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + videoCache.clearVideo({ mediaId: this.assetId }); editor.media.setAssets({ @@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command { undo(): void { const editor = EditorCore.getInstance(); - if (this.savedAssets) { - editor.media.setAssets({ assets: this.savedAssets }); - } + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; - if (this.savedTracks) { - editor.timeline.updateTracks(this.savedTracks); - } + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); - if (this.removedAsset) { storageService .saveMediaAsset({ projectId: this.projectId, - mediaAsset: this.removedAsset, + mediaAsset: restoredAsset, }) .catch((error) => { console.error("Failed to restore media item on undo:", error); }); } + + if (this.savedTracks) { + editor.timeline.updateTracks(this.savedTracks); + } } } 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 b7ab75ee..b16431c1 100644 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ b/apps/web/src/lib/commands/project/update-project-settings.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TProject, TProjectSettings } from "@/types/project"; +import type { TProject, TProjectSettings } from "@/lib/project/types"; export class UpdateProjectSettingsCommand extends Command { private savedSettings: TProjectSettings | null = null; diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts index 5fea5d53..28bda2fd 100644 --- a/apps/web/src/lib/commands/scene/create-scene.ts +++ b/apps/web/src/lib/commands/scene/create-scene.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { buildDefaultScene } from "@/lib/scenes"; export class CreateSceneCommand extends Command { diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts index 83066e24..58c790e6 100644 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ b/apps/web/src/lib/commands/scene/delete-scene.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; export class DeleteSceneCommand extends Command { diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts index 64382f03..28d7a45c 100644 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts index a3029366..19515b97 100644 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; import { getFrameTime, diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts index dc62369d..f1d65e27 100644 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ b/apps/web/src/lib/commands/scene/rename-scene.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; export class RenameSceneCommand extends Command { diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts index ac76d8fc..518a30aa 100644 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts index 5ce91a36..6aeef42b 100644 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { Bookmark, TScene } from "@/types/timeline"; +import type { Bookmark, TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 800cbb90..00758eb7 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -4,7 +4,7 @@ import type { TimelineTrack, TimelineElement, ClipboardItem, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { wouldElementOverlap } from "@/lib/timeline/element-utils"; import { 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 7fb46c36..4b439870 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; 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 97b475f4..207e2373 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { 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 5299749d..34ca12ab 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,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import { buildDefaultEffectInstance } from "@/lib/effects"; function addEffectToElement({ 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 b475474f..1b309f13 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,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function removeEffectFromElement({ element, 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 81c66503..d946be18 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,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function reorderEffectsOnElement({ element, 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 70bf955d..25e7c356 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,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; export function toggleEffectOnElement({ element, diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts index d9bf0aad..c3443c22 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -1,8 +1,8 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { EffectParamValues } from "@/types/effects"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { ParamValues } from "@/lib/params"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function updateEffectParamsOnElement({ element, @@ -11,7 +11,7 @@ function updateEffectParamsOnElement({ }: { element: VisualElement; effectId: string; - params: Partial; + params: Partial; }): VisualElement { const currentEffects = element.effects ?? []; const updated = currentEffects.map((effect) => { @@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command { private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; - private readonly params: Partial; + private readonly params: Partial; constructor({ trackId, @@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command { trackId: string; elementId: string; effectId: string; - params: Partial; + params: Partial; }) { super(); this.trackId = trackId; @@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isVisualElement, - update: (element) => { - return updateEffectParamsOnElement({ - element: element as VisualElement, - effectId: this.effectId, - params: this.params, - }); + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); }, }); diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 413d1adf..899754fb 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; export { MoveElementCommand } from "./move-elements"; export * from "./keyframes"; export * from "./effects"; +export * from "./masks"; +export * from "./retime"; 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 68fc9841..acd72b66 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -6,7 +6,7 @@ import type { TimelineElement, TrackType, ElementType, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { requiresMediaId, @@ -19,8 +19,9 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; -import type { MediaAsset } from "@/types/assets"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { MediaAsset } from "@/lib/media/types"; +import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -168,6 +169,14 @@ export class InsertElementCommand extends Command { return false; } + if (element.type === "graphic") { + registerDefaultGraphics(); + if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { + console.error("Graphic element must have a valid definitionId"); + return false; + } + } + if (element.type === "text" && !element.content) { console.error("Text element must have content"); return false; @@ -344,9 +353,6 @@ export class InsertElementCommand extends Command { }: { element: { type: ElementType }; }): TrackType { - if (element.type === "video" || element.type === "image") { - return "video"; - } - return element.type; + return ELEMENT_TRACK_MAP[element.type]; } } 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 d4922532..984a5ed7 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 @@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command"; import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; export class RemoveEffectParamKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; 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 f12f9829..eb5cf742 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 @@ -2,15 +2,13 @@ import { EditorCore } from "@/core"; import { getChannel, getChannelValueAtTime, - getElementBaseValueForProperty, removeElementKeyframe, - supportsAnimationProperty, - withElementBaseValueForProperty, + resolveAnimationTarget, } from "@/lib/animation"; import { Command } from "@/lib/commands/base-command"; import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; function sampleValueBeforeRemoval({ element, @@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({ keyframeId, }: { element: TimelineElement; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; -}): number | null { +}): AnimationValue | null { const channel = getChannel({ animations: element.animations, propertyPath, @@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({ return null; } - const baseValue = getElementBaseValueForProperty({ element, propertyPath }); - if (baseValue === null || typeof baseValue !== "number") { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return null; + } + const baseValue = target.getBaseValue(); + if (baseValue === null) { return null; } - const sampled = getChannelValueAtTime({ + return getChannelValueAtTime({ channel, time: keyframe.time, fallbackValue: baseValue, }); - return typeof sampled === "number" ? sampled : null; } function removeKeyframeAndPersist({ @@ -51,9 +52,14 @@ function removeKeyframeAndPersist({ keyframeId, }: { element: TimelineElement; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; }): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + const valueBefore = sampleValueBeforeRemoval({ element, propertyPath, @@ -71,11 +77,7 @@ function removeKeyframeAndPersist({ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; const baseElement = shouldPersistToBase - ? withElementBaseValueForProperty({ - element, - propertyPath, - value: valueBefore, - }) + ? target.setBaseValue(valueBefore) : element; return { ...baseElement, animations: nextAnimations }; @@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly keyframeId: string; constructor({ @@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; }) { super(); @@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => removeKeyframeAndPersist({ element, 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 5328419f..9e912f27 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,15 +1,15 @@ import { EditorCore } from "@/core"; -import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; import { Command } from "@/lib/commands/base-command"; import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { TimelineTrack } from "@/types/timeline"; +import type { AnimationPath } from "@/lib/animation/types"; +import type { TimelineTrack } from "@/lib/timeline"; export class RetimeKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly keyframeId: string; private readonly nextTime: number; @@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; nextTime: number; }) { @@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); - if (!Number.isFinite(boundedTime)) return element; return { ...element, animations: retimeElementKeyframe({ 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 f5236279..285bd80c 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 @@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command"; import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; 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 370f6865..28e7feff 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,19 +1,19 @@ import { EditorCore } from "@/core"; import { Command } from "@/lib/commands/base-command"; -import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import type { + AnimationPath, AnimationInterpolation, - AnimationPropertyPath, AnimationValue, -} from "@/types/animation"; +} from "@/lib/animation/types"; export class UpsertKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly time: number; private readonly value: AnimationValue; private readonly interpolation: AnimationInterpolation | undefined; @@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; time: number; value: AnimationValue; interpolation?: AnimationInterpolation; @@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => { + const target = resolveAnimationTarget({ + element, + path: this.propertyPath, + }); + if (!target) { + return element; + } + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); return { ...element, - animations: upsertElementKeyframe({ + animations: upsertPathKeyframe({ animations: element.animations, propertyPath: this.propertyPath, time: boundedTime, value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, + valueKind: target.valueKind, + defaultInterpolation: target.defaultInterpolation, + numericRange: target.numericRange, }), }; }, diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts new file mode 100644 index 00000000..acc47184 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..ee09cbdc --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -0,0 +1,64 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} 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 new file mode 100644 index 00000000..ac3c8c7c --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { Mask } from "@/lib/masks/types"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = (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 51e18c21..a36e8e93 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -4,7 +4,7 @@ import type { TimelineTrack, TimelineElement, TrackType, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { buildEmptyTrack, isMainTrack, diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts new file mode 100644 index 00000000..5daa59bf --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..d1f859e6 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts @@ -0,0 +1,114 @@ +import { EditorCore } from "@/core"; +import { clampRetimeRate } from "@/constants/retime-constants"; +import { clampAnimationsToDuration } from "@/lib/animation"; +import { Command } from "@/lib/commands/base-command"; +import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; +import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; +import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} + +export class UpdateElementRetimeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly retime: RetimeConfig | undefined; + + constructor({ + trackId, + elementId, + retime, + }: { + trackId: string; + elementId: string; + retime?: RetimeConfig; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.retime = retime; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isRetimableElement, + update: (element) => { + if (!isRetimableElement(element)) { + return element; + } + + const nextRetime = this.retime + ? { + ...this.retime, + rate: clampRetimeRate({ rate: this.retime.rate }), + } + : undefined; + const sourceDuration = getSourceDuration({ + trimStart: element.trimStart, + trimEnd: element.trimEnd, + duration: element.duration, + sourceDuration: element.sourceDuration, + retime: element.retime, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + ...element, + retime: nextRetime, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: nextDuration, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 37ccbb12..2acac683 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -1,9 +1,10 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; import { splitAnimationsAtTime } from "@/lib/animation"; +import { getSourceSpanAtClipTime } from "@/lib/retime"; export class SplitElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command { const relativeTime = this.splitTime - element.startTime; const leftVisibleDuration = relativeTime; const rightVisibleDuration = element.duration - relativeTime; + const retimeRef = isRetimableElement(element) + ? element.retime + : undefined; + const leftSourceSpan = getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }); + const totalSourceSpan = getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }); + const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command { if (this.retainSide === "left") { return [ - { - ...element, - duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, - name: `${element.name} (left)`, - animations: leftAnimations, - }, - ]; - } - - if (this.retainSide === "right") { - if (this.rippleEnabled && elementsToSplit.length === 1) { - leftVisibleDurationForRipple = leftVisibleDuration; - } - const newId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: newId, - }); - return [ - { - ...element, - id: newId, - startTime: this.splitTime, - duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, - name: `${element.name} (right)`, - animations: rightAnimations, - }, - ]; - } - - // "both" - split into two pieces - const secondElementId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: secondElementId, - }); - - return [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, name: `${element.name} (left)`, animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, + ]; + } + + if (this.retainSide === "right") { + if (this.rippleEnabled && elementsToSplit.length === 1) { + leftVisibleDurationForRipple = leftVisibleDuration; + } + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + return [ { ...element, - id: secondElementId, + id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, name: `${element.name} (right)`, animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; + } + + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + + return [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, + name: `${element.name} (left)`, + animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + { + ...element, + id: secondElementId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, + name: `${element.name} (right)`, + animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; }); if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { 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 1fa7421e..f416e7c5 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; 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 daeb759a..24d51c15 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { canElementBeHidden } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; 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 3366548d..d88cf9cb 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; 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 e25bc0e3..75773ec2 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index 7548638a..b2c77418 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -1,8 +1,9 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; -import { rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; +import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command { if (!targetElement) return track; const nextDuration = this.duration ?? targetElement.duration; - const nextStartTime = this.startTime ?? targetElement.startTime; + const requestedStartTime = this.startTime ?? targetElement.startTime; + const nextStartTime = enforceMainTrackStart({ + tracks: this.savedState ?? [], + targetTrackId: track.id, + requestedStartTime, + excludeElementId: this.elementId, + }); const oldEndTime = targetElement.startTime + targetElement.duration; const newEndTime = nextStartTime + nextDuration; @@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command { trimEnd: this.trimEnd, startTime: nextStartTime, duration: nextDuration, + ...(isRetimableElement(targetElement) + ? { retime: targetElement.retime } + : {}), animations: clampAnimationsToDuration({ animations: targetElement.animations, duration: nextDuration, @@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command { if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { const shiftedOthers = rippleShiftElements({ - elements: track.elements.filter((element) => element.id !== this.elementId), + elements: track.elements.filter( + (element) => element.id !== this.elementId, + ), afterTime: oldEndTime, shiftAmount, }); @@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command { elements: track.elements.map((element) => element.id === this.elementId ? updatedElement - : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element) + : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? + element), ), } as typeof track; } @@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command { return { ...track, elements: track.elements.map((element) => - element.id === this.elementId ? updatedElement : element + element.id === this.elementId ? updatedElement : element, ), } as typeof track; }); 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 643f8e4b..cf0c5cbc 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { updateElementInTracks } from "@/lib/timeline"; 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 95338888..dd4ceb64 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/types/timeline"; +import type { TrackType, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { 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 a32aacd7..35f29b1c 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { getMainTrack } from "@/lib/timeline"; export class RemoveTrackCommand extends Command { 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 3fe526c7..9acf732a 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTracktHaveAudio } from "@/lib/timeline"; 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 e81714ac..4bfd53c0 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,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTrackBeHidden } from "@/lib/timeline"; diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index 2ba059c7..a4370cab 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts index 067b0a35..d847db95 100644 --- a/apps/web/src/lib/drag-data.ts +++ b/apps/web/src/lib/drag-data.ts @@ -1,4 +1,4 @@ -import type { TimelineDragData } from "@/types/drag"; +import type { TimelineDragData } from "@/lib/timeline/drag"; const MIME_TYPE = "application/x-timeline-drag"; let lastDragData: TimelineDragData | null = null; diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl index 2c015725..c9ad2eae 100644 --- a/apps/web/src/lib/effects/definitions/blur.frag.glsl +++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl @@ -3,6 +3,7 @@ precision mediump float; uniform sampler2D u_texture; uniform vec2 u_resolution; uniform float u_sigma; +uniform float u_step; uniform vec2 u_direction; varying vec2 v_texCoord; @@ -13,13 +14,12 @@ void main() { vec4 color = vec4(0.0); float totalWeight = 0.0; - // step=1 texel — scaling step size instead causes discrete ghosting artifacts for (int i = -30; i <= 30; i++) { - float fi = float(i); - float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); - color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight; + float pos = float(i) * u_step; + float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma)); + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight; totalWeight += weight; } gl_FragColor = color / totalWeight; -} +} \ No newline at end of file diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts index 3819f82c..54934402 100644 --- a/apps/web/src/lib/effects/definitions/blur.ts +++ b/apps/web/src/lib/effects/definitions/blur.ts @@ -1,6 +1,72 @@ -import type { EffectDefinition } from "@/types/effects"; +import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types"; import blurFragmentShader from "./blur.frag.glsl"; +const MAX_SINGLE_PASS_SIGMA = 10; +const MAX_STEP = 4; +const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP; +const MAX_ITERATIONS = 8; + +/** + * Builds multi-pass gaussian blur passes for a given sigma. + * Shared by the blur effect and background blur — they each + * compute their own sigma from their own intensity scale. + */ +export function buildGaussianBlurPasses({ + sigmaX, + sigmaY, +}: { + sigmaX: number; + sigmaY: number; +}): ResolvedEffectPass[] { + const maxSigma = Math.max(sigmaX, sigmaY); + if (maxSigma < 0.001) return []; + + const iterations = Math.min( + MAX_ITERATIONS, + Math.max( + 1, + Math.ceil( + (maxSigma * maxSigma) / + (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA), + ), + ), + ); + const perPassSigmaX = sigmaX / Math.sqrt(iterations); + const perPassSigmaY = sigmaY / Math.sqrt(iterations); + const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA); + const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA); + + const passes: ResolvedEffectPass[] = []; + for (let i = 0; i < iterations; i++) { + passes.push({ + fragmentShader: blurFragmentShader, + uniforms: { + u_sigma: perPassSigmaX, + u_step: stepX, + u_direction: [1, 0], + }, + }); + passes.push({ + fragmentShader: blurFragmentShader, + uniforms: { + u_sigma: perPassSigmaY, + u_step: stepY, + u_direction: [0, 1], + }, + }); + } + return passes; +} + +function intensityToSigma(intensity: number, resolution: number, reference: number): number { + return (intensity / 5) * (resolution / reference); +} + +function parseIntensity(effectParams: Record): number { + const raw = effectParams.intensity; + return typeof raw === "number" ? raw : Number.parseFloat(String(raw)); +} + export const blurEffectDefinition: EffectDefinition = { type: "blur", name: "Blur", @@ -19,32 +85,29 @@ export const blurEffectDefinition: EffectDefinition = { renderer: { type: "webgl", passes: [ - { - fragmentShader: blurFragmentShader, - uniforms: ({ effectParams, width }) => { - const intensity = - typeof effectParams.intensity === "number" - ? effectParams.intensity - : Number.parseFloat(String(effectParams.intensity)); - return { - u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001), + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams, width }) => ({ + u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001), + u_step: 1, u_direction: [1, 0], - }; + }), }, - }, - { - fragmentShader: blurFragmentShader, - uniforms: ({ effectParams, height }) => { - const intensity = - typeof effectParams.intensity === "number" - ? effectParams.intensity - : Number.parseFloat(String(effectParams.intensity)); - return { - u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001), + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams, height }) => ({ + u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001), + u_step: 1, u_direction: [0, 1], - }; + }), }, - }, ], + buildPasses: ({ effectParams, width, height }) => { + const intensity = parseIntensity(effectParams); + return buildGaussianBlurPasses({ + sigmaX: intensityToSigma(intensity, width, 1920), + sigmaY: intensityToSigma(intensity, height, 1080), + }); + }, }, }; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts index 1742b1d7..edc8c5e2 100644 --- a/apps/web/src/lib/effects/definitions/index.ts +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -1,13 +1,13 @@ -import { hasEffect, registerEffect } from "../registry"; +import { effectsRegistry } from "../registry"; import { blurEffectDefinition } from "./blur"; const defaultEffects = [blurEffectDefinition]; export function registerDefaultEffects(): void { for (const definition of defaultEffects) { - if (hasEffect({ effectType: definition.type })) { + if (effectsRegistry.has(definition.type)) { continue; } - registerEffect({ definition }); + effectsRegistry.register(definition.type, definition); } } diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts index 14d06d84..549811f5 100644 --- a/apps/web/src/lib/effects/index.ts +++ b/apps/web/src/lib/effects/index.ts @@ -1,29 +1,42 @@ import { generateUUID } from "@/utils/id"; -import { getEffect } from "./registry"; -import type { Effect, EffectParamValues } from "@/types/effects"; -import type { VisualElement } from "@/types/timeline"; +import { buildDefaultParamValues } from "@/lib/registry"; +import { effectsRegistry } from "./registry"; +import type { ParamValues } from "@/lib/params"; +import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types"; +import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline"; -export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry"; +export { effectsRegistry } from "./registry"; export { registerDefaultEffects } from "./definitions"; -export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [ - "video", - "image", - "text", - "sticker", -]; +export function resolveEffectPasses({ + definition, + effectParams, + width, + height, +}: { + definition: EffectDefinition; + effectParams: ParamValues; + width: number; + height: number; +}): ResolvedEffectPass[] { + if (definition.renderer.buildPasses) { + return definition.renderer.buildPasses({ effectParams, width, height }); + } + return definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ effectParams, width, height }), + })); +} + +export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES; export function buildDefaultEffectInstance({ effectType, }: { effectType: string; }): Effect { - const definition = getEffect({ effectType }); - - const params: EffectParamValues = {}; - for (const paramDef of definition.params) { - params[paramDef.key] = paramDef.default; - } + const definition = effectsRegistry.get(effectType); + const params: ParamValues = buildDefaultParamValues(definition.params); return { id: generateUUID(), diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts index 568b8924..2b85007a 100644 --- a/apps/web/src/lib/effects/registry.ts +++ b/apps/web/src/lib/effects/registry.ts @@ -1,31 +1,10 @@ -import type { EffectDefinition } from "@/types/effects"; +import { DefinitionRegistry } from "@/lib/registry"; +import type { EffectDefinition } from "@/lib/effects/types"; -const effectDefinitions = new Map(); - -export function registerEffect({ - definition, -}: { - definition: EffectDefinition; -}): void { - effectDefinitions.set(definition.type, definition); -} - -export function hasEffect({ effectType }: { effectType: string }): boolean { - return effectDefinitions.has(effectType); -} - -export function getEffect({ - effectType, -}: { - effectType: string; -}): EffectDefinition { - const definition = effectDefinitions.get(effectType); - if (!definition) { - throw new Error(`Unknown effect type: ${effectType}`); +export class EffectsRegistry extends DefinitionRegistry { + constructor() { + super("effect"); } - return definition; } -export function getAllEffects(): EffectDefinition[] { - return Array.from(effectDefinitions.values()); -} +export const effectsRegistry = new EffectsRegistry(); diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts new file mode 100644 index 00000000..7f85389f --- /dev/null +++ b/apps/web/src/lib/effects/types.ts @@ -0,0 +1,42 @@ +import type { ParamDefinition, ParamValues } from "@/lib/params"; + +export interface Effect { + id: string; + type: string; + params: ParamValues; + enabled: boolean; +} + +export interface ResolvedEffectPass { + fragmentShader: string; + uniforms: Record; +} + +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/export.ts b/apps/web/src/lib/export.ts index ae68144b..68d2bc7f 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -1,5 +1,36 @@ import { EXPORT_MIME_TYPES } from "@/constants/export-constants"; -import type { ExportFormat } from "@/types/export"; + +export const EXPORT_QUALITY_VALUES = [ + "low", + "medium", + "high", + "very_high", +] as const; + +export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const; + +export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number]; +export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number]; + +export interface ExportOptions { + format: ExportFormat; + quality: ExportQuality; + fps?: number; + includeAudio?: boolean; +} + +export interface ExportResult { + success: boolean; + buffer?: ArrayBuffer; + error?: string; + cancelled?: boolean; +} + +export interface ExportState { + isExporting: boolean; + progress: number; + result: ExportResult | null; +} export function getExportMimeType({ format, diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts index 7e55dffd..580ce406 100644 --- a/apps/web/src/lib/fonts/google-fonts.ts +++ b/apps/web/src/lib/fonts/google-fonts.ts @@ -1,14 +1,16 @@ -import type { FontAtlas } from "@/types/fonts"; +import type { FontAtlas } from "@/lib/fonts/types"; import { SYSTEM_FONTS } from "@/constants/font-constants"; const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2"; +const FONT_ATLAS_PATH = "/fonts/font-atlas.json"; +const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-"; const fullLoaded = new Set(); let cachedAtlas: FontAtlas | null = null; let atlasFetchPromise: Promise | null = null; -function encodeFamily(family: string): string { +function encodeGoogleFontsFamily(family: string): string { return family.replace(/ /g, "+"); } @@ -19,17 +21,19 @@ export function getCachedFontAtlas(): FontAtlas | null { export function clearFontAtlasCache(): void { cachedAtlas = null; atlasFetchPromise = null; + fullLoaded.clear(); } -async function fetchAtlas(): Promise { - if (cachedAtlas) return cachedAtlas; +export function loadFontAtlas(): Promise { + if (cachedAtlas) return Promise.resolve(cachedAtlas); if (atlasFetchPromise) return atlasFetchPromise; - atlasFetchPromise = fetch("/fonts/font-atlas.json") + atlasFetchPromise = fetch(FONT_ATLAS_PATH) .then(async (response) => { if (!response.ok) return null; const data: FontAtlas = await response.json(); cachedAtlas = data; + preloadChunkImages({ atlas: data }); return data; }) .catch(() => null); @@ -42,18 +46,12 @@ function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void { ...Object.values(atlas.fonts).map((entry) => entry.ch), ); for (let i = 0; i <= maxChunk; i++) { + // hint browser to preload chunk images without blocking const img = new Image(); - img.src = `/fonts/font-chunk-${i}.avif`; + img.src = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`; } } -export function prefetchFontAtlas(): Promise { - return fetchAtlas().then((atlas) => { - if (atlas) preloadChunkImages({ atlas }); - return atlas; - }); -} - export async function loadFullFont({ family, weights = [400, 700], @@ -63,7 +61,7 @@ export async function loadFullFont({ }): Promise { if (fullLoaded.has(family)) return; - const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`; + const url = `${GOOGLE_FONTS_CSS}?family=${encodeGoogleFontsFamily(family)}:wght@${weights.join(";")}&display=swap`; const link = document.createElement("link"); link.rel = "stylesheet"; link.href = url; diff --git a/apps/web/src/lib/fonts/types.ts b/apps/web/src/lib/fonts/types.ts new file mode 100644 index 00000000..a2db6f91 --- /dev/null +++ b/apps/web/src/lib/fonts/types.ts @@ -0,0 +1,24 @@ +export interface FontOption { + value: string; + label: string; + category: "system" | "google" | "custom"; + weights?: number[]; + hasClassName?: boolean; +} + +export interface GoogleFontMeta { + family: string; + category: string; +} + +export interface FontAtlasEntry { + x: number; + y: number; + w: number; + ch: number; + s: string[]; +} + +export interface FontAtlas { + fonts: Record; +} diff --git a/apps/web/src/lib/graphics/definitions/ellipse.ts b/apps/web/src/lib/graphics/definitions/ellipse.ts new file mode 100644 index 00000000..3fd76608 --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/ellipse.ts @@ -0,0 +1,73 @@ +import type { ParamDefinition } from "@/lib/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface EllipseParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; +} + +const ELLIPSE_PARAMS: ParamDefinition[] = [ + { + 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 new file mode 100644 index 00000000..e791f917 --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/index.ts @@ -0,0 +1,29 @@ +import { graphicsRegistry } from "../registry"; +import { ellipseGraphicDefinition } from "./ellipse"; +import { polygonGraphicDefinition } from "./polygon"; +import { rectangleGraphicDefinition } from "./rectangle"; +import { starGraphicDefinition } from "./star"; + +const defaultGraphicDefinitions = [ + rectangleGraphicDefinition, + ellipseGraphicDefinition, + polygonGraphicDefinition, + starGraphicDefinition, +]; + +export function registerDefaultGraphics(): void { + for (const definition of defaultGraphicDefinitions) { + if (graphicsRegistry.has(definition.id)) { + continue; + } + graphicsRegistry.register(definition.id, definition); + } +} + +export { + ellipseGraphicDefinition, + polygonGraphicDefinition, + rectangleGraphicDefinition, + starGraphicDefinition, +}; +export { STROKE_ALIGN_PARAM } from "./shared"; diff --git a/apps/web/src/lib/graphics/definitions/polygon.ts b/apps/web/src/lib/graphics/definitions/polygon.ts new file mode 100644 index 00000000..780c974c --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/polygon.ts @@ -0,0 +1,211 @@ +import type { ParamDefinition } from "@/lib/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface Point { + x: number; + y: number; +} + +interface PolygonParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + sides: number; + cornerRadius: number; +} + +const POLYGON_PARAMS: ParamDefinition[] = [ + { + 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 new file mode 100644 index 00000000..4e7f78f6 --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/rectangle.ts @@ -0,0 +1,85 @@ +import type { ParamDefinition } from "@/lib/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface RectangleParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + cornerRadius: number; +} + +const RECTANGLE_PARAMS: ParamDefinition[] = [ + { + 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 new file mode 100644 index 00000000..d2833f51 --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/shared.ts @@ -0,0 +1,16 @@ +import type { ParamDefinition } from "@/lib/params"; + +export type GraphicStrokeAlign = "inside" | "center" | "outside"; + +export const STROKE_ALIGN_PARAM: ParamDefinition<"strokeAlign"> = { + key: "strokeAlign", + label: "Stroke align", + type: "select", + default: "center", + group: "stroke", + options: [ + { value: "inside", label: "Inside" }, + { value: "center", label: "Center" }, + { value: "outside", label: "Outside" }, + ], +}; diff --git a/apps/web/src/lib/graphics/definitions/star.ts b/apps/web/src/lib/graphics/definitions/star.ts new file mode 100644 index 00000000..c7f9ef56 --- /dev/null +++ b/apps/web/src/lib/graphics/definitions/star.ts @@ -0,0 +1,109 @@ +import type { ParamDefinition } from "@/lib/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface StarParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + points: number; + depth: number; +} + +const STAR_PARAMS: ParamDefinition[] = [ + { + 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 new file mode 100644 index 00000000..5286e932 --- /dev/null +++ b/apps/web/src/lib/graphics/index.ts @@ -0,0 +1,125 @@ +import { buildDefaultParamValues } from "@/lib/registry"; +import type { ParamValues } from "@/lib/params"; +import { graphicsRegistry } from "./registry"; +import { + registerDefaultGraphics, + ellipseGraphicDefinition, + polygonGraphicDefinition, + rectangleGraphicDefinition, + starGraphicDefinition, +} from "./definitions"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + type GraphicInstance, + type GraphicDefinition, +} from "./types"; + +const graphicPreviewUrlCache = new Map(); + +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 new file mode 100644 index 00000000..7f2ab9fd --- /dev/null +++ b/apps/web/src/lib/graphics/registry.ts @@ -0,0 +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(); diff --git a/apps/web/src/lib/graphics/stroke.ts b/apps/web/src/lib/graphics/stroke.ts new file mode 100644 index 00000000..98f9f43f --- /dev/null +++ b/apps/web/src/lib/graphics/stroke.ts @@ -0,0 +1,102 @@ +import type { GraphicStrokeAlign } from "./definitions/shared"; + +type GraphicRenderContext = + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + +function createTempCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} + +function applyStroke({ + ctx, + path, + strokeWidth, + strokeColor, +}: { + ctx: GraphicRenderContext; + path: Path2D; + strokeWidth: number; + strokeColor: string; +}) { + ctx.strokeStyle = strokeColor; + ctx.lineWidth = strokeWidth; + ctx.stroke(path); +} + +export function applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor, +}: { + ctx: GraphicRenderContext; + path: Path2D; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + strokeColor: string; +}): void { + if (strokeWidth <= 0) { + return; + } + + if (strokeAlign === "inside") { + ctx.save(); + ctx.clip(path); + applyStroke({ + ctx, + path, + strokeWidth: strokeWidth * 2, + strokeColor, + }); + ctx.restore(); + return; + } + + if (strokeAlign === "outside") { + const strokeCanvas = createTempCanvas({ + width: ctx.canvas.width, + height: ctx.canvas.height, + }); + const strokeCtx = strokeCanvas.getContext("2d") as GraphicRenderContext | null; + if (!strokeCtx) { + return; + } + + applyStroke({ + ctx: strokeCtx, + path, + strokeWidth: strokeWidth * 2, + strokeColor, + }); + + // Keep only the outer half of the doubled stroke so alpha fills do not + // leave a visible inner stroke behind. + strokeCtx.globalCompositeOperation = "destination-out"; + strokeCtx.fill(path); + + ctx.drawImage(strokeCanvas, 0, 0); + return; + } + + applyStroke({ + ctx, + path, + strokeWidth, + strokeColor, + }); +} diff --git a/apps/web/src/lib/graphics/types.ts b/apps/web/src/lib/graphics/types.ts new file mode 100644 index 00000000..af932d39 --- /dev/null +++ b/apps/web/src/lib/graphics/types.ts @@ -0,0 +1,23 @@ +import type { ParamDefinition, ParamValues } from "@/lib/params"; + +export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512; + +export interface GraphicRenderContext { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + params: ParamValues; + width: number; + height: number; +} + +export interface GraphicDefinition { + id: string; + name: string; + keywords: string[]; + params: ParamDefinition[]; + render(context: GraphicRenderContext): void; +} + +export interface GraphicInstance { + definitionId: string; + params: ParamValues; +} diff --git a/apps/web/src/lib/guides/definitions/custom.tsx b/apps/web/src/lib/guides/definitions/custom.tsx new file mode 100644 index 00000000..bc92cb84 --- /dev/null +++ b/apps/web/src/lib/guides/definitions/custom.tsx @@ -0,0 +1,24 @@ +import { PlusSignIcon, RulerIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@/components/ui/button"; +import type { GuideDefinition } from "@/lib/guides/types"; + +function CustomGuideOptions() { + return ( +
+ +
+ ); +} + +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/grid.tsx b/apps/web/src/lib/guides/definitions/grid.tsx new file mode 100644 index 00000000..0e7a2004 --- /dev/null +++ b/apps/web/src/lib/guides/definitions/grid.tsx @@ -0,0 +1,125 @@ +import { + GridTableIcon, + LayoutThreeColumnIcon, + LayoutThreeRowIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { NumberField } from "@/components/ui/number-field"; +import { + GRID_MIN, + GRID_MAX, + DEFAULT_GRID_CONFIG, +} from "@/constants/guide-constants"; +import { usePreviewStore } from "@/stores/preview-store"; +import { clampRound } from "@/utils/math"; +import { cn } from "@/utils/ui"; +import type { GuideDefinition } from "@/lib/guides/types"; + +function GridLines({ + rows, + cols, + color, +}: { + rows: number; + cols: number; + color: string; +}) { + const verticals = Array.from( + { length: cols - 1 }, + (_, i) => ((i + 1) / cols) * 100, + ); + const horizontals = Array.from( + { length: rows - 1 }, + (_, i) => ((i + 1) / rows) * 100, + ); + + return ( + <> + {verticals.map((pct) => ( +
+ ))} + {horizontals.map((pct) => ( +
+ ))} + + ); +} + +function GridGuidePreview() { + return ( +
+ +
+ ); +} + +function GridGuideOverlay() { + const { rows, cols } = usePreviewStore((s) => s.gridConfig); + + return ( +
+ +
+ ); +} + +function GridGuideOptions() { + const rows = usePreviewStore((s) => s.gridConfig.rows); + const cols = usePreviewStore((s) => s.gridConfig.cols); + const setGridConfig = usePreviewStore((s) => s.setGridConfig); + + const clampGridValue = (value: number) => + clampRound({ value, min: GRID_MIN, max: GRID_MAX }); + + return ( +
+ } + value={rows} + min={GRID_MIN} + max={GRID_MAX} + isDefault={rows === DEFAULT_GRID_CONFIG.rows} + onReset={() => setGridConfig({ rows: DEFAULT_GRID_CONFIG.rows })} + onScrub={(value) => setGridConfig({ rows: clampGridValue(value) })} + onChange={(event) => { + const parsed = Number.parseInt(event.target.value, 10); + if (!Number.isNaN(parsed)) + setGridConfig({ rows: clampGridValue(parsed) }); + }} + className="flex-1" + /> + } + value={cols} + min={GRID_MIN} + max={GRID_MAX} + isDefault={cols === DEFAULT_GRID_CONFIG.cols} + onReset={() => setGridConfig({ cols: DEFAULT_GRID_CONFIG.cols })} + onScrub={(value) => setGridConfig({ cols: clampGridValue(value) })} + onChange={(event) => { + const parsed = Number.parseInt(event.target.value, 10); + if (!Number.isNaN(parsed)) + setGridConfig({ cols: clampGridValue(parsed) }); + }} + className="flex-1" + /> +
+ ); +} + +export const gridGuide = { + id: "grid", + label: "Grid", + renderPreview: () => , + renderTriggerIcon: () => , + renderOverlay: () => , + 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 new file mode 100644 index 00000000..ffeec1c0 --- /dev/null +++ b/apps/web/src/lib/guides/definitions/platforms.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; +import type { GuideDefinition } from "@/lib/guides/types"; +import { TikTokLayout } from "./tiktok-layout"; + +function PlatformLogo({ + domain, + className = "size-4", +}: { + domain: string; + className?: string; +}) { + return ( + + ); +} + +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 new file mode 100644 index 00000000..794d1379 --- /dev/null +++ b/apps/web/src/lib/guides/definitions/tiktok-layout.tsx @@ -0,0 +1,335 @@ +export function TikTokLayout() { + return ( + + TikTok Layout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/lib/guides/index.ts b/apps/web/src/lib/guides/index.ts new file mode 100644 index 00000000..007e6fda --- /dev/null +++ b/apps/web/src/lib/guides/index.ts @@ -0,0 +1,13 @@ +import { GUIDE_REGISTRY } from "./registry"; +import type { GuideDefinition } from "@/lib/guides/types"; + +export { GUIDE_REGISTRY, isGuideId } from "./registry"; +export type { GuideDefinition, GuideId, GuideRenderProps } from "./registry"; + +export function getGuideById(guideId: string | null): GuideDefinition | null { + if (!guideId) { + return null; + } + + return GUIDE_REGISTRY.find((guide) => guide.id === guideId) ?? null; +} diff --git a/apps/web/src/lib/guides/registry.tsx b/apps/web/src/lib/guides/registry.tsx new file mode 100644 index 00000000..60dec428 --- /dev/null +++ b/apps/web/src/lib/guides/registry.tsx @@ -0,0 +1,28 @@ +import type { GuideDefinition } from "@/lib/guides/types"; +import { gridGuide } from "./definitions/grid"; +// import { customGuide } from "./definitions/custom"; +import { + tiktokGuide, + igReelsGuide, + ytShortsGuide, + spotlightGuide, +} from "./definitions/platforms"; + +export type { GuideDefinition, GuideRenderProps } from "@/lib/guides/types"; + +export const GUIDE_REGISTRY = [ + gridGuide, + tiktokGuide, + igReelsGuide, + ytShortsGuide, + spotlightGuide, + + // todo: wire up custom guide fully, then uncomment this: + // customGuide, +] as const satisfies readonly GuideDefinition[]; + +export type GuideId = (typeof GUIDE_REGISTRY)[number]["id"]; + +export function isGuideId(value: string): value is GuideId { + return GUIDE_REGISTRY.some((guide) => guide.id === value); +} diff --git a/apps/web/src/lib/guides/types.ts b/apps/web/src/lib/guides/types.ts new file mode 100644 index 00000000..340101ac --- /dev/null +++ b/apps/web/src/lib/guides/types.ts @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export interface GridConfig { + rows: number; + cols: number; +} + +export interface GuideRenderProps { + width: number; + height: number; +} + +export interface GuideDefinition { + id: string; + label: string; + renderPreview: () => ReactNode; + renderTriggerIcon: () => ReactNode; + renderOverlay: (props: GuideRenderProps) => ReactNode; + renderOptions?: () => ReactNode; +} diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts deleted file mode 100644 index e4afe111..00000000 --- a/apps/web/src/lib/iconify-api.ts +++ /dev/null @@ -1,241 +0,0 @@ -export const ICONIFY_HOSTS = [ - "https://api.iconify.design", - "https://api.simplesvg.com", - "https://api.unisvg.com", -]; - -let currentHost = ICONIFY_HOSTS[0]; - -async function fetchWithFallback(path: string): Promise { - for (const host of ICONIFY_HOSTS) { - try { - const response = await fetch(`${host}${path}`, { - signal: AbortSignal.timeout(2000), - }); - if (response.ok) { - currentHost = host; - return response; - } - } catch (error) { - console.warn(`Failed to fetch from ${host}:`, error); - } - } - throw new Error("All API hosts failed"); -} - -export interface IconSet { - prefix: string; - name: string; - total: number; - author?: { - name: string; - url?: string; - }; - license?: { - title: string; - spdx?: string; - url?: string; - }; - samples?: string[]; - category?: string; - palette?: boolean; -} - -export interface IconSearchResult { - icons: string[]; - total: number; - limit: number; - start: number; - collections: Record; -} - -export interface CollectionInfo { - prefix: string; - total: number; - title?: string; - uncategorized?: string[]; - categories?: Record; - hidden?: string[]; - aliases?: Record; -} - -export async function getCollections( - category?: string, -): Promise> { - try { - const response = await fetchWithFallback("/collections?pretty=1"); - const data = (await response.json()) as Record; - - if (category) { - const filtered = Object.fromEntries( - Object.entries(data).filter( - ([_key, info]) => info.category === category, - ), - ) as Record; - return filtered; - } - - return data; - } catch (error) { - console.error("Failed to fetch collections:", error); - return {}; - } -} - -export async function getCollection( - prefix: string, -): Promise { - try { - const response = await fetchWithFallback( - `/collection?prefix=${prefix}&pretty=1`, - ); - return await response.json(); - } catch (error) { - console.error(`Failed to fetch collection ${prefix}:`, error); - return null; - } -} - -export async function searchIcons( - query: string, - limit: number = 64, - prefixes?: string[], - category?: string, -): Promise { - const params = new URLSearchParams({ - query, - limit: limit.toString(), - pretty: "1", - }); - - if (prefixes?.length) { - params.append("prefixes", prefixes.join(",")); - } - - if (category) { - params.append("category", category); - } - - try { - const response = await fetchWithFallback(`/search?${params}`); - return await response.json(); - } catch (error) { - console.error("Failed to search icons:", error); - return { - icons: [], - total: 0, - limit, - start: 0, - collections: {}, - }; - } -} - -export function buildIconSvgUrl( - host: string, - iconName: string, - params?: { - color?: string; - width?: number; - height?: number; - flip?: "horizontal" | "vertical" | "horizontal,vertical"; - rotate?: number | string; - }, -): string { - const [prefix, name] = iconName.includes(":") - ? iconName.split(":") - : ["", iconName]; - - if (!prefix || !name) { - throw new Error('Invalid icon name format. Expected "prefix:name"'); - } - - const urlParams = new URLSearchParams(); - - if (params?.color) { - urlParams.append("color", params.color.replace("#", "%23")); - } - - if (params?.width) { - urlParams.append("width", params.width.toString()); - } - - if (params?.height) { - urlParams.append("height", params.height.toString()); - } - - if (params?.flip) { - urlParams.append("flip", params.flip); - } - - if (params?.rotate) { - urlParams.append("rotate", params.rotate.toString()); - } - - const queryString = urlParams.toString(); - return `${host}/${prefix}/${name}.svg${queryString ? `?${queryString}` : ""}`; -} - -export function getIconSvgUrl( - iconName: string, - params?: Parameters[2], -): string { - return buildIconSvgUrl(currentHost, iconName, params); -} - -export async function downloadSvgAsText( - iconName: string, - params?: Parameters[1], -): Promise { - const url = getIconSvgUrl(iconName, params); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to download SVG: ${response.statusText}`); - } - return await response.text(); -} - -export function svgToFile(svgText: string, fileName: string): File { - const blob = new Blob([svgText], { type: "image/svg+xml" }); - return new File([blob], fileName, { type: "image/svg+xml" }); -} - -export const POPULAR_COLLECTIONS = { - general: [ - { prefix: "mdi", name: "Material Design Icons" }, - { prefix: "ic", name: "Google Material Icons" }, - { prefix: "ph", name: "Phosphor" }, - { prefix: "heroicons", name: "Heroicons" }, - { prefix: "lucide", name: "Lucide" }, - { prefix: "tabler", name: "Tabler Icons" }, - { prefix: "fe", name: "Feather Icons" }, - { prefix: "bi", name: "Bootstrap Icons" }, - ], - brands: [ - { prefix: "simple-icons", name: "Simple Icons" }, - { prefix: "logos", name: "SVG Logos" }, - { prefix: "skill-icons", name: "Skill Icons" }, - { prefix: "devicon", name: "Devicon" }, - { prefix: "fa-brands", name: "Font Awesome Brands" }, - ], - emoji: [ - { prefix: "noto", name: "Noto Emoji" }, - { prefix: "twemoji", name: "Twemoji" }, - { prefix: "fluent-emoji", name: "Fluent Emoji" }, - { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" }, - { prefix: "emojione", name: "EmojiOne" }, - { prefix: "openmoji", name: "OpenMoji" }, - ], -}; - -export function getCategoriesFromCollections( - collections: Record, -): string[] { - const categories = new Set(); - Object.values(collections).forEach((collection) => { - if (collection.category) { - categories.add(collection.category); - } - }); - return Array.from(categories).sort(); -} diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts new file mode 100644 index 00000000..3ec96344 --- /dev/null +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test"; +import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; +import { getMaskSnapGeometry } from "@/lib/masks/geometry"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial = {}, +): SplitMaskParams { + 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 new file mode 100644 index 00000000..0436151e --- /dev/null +++ b/apps/web/src/lib/masks/definitions/box-like.ts @@ -0,0 +1,270 @@ +import { + DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, + MIN_MASK_DIMENSION, +} from "@/constants/mask-constants"; +import { computeFeatherUpdate } from "../param-update"; +import type { + BaseMaskParams, + MaskDefaultContext, + MaskParamUpdateArgs, + RectangleMaskParams, +} from "@/lib/masks/types"; +import type { + NumberParamDefinition, + ParamDefinition, + ParamValues, +} from "@/lib/params"; + +const PERCENTAGE_DISPLAY: Pick< + NumberParamDefinition, + "displayMultiplier" | "step" +> = { + displayMultiplier: 100, + step: 1, +}; + +export const BOX_LIKE_MASK_PARAMS: ParamDefinition< + keyof RectangleMaskParams & string +>[] = [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "width", + label: "Width", + type: "number", + default: 0.6, + min: 1, + ...PERCENTAGE_DISPLAY, + }, + { + key: "height", + label: "Height", + type: "number", + default: 0.6, + min: 1, + ...PERCENTAGE_DISPLAY, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + { + key: "scale", + label: "Scale", + type: "number", + default: 1, + min: 1, + max: 500, + ...PERCENTAGE_DISPLAY, + }, + { + key: "strokeAlign", + label: "Stroke Align", + type: "select", + default: "center", + options: [ + { value: "inside", label: "Inside" }, + { value: "center", label: "Center" }, + { value: "outside", label: "Outside" }, + ], + }, +]; + +export function getDefaultBaseMaskParams(): BaseMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + }; +} + +export function getStrokeOffset({ + strokeAlign, + strokeWidth, +}: Pick): 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 new file mode 100644 index 00000000..45d0a7c0 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/ellipse.ts @@ -0,0 +1,72 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "./box-like"; + +export const ellipseMaskDefinition: MaskDefinition = { + 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 new file mode 100644 index 00000000..c2affe30 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/index.ts @@ -0,0 +1,39 @@ +import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; +import { masksRegistry, type MaskIconProps } from "../registry"; +import { ellipseMaskDefinition } from "./ellipse"; +import { rectangleMaskDefinition } from "./rectangle"; +import { splitMaskDefinition } from "./split"; +import { + PanelRightDashedIcon, + SquareIcon, + CircleIcon, +} from "@hugeicons/core-free-icons"; + +function registerDefaultMask({ + 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 new file mode 100644 index 00000000..5b7f84a9 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/rectangle.ts @@ -0,0 +1,94 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "./box-like"; + +function buildRectanglePath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const corners = [ + { x: centerX - halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY + halfHeight }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(corners[0].x, corners[0].y); + for (const corner of corners.slice(1)) { + path.lineTo(corner.x, corner.y); + } + path.closePath(); + return path; +} + +export const rectangleMaskDefinition: MaskDefinition = { + 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 new file mode 100644 index 00000000..43f02db1 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/split.ts @@ -0,0 +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; + }, + }, +}; diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts new file mode 100644 index 00000000..c6d386f8 --- /dev/null +++ b/apps/web/src/lib/masks/geometry.ts @@ -0,0 +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, + }, + ); +} + diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts new file mode 100644 index 00000000..753b17ce --- /dev/null +++ b/apps/web/src/lib/masks/handle-positions.ts @@ -0,0 +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, + }); +} diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts new file mode 100644 index 00000000..bc9f20b0 --- /dev/null +++ b/apps/web/src/lib/masks/index.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts new file mode 100644 index 00000000..967a4be0 --- /dev/null +++ b/apps/web/src/lib/masks/param-update.ts @@ -0,0 +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), + }); +} diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts new file mode 100644 index 00000000..5a98cb41 --- /dev/null +++ b/apps/web/src/lib/masks/registry.ts @@ -0,0 +1,70 @@ +import { MAX_FEATHER } from "@/constants/mask-constants"; +import type { ParamDefinition } from "@/lib/params"; +import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types"; +import type { HugeiconsIconProps } from "@hugeicons/react"; +import { DefinitionRegistry } from "@/lib/registry"; + +export type MaskIconProps = { + icon: HugeiconsIconProps["icon"]; + strokeWidth?: number; +}; + +const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< + keyof BaseMaskParams & string +>[] = [ + { + key: "feather", + label: "Feather", + type: "number", + default: 0, + min: 0, + max: MAX_FEATHER, + step: 1, + unit: "percent", + }, + { + key: "strokeWidth", + label: "Stroke width", + type: "number", + default: 0, + min: 0, + max: 100, + step: 1, + }, + { + key: "strokeColor", + label: "Stroke color", + type: "color", + default: "#ffffff", + }, +]; + +export type RegisteredMaskDefinition = MaskDefinition & { + icon: MaskIconProps; +}; + +export class MasksRegistry extends DefinitionRegistry< + MaskType, + RegisteredMaskDefinition +> { + constructor() { + super("mask"); + } + + registerMask({ + definition, + icon, + }: { + definition: MaskDefinition; + icon: MaskIconProps; + }): void { + const withBaseParams: RegisteredMaskDefinition = { + ...definition, + params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], + icon, + }; + this.register(definition.type, withBaseParams); + } +} + +export const masksRegistry = new MasksRegistry(); diff --git a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl new file mode 100644 index 00000000..4ba2c5a1 --- /dev/null +++ b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl @@ -0,0 +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); +} diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts new file mode 100644 index 00000000..83cc0e99 --- /dev/null +++ b/apps/web/src/lib/masks/snap.ts @@ -0,0 +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, + }); +} + diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts new file mode 100644 index 00000000..569ff0ff --- /dev/null +++ b/apps/web/src/lib/masks/types.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts new file mode 100644 index 00000000..a9110237 --- /dev/null +++ b/apps/web/src/lib/masks/utils.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts new file mode 100644 index 00000000..29af223b --- /dev/null +++ b/apps/web/src/lib/media/audio-mastering.ts @@ -0,0 +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]), + ); + } + } +} diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index e96f158b..41d3b38e 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -1,24 +1,44 @@ import type { AudioElement, LibraryAudioElement, - TimelineElement, + RetimeConfig, TimelineTrack, -} from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; +} from "@/lib/timeline"; +import { shouldMaintainPitch } from "@/constants/retime-constants"; +import type { MediaAsset } from "@/lib/media/types"; +import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering"; +import type { AudioCapableElement } from "@/lib/timeline/audio-state"; +import { + hasAnimatedVolume, + resolveEffectiveAudioGain, +} from "@/lib/timeline/audio-state"; import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { canTracktHaveAudio } from "@/lib/timeline"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime"; import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; const MAX_AUDIO_CHANNELS = 2; const EXPORT_SAMPLE_RATE = 44100; +const COARSE_SAMPLE_COUNT = 2048; -export type CollectedAudioElement = Omit< - AudioElement, - "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl" -> & { buffer: AudioBuffer }; +export interface CollectedAudioElement { + timelineElement: AudioCapableElement; + buffer: AudioBuffer; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + volume: number; + muted: boolean; + retime?: RetimeConfig; +} -export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext { +export function createAudioContext({ + sampleRate, +}: { + sampleRate?: number; +} = {}): AudioContext { const AudioContextConstructor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }) @@ -90,13 +110,21 @@ export async function collectAudioElements({ audioContext, }).then((audioBuffer) => { if (!audioBuffer) return null; + const muted = element.muted === true || isTrackMuted; return { + timelineElement: element, buffer: audioBuffer, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, - muted: element.muted || isTrackMuted, + volume: resolveEffectiveAudioGain({ + element, + trackMuted: isTrackMuted, + localTime: 0, + }), + muted, + retime: element.retime, }; }), ); @@ -113,14 +141,21 @@ export async function collectAudioElements({ audioContext, }).then((audioBuffer) => { if (!audioBuffer) return null; - const elementMuted = element.muted ?? false; + const muted = (element.muted ?? false) || isTrackMuted; return { + timelineElement: element, buffer: audioBuffer, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, - muted: elementMuted || isTrackMuted, + volume: resolveEffectiveAudioGain({ + element, + trackMuted: isTrackMuted, + localTime: 0, + }), + muted, + retime: element.retime, }; }), ); @@ -199,7 +234,10 @@ async function resolveAudioBufferForVideoElement({ if (chunks.length === 0) return null; const nativeSampleRate = chunks[0].sampleRate; - const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels); + const numChannels = Math.min( + MAX_AUDIO_CHANNELS, + chunks[0].numberOfChannels, + ); const nativeChannels = Array.from( { length: numChannels }, @@ -208,17 +246,29 @@ async function resolveAudioBufferForVideoElement({ let offset = 0; for (const chunk of chunks) { for (let channel = 0; channel < numChannels; channel++) { - const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)); + const sourceData = chunk.getChannelData( + Math.min(channel, chunk.numberOfChannels - 1), + ); nativeChannels[channel].set(sourceData, offset); } offset += chunk.length; } // use OfflineAudioContext for high-quality resampling to target rate - const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate)); - const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate); + const outputSamples = Math.ceil( + totalSamples * (targetSampleRate / nativeSampleRate), + ); + const offlineContext = new OfflineAudioContext( + numChannels, + outputSamples, + targetSampleRate, + ); - const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate); + const nativeBuffer = audioContext.createBuffer( + numChannels, + totalSamples, + nativeSampleRate, + ); for (let ch = 0; ch < numChannels; ch++) { nativeBuffer.copyToChannel(nativeChannels[ch], ch); } @@ -238,14 +288,18 @@ async function resolveAudioBufferForVideoElement({ } interface AudioMixSource { + timelineElement: AudioCapableElement; file: File; startTime: number; duration: number; trimStart: number; trimEnd: number; + volume: number; + retime?: RetimeConfig; } export interface AudioClipSource { + timelineElement: AudioCapableElement; id: string; sourceKey: string; file: File; @@ -253,13 +307,17 @@ export interface AudioClipSource { duration: number; trimStart: number; trimEnd: number; + volume: number; muted: boolean; + retime?: RetimeConfig; } async function fetchLibraryAudioSource({ element, + volume, }: { element: LibraryAudioElement; + volume: number; }): Promise { try { const response = await fetch(element.sourceUrl); @@ -273,11 +331,14 @@ async function fetchLibraryAudioSource({ }); return { + timelineElement: element, file, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, + volume, + retime: element.retime, }; } catch (error) { console.warn("Failed to fetch library audio:", error); @@ -288,9 +349,11 @@ async function fetchLibraryAudioSource({ async function fetchLibraryAudioClip({ element, muted, + volume, }: { element: LibraryAudioElement; muted: boolean; + volume: number; }): Promise { try { const response = await fetch(element.sourceUrl); @@ -304,6 +367,7 @@ async function fetchLibraryAudioClip({ }); return { + timelineElement: element, id: element.id, sourceKey: element.id, file, @@ -311,7 +375,9 @@ async function fetchLibraryAudioClip({ duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, + volume, muted, + retime: element.retime, }; } catch (error) { console.warn("Failed to fetch library audio:", error); @@ -322,16 +388,21 @@ async function fetchLibraryAudioClip({ function collectMediaAudioSource({ element, mediaAsset, + volume, }: { - element: TimelineElement; + element: AudioCapableElement; mediaAsset: MediaAsset; + volume: number; }): AudioMixSource { return { + timelineElement: element, file: mediaAsset.file, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, + volume, + retime: element.retime, }; } @@ -339,12 +410,15 @@ function collectMediaAudioClip({ element, mediaAsset, muted, + volume, }: { - element: TimelineElement; + element: AudioCapableElement; mediaAsset: MediaAsset; muted: boolean; + volume: number; }): AudioClipSource { return { + timelineElement: element, id: element.id, sourceKey: mediaAsset.id, file: mediaAsset.file, @@ -352,7 +426,9 @@ function collectMediaAudioClip({ duration: element.duration, trimStart: element.trimStart, trimEnd: element.trimEnd, + volume, muted, + retime: element.retime, }; } @@ -374,6 +450,11 @@ export async function collectAudioMixSources({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; + if (element.muted === true) continue; + const volume = resolveEffectiveAudioGain({ + element, + localTime: 0, + }); if (element.type === "audio") { if (element.sourceType === "upload") { @@ -381,10 +462,12 @@ export async function collectAudioMixSources({ if (!mediaAsset) continue; audioMixSources.push( - collectMediaAudioSource({ element, mediaAsset }), + collectMediaAudioSource({ element, mediaAsset, volume }), ); } else { - pendingLibrarySources.push(fetchLibraryAudioSource({ element })); + pendingLibrarySources.push( + fetchLibraryAudioSource({ element, volume }), + ); } continue; } @@ -395,7 +478,7 @@ export async function collectAudioMixSources({ if (mediaSupportsAudio({ media: mediaAsset })) { audioMixSources.push( - collectMediaAudioSource({ element, mediaAsset }), + collectMediaAudioSource({ element, mediaAsset, volume }), ); } } @@ -432,6 +515,11 @@ export async function collectAudioClips({ const isElementMuted = "muted" in element ? (element.muted ?? false) : false; const muted = isTrackMuted || isElementMuted; + const volume = resolveEffectiveAudioGain({ + element, + trackMuted: isTrackMuted, + localTime: 0, + }); if (element.type === "audio") { if (element.sourceType === "upload") { @@ -443,10 +531,13 @@ export async function collectAudioClips({ element, mediaAsset, muted, + volume, }), ); } else { - pendingLibraryClips.push(fetchLibraryAudioClip({ element, muted })); + pendingLibraryClips.push( + fetchLibraryAudioClip({ element, muted, volume }), + ); } continue; } @@ -461,6 +552,7 @@ export async function collectAudioClips({ element, mediaAsset, muted, + volume, }), ); } @@ -510,36 +602,114 @@ export async function createTimelineAudioBuffer({ for (const element of audioElements) { if (element.muted) continue; + const renderedBuffer = shouldMaintainPitch({ + rate: element.retime?.rate ?? 1, + maintainPitch: element.retime?.maintainPitch, + }) + ? await renderRetimedBuffer({ + audioContext: context, + sourceBuffer: element.buffer, + trimStart: element.trimStart, + clipDuration: element.duration, + retime: element.retime, + maintainPitch: true, + }) + : undefined; + mixAudioChannels({ element, + buffer: renderedBuffer ?? element.buffer, + trimStart: renderedBuffer ? 0 : element.trimStart, + retime: renderedBuffer ? undefined : element.retime, outputBuffer, outputLength, sampleRate, }); } - return outputBuffer; + return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer }); +} + +export function computeGlobalMaxRms({ + buffer, +}: { + buffer: AudioBuffer; +}): number { + const channels = buffer.numberOfChannels; + const step = Math.max(1, Math.floor(buffer.length / COARSE_SAMPLE_COUNT)); + let globalMax = 0; + + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c); + for (let i = 0; i + step <= buffer.length; i += step) { + for (let j = i; j < i + step; j++) { + const abs = Math.abs(data[j]); + if (abs > globalMax) globalMax = abs; + } + } + } + + return globalMax || 1; +} + +export function extractRmsRange({ + buffer, + count, + startSample, + endSample, + globalMax, +}: { + buffer: AudioBuffer; + count: number; + startSample: number; + endSample: number; + globalMax: number; +}): number[] { + const channels = buffer.numberOfChannels; + const rangeLength = endSample - startSample; + const step = Math.max(1, Math.floor(rangeLength / count)); + const peaks = new Float32Array(count); + + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c); + for (let i = 0; i < count; i++) { + const start = startSample + i * step; + const end = Math.min(start + step, endSample); + for (let j = start; j < end; j++) { + const abs = Math.abs(data[j]); + if (abs > peaks[i]) peaks[i] = abs; + } + } + } + + const norm = 1 / globalMax; + const result = new Array(count); + for (let i = 0; i < count; i++) result[i] = Math.min(1, peaks[i] * norm); + + return result; } function mixAudioChannels({ element, + buffer, + trimStart, + retime, outputBuffer, outputLength, sampleRate, }: { element: CollectedAudioElement; + buffer: AudioBuffer; + trimStart: number; + retime?: RetimeConfig; outputBuffer: AudioBuffer; outputLength: number; sampleRate: number; }): void { - const { buffer, startTime, trimStart, duration: elementDuration } = element; + const { startTime, duration: elementDuration } = element; - const sourceStartSample = Math.floor(trimStart * buffer.sampleRate); - const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate); const outputStartSample = Math.floor(startTime * sampleRate); - - const resampleRatio = sampleRate / buffer.sampleRate; - const resampledLength = Math.floor(sourceLengthSamples * resampleRatio); + const renderedLength = Math.ceil(elementDuration * sampleRate); const outputChannels = 2; for (let channel = 0; channel < outputChannels; channel++) { @@ -547,14 +717,29 @@ function mixAudioChannels({ const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1); const sourceData = buffer.getChannelData(sourceChannel); - for (let i = 0; i < resampledLength; i++) { + for (let i = 0; i < renderedLength; i++) { const outputIndex = outputStartSample + i; if (outputIndex >= outputLength) break; - const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio); + const clipTime = i / sampleRate; + const sourceTime = + trimStart + getSourceTimeAtClipTime({ clipTime, retime }); + const sourceIndex = sourceTime * buffer.sampleRate; if (sourceIndex >= sourceData.length) break; - outputData[outputIndex] += sourceData[sourceIndex]; + const lowerIndex = Math.floor(sourceIndex); + const upperIndex = Math.min(sourceData.length - 1, lowerIndex + 1); + const fraction = sourceIndex - lowerIndex; + const gain = hasAnimatedVolume({ element: element.timelineElement }) + ? resolveEffectiveAudioGain({ + element: element.timelineElement, + localTime: clipTime, + }) + : element.volume; + outputData[outputIndex] += + (sourceData[lowerIndex] * (1 - fraction) + + sourceData[upperIndex] * fraction) * + gain; } } } diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts index 4b487bf2..52c08f0c 100644 --- a/apps/web/src/lib/media/media-utils.ts +++ b/apps/web/src/lib/media/media-utils.ts @@ -1,4 +1,4 @@ -import type { MediaAsset, MediaType } from "@/types/assets"; +import type { MediaAsset, MediaType } from "@/lib/media/types"; export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"]; diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index 11fe9837..c2d1d7f3 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,7 +1,7 @@ -import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; -import { collectAudioMixSources } from "@/lib/media/audio"; -import type { TimelineTrack } from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; +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, @@ -12,6 +12,7 @@ export async function getVideoInfo({ width: number; height: number; fps: number; + hasAudio: boolean; }> { const input = new Input({ source: new BlobSource(videoFile), @@ -27,12 +28,14 @@ export async function getVideoInfo({ 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, }; } @@ -54,12 +57,16 @@ export const extractTimelineAudio = async ({ return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) }); } - const audioMixSources = await collectAudioMixSources({ + onProgress?.(10); + + const audioBuffer = await createTimelineAudioBuffer({ tracks, mediaAssets, + duration: totalDuration, + sampleRate: SAMPLE_RATE, }); - if (audioMixSources.length === 0) { + if (!audioBuffer) { const silentDuration = Math.max(1, totalDuration); const silentSamples = new Float32Array( Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS, @@ -67,108 +74,33 @@ export const extractTimelineAudio = async ({ return createWavBlob({ samples: silentSamples }); } - const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE); - const mixBuffers = [ - new Float32Array(totalSamples), - new Float32Array(totalSamples), - ]; - - for (let i = 0; i < audioMixSources.length; i++) { - const source = audioMixSources[i]; - - if (onProgress) { - onProgress((i / audioMixSources.length) * 90); - } - - try { - await decodeAndMixAudioSource({ - source, - mixBuffers, - totalSamples, - }); - } catch (error) { - console.warn( - `Failed to process audio source ${source.file.name}:`, - error, - ); - } - } - - // clamp to prevent clipping - for (const channel of mixBuffers) { - for (let i = 0; i < channel.length; i++) { - channel[i] = Math.max(-1, Math.min(1, channel[i])); - } - } - - // interleave channels for wav output - const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS); - for (let i = 0; i < totalSamples; i++) { - interleavedSamples[i * 2] = mixBuffers[0][i]; - interleavedSamples[i * 2 + 1] = mixBuffers[1][i]; - } + onProgress?.(90); - if (onProgress) { - onProgress(100); - } + const interleavedSamples = interleaveAudioBuffer({ audioBuffer }); + onProgress?.(100); return createWavBlob({ samples: interleavedSamples }); }; -async function decodeAndMixAudioSource({ - source, - mixBuffers, - totalSamples, +function interleaveAudioBuffer({ + audioBuffer, }: { - source: { - file: File; - startTime: number; - duration: number; - trimStart: number; - }; - mixBuffers: Float32Array[]; - totalSamples: number; -}): Promise { - const input = new Input({ - source: new BlobSource(source.file), - formats: ALL_FORMATS, - }); - - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) return; - - const sink = new AudioBufferSink(audioTrack); - const trimEnd = source.trimStart + source.duration; - - for await (const { buffer, timestamp } of sink.buffers( - source.trimStart, - trimEnd, - )) { - const relativeTime = timestamp - source.trimStart; - const outputStartSample = Math.floor( - (source.startTime + relativeTime) * SAMPLE_RATE, - ); - - // resample if needed - const resampleRatio = SAMPLE_RATE / buffer.sampleRate; - - for (let ch = 0; ch < NUM_CHANNELS; ch++) { - const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1); - const channelData = buffer.getChannelData(sourceChannel); - const outputChannel = mixBuffers[ch]; - - const resampledLength = Math.floor(channelData.length * resampleRatio); - for (let i = 0; i < resampledLength; i++) { - const outputIdx = outputStartSample + i; - if (outputIdx < 0 || outputIdx >= totalSamples) continue; - - const sourceIdx = Math.floor(i / resampleRatio); - if (sourceIdx < channelData.length) { - outputChannel[outputIdx] += channelData[sourceIdx]; - } - } + 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 { diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index fbe29acd..547e7ed8 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -1,14 +1,34 @@ +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import { toast } from "sonner"; -import type { MediaAsset } from "@/types/assets"; import { getMediaTypeFromFile } from "@/lib/media/media-utils"; +import { formatStorageBytes } from "@/services/storage/quota"; +import { storageService } from "@/services/storage/service"; +import type { MediaAsset } from "@/lib/media/types"; import { getVideoInfo } from "./mediabunny"; -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; export interface ProcessedMediaAsset extends Omit {} const THUMBNAIL_MAX_WIDTH = 1280; const THUMBNAIL_MAX_HEIGHT = 720; +const getStorageLimitDescription = ({ + fileSize, + availableBytes, +}: { + fileSize: number; + availableBytes: number | null; +}): string => { + const fileSizeLabel = formatStorageBytes({ bytes: fileSize }); + + if (availableBytes === null) { + return `File size is ${fileSizeLabel}.`; + } + + return `File size is ${fileSizeLabel}, but only ${formatStorageBytes({ + bytes: availableBytes, + })} is safely available in browser storage.`; +}; + const getThumbnailSize = ({ width, height, @@ -63,7 +83,7 @@ const renderToThumbnailDataUrl = ({ return canvas.toDataURL("image/jpeg", 0.8); }; -export async function generateThumbnail({ +async function generateThumbnail({ videoFile, timeInSeconds, }: { @@ -106,25 +126,29 @@ export async function generateThumbnail({ } } -export async function generateImageThumbnail({ +async function generateImageThumbnail({ imageFile, }: { imageFile: File; -}): Promise { +}): Promise<{ thumbnailUrl: string; width: number; height: number }> { return new Promise((resolve, reject) => { const image = new window.Image(); const objectUrl = URL.createObjectURL(imageFile); image.addEventListener("load", () => { try { - const dataUrl = renderToThumbnailDataUrl({ + const thumbnailUrl = renderToThumbnailDataUrl({ width: image.naturalWidth, height: image.naturalHeight, draw: ({ context, width, height }) => { context.drawImage(image, 0, 0, width, height); }, }); - resolve(dataUrl); + resolve({ + thumbnailUrl, + width: image.naturalWidth, + height: image.naturalHeight, + }); } catch (error) { reject( error instanceof Error ? error : new Error("Could not render image"), @@ -166,19 +190,34 @@ export async function processMediaAssets({ continue; } + const storageCheck = await storageService.canStoreFile({ + size: file.size, + }); + + if (!storageCheck.canStore) { + toast.error(`Not enough browser storage for ${file.name}`, { + description: getStorageLimitDescription({ + fileSize: file.size, + availableBytes: storageCheck.availableBytes, + }), + }); + continue; + } + const url = URL.createObjectURL(file); let thumbnailUrl: string | undefined; let duration: number | undefined; let width: number | undefined; let height: number | undefined; let fps: number | undefined; + let hasAudio: boolean | undefined; try { if (fileType === "image") { - const dimensions = await getImageDimensions({ file }); - width = dimensions.width; - height = dimensions.height; - thumbnailUrl = await generateImageThumbnail({ imageFile: file }); + const result = await generateImageThumbnail({ imageFile: file }); + thumbnailUrl = result.thumbnailUrl; + width = result.width; + height = result.height; } else if (fileType === "video") { try { const videoInfo = await getVideoInfo({ videoFile: file }); @@ -188,6 +227,7 @@ export async function processMediaAssets({ fps = Number.isFinite(videoInfo.fps) ? Math.round(videoInfo.fps) : undefined; + hasAudio = videoInfo.hasAudio; thumbnailUrl = await generateThumbnail({ videoFile: file, @@ -211,6 +251,7 @@ export async function processMediaAssets({ width, height, fps, + hasAudio, }); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -230,33 +271,6 @@ export async function processMediaAssets({ return processedAssets; } -const getImageDimensions = ({ - file, -}: { - file: File; -}): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const img = new window.Image(); - const objectUrl = URL.createObjectURL(file); - - img.addEventListener("load", () => { - const width = img.naturalWidth; - const height = img.naturalHeight; - resolve({ width, height }); - URL.revokeObjectURL(objectUrl); - img.remove(); - }); - - img.addEventListener("error", () => { - reject(new Error("Could not load image")); - URL.revokeObjectURL(objectUrl); - img.remove(); - }); - - img.src = objectUrl; - }); -}; - const getMediaDuration = ({ file }: { file: File }): Promise => { return new Promise((resolve, reject) => { const element = document.createElement( diff --git a/apps/web/src/lib/media/types.ts b/apps/web/src/lib/media/types.ts new file mode 100644 index 00000000..c8dddfc7 --- /dev/null +++ b/apps/web/src/lib/media/types.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..e5c5ee28 --- /dev/null +++ b/apps/web/src/lib/media/upload-toast.ts @@ -0,0 +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(); +} diff --git a/apps/web/src/lib/params.ts b/apps/web/src/lib/params.ts new file mode 100644 index 00000000..23085407 --- /dev/null +++ b/apps/web/src/lib/params.ts @@ -0,0 +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; + diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 08ffbfa8..5c416afc 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,17 +1,12 @@ -import type { TimelineTrack, TimelineElement } from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; +import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { isMainTrack } from "@/lib/timeline"; -import { - DEFAULT_TEXT_ELEMENT, - DEFAULT_LINE_HEIGHT, - DEFAULT_TEXT_BACKGROUND, - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout"; +import { 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, - resolveNumberAtTime, } from "@/lib/animation"; export interface ElementBounds { @@ -41,7 +36,8 @@ function getVisualElementBounds({ sourceWidth: number; sourceHeight: number; transform: { - scale: number; + scaleX: number; + scaleY: number; position: { x: number; y: number }; rotate: number; }; @@ -50,8 +46,8 @@ function getVisualElementBounds({ canvasWidth / sourceWidth, canvasHeight / sourceHeight, ); - const scaledWidth = sourceWidth * containScale * transform.scale; - const scaledHeight = sourceHeight * containScale * transform.scale; + 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; @@ -64,7 +60,53 @@ function getVisualElementBounds({ }; } -export function getElementBounds({ +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, @@ -106,123 +148,118 @@ export function getElementBounds({ return getVisualElementBounds({ canvasWidth, canvasHeight, - sourceWidth: 200, - sourceHeight: 200, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, transform, }); } - if (element.type === "text") { + if (element.type === "graphic") { const transform = resolveTransformAtTime({ baseTransform: element.transform, animations: element.animations, localTime, }); - const scaledFontSize = - element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const letterSpacing = element.letterSpacing ?? 0; - const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; - const lineHeightPx = scaledFontSize * lineHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } - let measuredWidth = 100; - let measuredHeight = scaledFontSize; + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); const canvas = document.createElement("canvas"); - canvas.width = 4096; - canvas.height = 4096; const ctx = canvas.getContext("2d"); + if (!ctx) return null; - if (ctx) { - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; - ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - ctx.textAlign = element.textAlign as CanvasTextAlign; - if ("letterSpacing" in ctx) { - ( - ctx as CanvasRenderingContext2D & { letterSpacing: string } - ).letterSpacing = `${letterSpacing}px`; - } - - const lines = element.content.split("\n"); - const lineMetrics = lines.map((line) => ctx.measureText(line)); - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize: scaledFontSize, - }); - const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; - const resolvedBackground = { - ...element.background, - paddingX: resolveNumberAtTime({ - baseValue: - element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }), - paddingY: resolveNumberAtTime({ - baseValue: - element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }), - offsetX: resolveNumberAtTime({ - baseValue: - element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }), - offsetY: resolveNumberAtTime({ - baseValue: - element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }), - }; - const visualRect = getTextVisualRect({ - textAlign: element.textAlign, - block, - background: resolvedBackground, - fontSizeRatio, - }); - measuredWidth = visualRect.width; - measuredHeight = visualRect.height; - const localCenterX = visualRect.left + visualRect.width / 2; - const localCenterY = visualRect.top + visualRect.height / 2; - const scaledCenterX = localCenterX * transform.scale; - const scaledCenterY = localCenterY * transform.scale; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin; - const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos; - return { - cx: canvasWidth / 2 + transform.position.x + rotatedCenterX, - cy: canvasHeight / 2 + transform.position.y + rotatedCenterY, - width: measuredWidth * transform.scale, - height: measuredHeight * transform.scale, - rotation: transform.rotate, - }; - } + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); - const width = measuredWidth * transform.scale; - const height = measuredHeight * transform.scale; - return { - cx: canvasWidth / 2 + transform.position.x, - cy: canvasHeight / 2 + transform.position.y, - width, - height, - rotation: transform.rotate, - }; + 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, diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts index c2fde87b..2552922b 100644 --- a/apps/web/src/lib/preview/hit-test.ts +++ b/apps/web/src/lib/preview/hit-test.ts @@ -1,4 +1,5 @@ import type { ElementWithBounds } from "./element-bounds"; +import type { ElementRef } from "@/lib/timeline/types"; function pointInRotatedRect({ px, @@ -24,14 +25,14 @@ function pointInRotatedRect({ const dy = py - cy; const localX = dx * cos - dy * sin; const localY = dx * sin + dy * cos; - const halfW = width / 2; - const halfH = height / 2; + const halfW = Math.abs(width) / 2; + const halfH = Math.abs(height) / 2; return ( localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH ); } -export function hitTest({ +export function getHitElements({ canvasX, canvasY, elementsWithBounds, @@ -39,7 +40,9 @@ export function hitTest({ canvasX: number; canvasY: number; elementsWithBounds: ElementWithBounds[]; -}): ElementWithBounds | null { +}): ElementWithBounds[] { + const hits: ElementWithBounds[] = []; + for (let i = elementsWithBounds.length - 1; i >= 0; i--) { const { bounds } = elementsWithBounds[i]; if ( @@ -53,8 +56,47 @@ export function hitTest({ rotation: bounds.rotation, }) ) { - return elementsWithBounds[i]; + hits.push(elementsWithBounds[i]); } } - return null; + + return hits; +} + +export function hitTest({ + canvasX, + canvasY, + elementsWithBounds, +}: { + canvasX: number; + canvasY: number; + elementsWithBounds: ElementWithBounds[]; +}): ElementWithBounds | null { + return ( + getHitElements({ + canvasX, + canvasY, + elementsWithBounds, + })[0] ?? null + ); +} + +export function resolvePreferredHit({ + hits, + preferredElements, +}: { + hits: ElementWithBounds[]; + preferredElements: ElementRef[]; +}): ElementWithBounds | null { + if (preferredElements.length === 0) return null; + + return ( + hits.find((hit) => + preferredElements.some( + (preferredElement) => + preferredElement.trackId === hit.trackId && + preferredElement.elementId === hit.elementId, + ), + ) ?? null + ); } diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts index dcc87282..64184990 100644 --- a/apps/web/src/lib/preview/preview-coords.ts +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -1,90 +1,101 @@ +export interface PreviewViewportGeometry { + canvasHeight: number; + canvasWidth: number; + centerX: number; + centerY: number; + scale: number; + viewportHeight: number; + viewportWidth: number; +} + +function getCanvasOrigin({ + geometry, +}: { + geometry: PreviewViewportGeometry; +}): { x: number; y: number } { + return { + x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale, + y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale, + }; +} + export function screenToCanvas({ clientX, clientY, - canvas, + geometry, + viewportRect, }: { clientX: number; clientY: number; - canvas: HTMLCanvasElement; + geometry: PreviewViewportGeometry; + viewportRect: DOMRect; }): { x: number; y: number } { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; + const overlayX = clientX - viewportRect.left; + const overlayY = clientY - viewportRect.top; + return { - x: (clientX - rect.left) * scaleX, - y: (clientY - rect.top) * scaleY, + x: + geometry.centerX + + (overlayX - geometry.viewportWidth / 2) / geometry.scale, + y: + geometry.centerY + + (overlayY - geometry.viewportHeight / 2) / geometry.scale, }; } export function canvasToOverlay({ canvasX, canvasY, - canvasRect, - containerRect, - canvasSize, + geometry, }: { canvasX: number; canvasY: number; - canvasRect: DOMRect; - containerRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { - const scaleX = canvasRect.width / canvasSize.width; - const scaleY = canvasRect.height / canvasSize.height; + const canvasOrigin = getCanvasOrigin({ geometry }); + return { - x: canvasRect.left - containerRect.left + canvasX * scaleX, - y: canvasRect.top - containerRect.top + canvasY * scaleY, + x: canvasOrigin.x + canvasX * geometry.scale, + y: canvasOrigin.y + canvasY * geometry.scale, }; } export function positionToOverlay({ positionX, positionY, - canvasRect, - containerRect, - canvasSize, + geometry, }: { positionX: number; positionY: number; - canvasRect: DOMRect; - containerRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { - const scaleX = canvasRect.width / canvasSize.width; - const scaleY = canvasRect.height / canvasSize.height; - const centerScreenX = - canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX; - const centerScreenY = - canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY; - return { - x: centerScreenX + positionX * scaleX, - y: centerScreenY + positionY * scaleY, - }; + return canvasToOverlay({ + canvasX: geometry.canvasWidth / 2 + positionX, + canvasY: geometry.canvasHeight / 2 + positionY, + geometry, + }); } export function getDisplayScale({ - canvasRect, - canvasSize, + geometry, }: { - canvasRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { return { - x: canvasRect.width / canvasSize.width, - y: canvasRect.height / canvasSize.height, + x: geometry.scale, + y: geometry.scale, }; } export function screenPixelsToLogicalThreshold({ - canvas, + geometry, screenPixels, }: { - canvas: HTMLCanvasElement; + geometry: PreviewViewportGeometry; screenPixels: number; }): { x: number; y: number } { - const canvasRect = canvas.getBoundingClientRect(); return { - x: screenPixels * (canvas.width / canvasRect.width), - y: screenPixels * (canvas.height / canvasRect.height), + x: screenPixels / geometry.scale, + y: screenPixels / geometry.scale, }; } diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts index 36efc3ce..64220264 100644 --- a/apps/web/src/lib/preview/preview-snap.ts +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -13,15 +13,70 @@ export interface SnapResult { 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; @@ -31,8 +86,11 @@ export function snapPosition({ const top = -canvasSize.height / 2; const bottom = canvasSize.height / 2; - const halfWidth = elementSize.width / 2; - const halfHeight = elementSize.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 = { @@ -59,8 +117,8 @@ export function snapPosition({ ); } - const verticalTargets = [left, centerX, right]; - const horizontalTargets = [top, centerY, bottom]; + const verticalTargets = [centerX, left, right]; + const horizontalTargets = [centerY, top, bottom]; const xCandidates: AxisSnapCandidate[] = []; for (const targetX of verticalTargets) { @@ -133,15 +191,19 @@ export function snapScale({ 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; @@ -150,15 +212,22 @@ export function snapScale({ const top = -canvasSize.height / 2; const bottom = canvasSize.height / 2; - const leftEdge = position.x - (baseWidth * proposedScale) / 2; - const rightEdge = position.x + (baseWidth * proposedScale) / 2; - const topEdge = position.y - (baseHeight * proposedScale) / 2; - const bottomEdge = position.y + (baseHeight * proposedScale) / 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[] = []; @@ -175,23 +244,25 @@ export function snapScale({ for (const target of verticalTargets) { const distanceLeft = Math.abs(leftEdge - target.position); if (distanceLeft <= snapThreshold.x) { - const scale = (2 * (position.x - target.position)) / baseWidth; - if (scale > MIN_SCALE) { + 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 = (2 * (target.position - position.x)) / baseWidth; - if (scale > MIN_SCALE) { + const scale = (target.position - position.x) / aabbBaseHalfW; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceRight, lines: [target.line], + edge: "right", }); } } @@ -212,40 +283,42 @@ export function snapScale({ for (const target of horizontalTargets) { const distanceTop = Math.abs(topEdge - target.position); if (distanceTop <= snapThreshold.y) { - const scale = (2 * (position.y - target.position)) / baseHeight; - if (scale > MIN_SCALE) { + 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 = (2 * (target.position - position.y)) / baseHeight; - if (scale > MIN_SCALE) { + const scale = (target.position - position.y) / aabbBaseHalfH; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceBottom, lines: [target.line], + edge: "bottom", }); } } } - if (candidates.length === 0) { + const best = pickClosestScaleCandidate({ + candidates, + preferredEdges, + }); + if (!best) { return { snappedScale: proposedScale, activeLines: [] }; } - const best = candidates.reduce((acc, candidate) => - candidate.distance < acc.distance ? candidate : acc, - ); - - const snappedLeft = position.x - (baseWidth * best.scale) / 2; - const snappedRight = position.x + (baseWidth * best.scale) / 2; - const snappedTop = position.y - (baseHeight * best.scale) / 2; - const snappedBottom = position.y + (baseHeight * best.scale) / 2; + 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(); @@ -260,16 +333,26 @@ export function snapScale({ for (const target of verticalTargets) { if ( - Math.abs(snappedLeft - target.position) <= 1 || - Math.abs(snappedRight - target.position) <= 1 + (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 ( - Math.abs(snappedTop - target.position) <= 1 || - Math.abs(snappedBottom - target.position) <= 1 + (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 }); } @@ -281,6 +364,158 @@ export function snapScale({ }; } +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; diff --git a/apps/web/src/lib/project/types.ts b/apps/web/src/lib/project/types.ts new file mode 100644 index 00000000..734c3835 --- /dev/null +++ b/apps/web/src/lib/project/types.ts @@ -0,0 +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}`; diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts new file mode 100644 index 00000000..433feae7 --- /dev/null +++ b/apps/web/src/lib/registry.ts @@ -0,0 +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()); + } +} diff --git a/apps/web/src/lib/rendering.ts b/apps/web/src/lib/rendering.ts new file mode 100644 index 00000000..d16296e2 --- /dev/null +++ b/apps/web/src/lib/rendering.ts @@ -0,0 +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"; diff --git a/apps/web/src/lib/retime/__tests__/resolve.test.ts b/apps/web/src/lib/retime/__tests__/resolve.test.ts new file mode 100644 index 00000000..4fcf6249 --- /dev/null +++ b/apps/web/src/lib/retime/__tests__/resolve.test.ts @@ -0,0 +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); + }); +}); diff --git a/apps/web/src/lib/retime/__tests__/split.test.ts b/apps/web/src/lib/retime/__tests__/split.test.ts new file mode 100644 index 00000000..0ce532a1 --- /dev/null +++ b/apps/web/src/lib/retime/__tests__/split.test.ts @@ -0,0 +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(); + }); +}); diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts new file mode 100644 index 00000000..51ef38d0 --- /dev/null +++ b/apps/web/src/lib/retime/audio-stretch.ts @@ -0,0 +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, + }); +} diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts new file mode 100644 index 00000000..4915c203 --- /dev/null +++ b/apps/web/src/lib/retime/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..382320b0 --- /dev/null +++ b/apps/web/src/lib/retime/presets.ts @@ -0,0 +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 }; +} diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts new file mode 100644 index 00000000..cfcfb6a2 --- /dev/null +++ b/apps/web/src/lib/retime/resolve.ts @@ -0,0 +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 }); +} diff --git a/apps/web/src/lib/retime/split.ts b/apps/web/src/lib/retime/split.ts new file mode 100644 index 00000000..12982292 --- /dev/null +++ b/apps/web/src/lib/retime/split.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 62195669..198116c0 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,4 +1,4 @@ -import type { TScene } from "@/types/timeline"; +import type { TScene } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/lib/timeline"; import { ensureMainTrack } from "@/lib/timeline/track-utils"; diff --git a/apps/web/src/lib/selection/constants.ts b/apps/web/src/lib/selection/constants.ts new file mode 100644 index 00000000..a1aee842 --- /dev/null +++ b/apps/web/src/lib/selection/constants.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..ced49c88 --- /dev/null +++ b/apps/web/src/lib/selection/context.tsx @@ -0,0 +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, + }; +} diff --git a/apps/web/src/lib/selection/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts new file mode 100644 index 00000000..ba086425 --- /dev/null +++ b/apps/web/src/lib/selection/hit-testing.ts @@ -0,0 +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); +} diff --git a/apps/web/src/lib/selection/hooks/use-box-select.ts b/apps/web/src/lib/selection/hooks/use-box-select.ts new file mode 100644 index 00000000..5a9a5cd0 --- /dev/null +++ b/apps/web/src/lib/selection/hooks/use-box-select.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/lib/selection/hooks/use-selection-scope.ts b/apps/web/src/lib/selection/hooks/use-selection-scope.ts new file mode 100644 index 00000000..7ed63815 --- /dev/null +++ b/apps/web/src/lib/selection/hooks/use-selection-scope.ts @@ -0,0 +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]); +} diff --git a/apps/web/src/lib/selection/index.ts b/apps/web/src/lib/selection/index.ts new file mode 100644 index 00000000..b8699ecf --- /dev/null +++ b/apps/web/src/lib/selection/index.ts @@ -0,0 +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"; diff --git a/apps/web/src/lib/selection/scope.ts b/apps/web/src/lib/selection/scope.ts new file mode 100644 index 00000000..45bf6b56 --- /dev/null +++ b/apps/web/src/lib/selection/scope.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx new file mode 100644 index 00000000..1d7e74a8 --- /dev/null +++ b/apps/web/src/lib/selection/selectable-item.tsx @@ -0,0 +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} +
+ ); + }, +); diff --git a/apps/web/src/lib/selection/selectable-surface.tsx b/apps/web/src/lib/selection/selectable-surface.tsx new file mode 100644 index 00000000..605b3d18 --- /dev/null +++ b/apps/web/src/lib/selection/selectable-surface.tsx @@ -0,0 +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} + +
+
+ ); +} diff --git a/apps/web/src/lib/selection/selection-box.tsx b/apps/web/src/lib/selection/selection-box.tsx new file mode 100644 index 00000000..0b757e36 --- /dev/null +++ b/apps/web/src/lib/selection/selection-box.tsx @@ -0,0 +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 ( +
+ ); +} diff --git a/apps/web/src/lib/selection/state.ts b/apps/web/src/lib/selection/state.ts new file mode 100644 index 00000000..b4c454fb --- /dev/null +++ b/apps/web/src/lib/selection/state.ts @@ -0,0 +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, + }); +} diff --git a/apps/web/src/lib/selection/types.ts b/apps/web/src/lib/selection/types.ts new file mode 100644 index 00000000..94fd7abe --- /dev/null +++ b/apps/web/src/lib/selection/types.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/shaders/jfa-init.frag.glsl b/apps/web/src/lib/shaders/jfa-init.frag.glsl new file mode 100644 index 00000000..d199d234 --- /dev/null +++ b/apps/web/src/lib/shaders/jfa-init.frag.glsl @@ -0,0 +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); + } +} diff --git a/apps/web/src/lib/shaders/jfa-step.frag.glsl b/apps/web/src/lib/shaders/jfa-step.frag.glsl new file mode 100644 index 00000000..5a95f497 --- /dev/null +++ b/apps/web/src/lib/shaders/jfa-step.frag.glsl @@ -0,0 +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); + } +} diff --git a/apps/web/src/lib/sounds/types.ts b/apps/web/src/lib/sounds/types.ts new file mode 100644 index 00000000..eecf7701 --- /dev/null +++ b/apps/web/src/lib/sounds/types.ts @@ -0,0 +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; +} 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 573dc251..3236e213 100644 --- a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts +++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts @@ -3,13 +3,13 @@ import { buildStickerId, parseStickerId } from "../sticker-id"; describe("sticker-id strict mode", () => { test("parses provider-prefixed IDs", () => { - expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({ - providerId: "icons", - providerValue: "mdi:home", + expect(parseStickerId({ stickerId: "flags:US" })).toEqual({ + providerId: "flags", + providerValue: "US", }); - expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({ - providerId: "emoji", - providerValue: "noto:grinning-face", + expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({ + providerId: "shapes", + providerValue: "circle", }); }); @@ -19,7 +19,7 @@ describe("sticker-id strict mode", () => { test("throws for malformed IDs", () => { expect(() => parseStickerId({ stickerId: "" })).toThrow(); - expect(() => parseStickerId({ stickerId: "icons:" })).toThrow(); + expect(() => parseStickerId({ stickerId: "flags:" })).toThrow(); expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow(); }); diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts index 1f924c6d..47be18fe 100644 --- a/apps/web/src/lib/stickers/index.ts +++ b/apps/web/src/lib/stickers/index.ts @@ -1,10 +1,20 @@ -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import type { StickerCategory } from "@/types/stickers"; -import { getAllProviders, getProvider } from "./registry"; +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 type { StickerProvider, StickerSearchResult } from "./types"; +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({ @@ -46,7 +56,46 @@ function getProviderByCategory({ } try { - return getProvider({ providerId: category }); + 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; } @@ -79,7 +128,7 @@ export async function searchStickers({ }); } - const providers = getAllProviders(); + const providers = stickersRegistry.getAll(); if (providers.length === 0) { return { items: [], @@ -110,64 +159,157 @@ export async function searchStickers({ }); } -export async function browseStickers({ - category, - page = 1, +export async function searchAll({ + query, limit = DEFAULT_SEARCH_LIMIT, }: { - category: StickerCategory; - page?: number; + query: string; limit?: number; -}): Promise { +}): 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, - }; + 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; } - return provider.browse({ - options: { page, limit }, + 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", }); } - const providers = getAllProviders(); - if (providers.length === 0) { - return { - items: [], - total: 0, - hasMore: false, - }; + 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 perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); const settledResults = await Promise.allSettled( - providers.map((provider) => - provider.browse({ - options: { page, limit: perProviderLimit }, - }), - ), + 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, + }, + }; + }), ); - const fulfilledResults = settledResults - .filter( - (result): result is PromiseFulfilledResult => - result.status === "fulfilled", - ) - .map((result) => result.value); + for (const result of settledResults) { + if (result.status === "fulfilled" && result.value) { + sections.push(result.value); + } + } - return mergeSearchResults({ - results: fulfilledResults, + 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, diff --git a/apps/web/src/lib/stickers/providers/countries-data.ts b/apps/web/src/lib/stickers/providers/countries-data.ts new file mode 100644 index 00000000..1bcc1f50 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/countries-data.ts @@ -0,0 +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" + } +]; diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts deleted file mode 100644 index cc198236..00000000 --- a/apps/web/src/lib/stickers/providers/emoji.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - POPULAR_COLLECTIONS, - getIconSvgUrl, - searchIcons, -} from "@/lib/iconify-api"; -import { buildStickerId, parseStickerId } from "../sticker-id"; -import type { - StickerItem, - StickerProvider, - StickerSearchResult, -} from "../types"; - -const EMOJI_PROVIDER_ID = "emoji"; -const DEFAULT_SEARCH_LIMIT = 100; - -const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map( - (collection) => collection.prefix, -); - -const DEFAULT_EMOJI_BROWSE = [ - "noto:grinning-face", - "noto:smiling-face-with-heart-eyes", - "noto:fire", - "noto:rocket", - "noto:party-popper", - "noto:clapping-hands", - "noto:sparkles", - "noto:red-heart", - "noto:thumbs-up", - "noto:eyes", - "noto:thinking-face", - "noto:hundred-points", -]; - -function getDisplayNameFromIconName({ - iconName, -}: { - iconName: string; -}): string { - const parts = iconName.split(":"); - const rawName = parts[parts.length - 1] ?? iconName; - return rawName.replaceAll("-", " ").replaceAll("_", " "); -} - -function toStickerItem({ iconName }: { iconName: string }): StickerItem { - return { - id: buildStickerId({ - providerId: EMOJI_PROVIDER_ID, - providerValue: iconName, - }), - provider: EMOJI_PROVIDER_ID, - name: getDisplayNameFromIconName({ iconName }), - previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), - metadata: { iconName }, - }; -} - -function computeHasMore({ - total, - limit, - start = 0, -}: { - total: number; - limit: number; - start?: number; -}): boolean { - return start + limit < total; -} - -export const emojiProvider: StickerProvider = { - id: EMOJI_PROVIDER_ID, - async search({ - query, - options, - }: { - query: string; - options?: { limit?: number }; - }): Promise { - const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; - const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES); - return { - items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), - total: searchResult.total, - hasMore: computeHasMore({ - total: searchResult.total, - limit: searchResult.limit, - start: searchResult.start, - }), - }; - }, - async browse({ - options, - }: { - options?: { page?: number; limit?: number }; - }): Promise { - const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length; - const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) => - toStickerItem({ iconName }), - ); - return { - items, - total: items.length, - hasMore: false, - }; - }, - resolveUrl({ - stickerId, - options, - }: { - stickerId: string; - options?: { width?: number; height?: number }; - }): string { - const { providerValue } = parseStickerId({ stickerId }); - return getIconSvgUrl(providerValue, { - width: options?.width, - height: options?.height, - }); - }, -}; diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts index 91cc5489..0cca292f 100644 --- a/apps/web/src/lib/stickers/providers/flags.ts +++ b/apps/web/src/lib/stickers/providers/flags.ts @@ -1,23 +1,17 @@ import { buildStickerId, parseStickerId } from "../sticker-id"; import type { + StickerBrowseResult, StickerItem, StickerProvider, StickerSearchResult, } from "../types"; +import { REGIONS, REGION_GROUPS } from "./countries-data"; +import type { CountryRecord, RegionId } from "./countries-data"; const FLAGS_PROVIDER_ID = "flags"; -const FLAGS_DATASET_URL = "/countries.json"; const DEFAULT_SEARCH_LIMIT = 100; const DEFAULT_FLAGS_BASE_URL = "/flags"; -type CountryRecord = { - name: string; - code: string; - languages?: string[]; - flag_colors?: string[]; - region?: string; -}; - let countriesPromise: Promise | null = null; function getFlagsBaseUrl(): string { @@ -25,7 +19,7 @@ function getFlagsBaseUrl(): string { } function buildFlagUrl({ code }: { code: string }): string { - const normalizedCode = code.toUpperCase(); + const normalizedCode = code.toLowerCase(); return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`; } @@ -34,13 +28,8 @@ async function loadCountries(): Promise { return countriesPromise; } - countriesPromise = fetch(FLAGS_DATASET_URL) - .then(async (response) => { - if (!response.ok) { - throw new Error(`Failed to load countries: ${response.status}`); - } - return (await response.json()) as CountryRecord[]; - }) + countriesPromise = import("./countries-data") + .then((m) => m.COUNTRIES) .catch((error) => { console.error("Failed to load countries dataset:", error); return []; @@ -72,6 +61,44 @@ function normalizeQuery({ query }: { query: string }): string { return query.trim().toLowerCase(); } +function findMatchingRegions({ + query, +}: { + query: string; +}): (typeof REGIONS)[number][] { + return REGIONS.filter( + (r) => + r.id.toLowerCase() === query || + r.aliases.some((alias) => alias === query), + ); +} + +export function resolveQueryToRegions({ + query, +}: { + query: string; +}): Set | null { + const group = REGION_GROUPS[query]; + if (group) { + return new Set(group); + } + + const matched = findMatchingRegions({ query }); + return matched.length > 0 ? new Set(matched.map((r) => r.id)) : null; +} + +export function getRegionLabel({ query }: { query: string }): string { + if (REGION_GROUPS[query]) { + return query + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } + + const matched = findMatchingRegions({ query }); + return matched[0]?.id ?? query; +} + function filterCountriesByQuery({ countries, query, @@ -83,15 +110,16 @@ function filterCountriesByQuery({ return countries; } + const regionIds = resolveQueryToRegions({ query }); + + if (regionIds) { + return countries.filter((country) => country.region && regionIds.has(country.region)); + } + return countries.filter((country) => { const normalizedName = country.name.toLowerCase(); const normalizedCode = country.code.toLowerCase(); - const normalizedRegion = country.region?.toLowerCase() ?? ""; - return ( - normalizedName.includes(query) || - normalizedCode.includes(query) || - normalizedRegion.includes(query) - ); + return normalizedName.includes(query) || normalizedCode.includes(query); }); } @@ -102,13 +130,15 @@ function paginateCountries({ countries: CountryRecord[]; options?: { page?: number; limit?: number }; }): { items: CountryRecord[]; hasMore: boolean; total: number } { - const page = Math.max(1, options?.page ?? 1); - const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT); + if (options?.limit === undefined) { + return { items: countries, hasMore: false, total: countries.length }; + } + const page = Math.max(1, options.page ?? 1); + const limit = Math.max(1, options.limit); const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; - const pagedItems = countries.slice(startIndex, endIndex); return { - items: pagedItems, + items: countries.slice(startIndex, endIndex), hasMore: endIndex < countries.length, total: countries.length, }; @@ -146,13 +176,18 @@ export const flagsProvider: StickerProvider = { options, }: { options?: { page?: number; limit?: number }; - }): Promise { + }): Promise { const countries = await loadCountries(); const paged = paginateCountries({ countries, options }); return { - items: paged.items.map((country) => toStickerItem({ country })), - total: paged.total, - hasMore: paged.hasMore, + sections: [ + { + id: "all", + items: paged.items.map((country) => toStickerItem({ country })), + hasMore: paged.hasMore, + layout: "grid", + }, + ], }; }, resolveUrl({ diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts deleted file mode 100644 index 06b2a4c2..00000000 --- a/apps/web/src/lib/stickers/providers/icons.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - POPULAR_COLLECTIONS, - getIconSvgUrl, - searchIcons, -} from "@/lib/iconify-api"; -import { buildStickerId, parseStickerId } from "../sticker-id"; -import type { - StickerItem, - StickerProvider, - StickerSearchResult, -} from "../types"; - -const ICONS_PROVIDER_ID = "icons"; -const DEFAULT_SEARCH_LIMIT = 100; - -const ICONS_PREFIXES = Array.from( - new Set( - [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map( - (collection) => collection.prefix, - ), - ), -); - -const DEFAULT_ICONS_BROWSE = [ - "mdi:home", - "mdi:star", - "mdi:heart", - "mdi:check-circle", - "mdi:account", - "mdi:camera", - "mdi:music", - "mdi:map-marker", - "mdi:calendar", - "mdi:lightning-bolt", - "mdi:cog", - "mdi:rocket", -]; - -function getDisplayNameFromIconName({ - iconName, -}: { - iconName: string; -}): string { - const [, rawName = iconName] = iconName.split(":"); - return rawName.replaceAll("-", " ").replaceAll("_", " "); -} - -function toStickerItem({ iconName }: { iconName: string }): StickerItem { - return { - id: buildStickerId({ - providerId: ICONS_PROVIDER_ID, - providerValue: iconName, - }), - provider: ICONS_PROVIDER_ID, - name: getDisplayNameFromIconName({ iconName }), - previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), - metadata: { iconName }, - }; -} - -function computeHasMore({ - total, - limit, - start = 0, -}: { - total: number; - limit: number; - start?: number; -}): boolean { - return start + limit < total; -} - -export const iconsProvider: StickerProvider = { - id: ICONS_PROVIDER_ID, - async search({ - query, - options, - }: { - query: string; - options?: { limit?: number }; - }): Promise { - const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; - const searchResult = await searchIcons(query, limit, ICONS_PREFIXES); - return { - items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), - total: searchResult.total, - hasMore: computeHasMore({ - total: searchResult.total, - limit: searchResult.limit, - start: searchResult.start, - }), - }; - }, - async browse({ - options, - }: { - options?: { page?: number; limit?: number }; - }): Promise { - const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length; - const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) => - toStickerItem({ iconName }), - ); - return { - items, - total: items.length, - hasMore: false, - }; - }, - resolveUrl({ - stickerId, - options, - }: { - stickerId: string; - options?: { width?: number; height?: number }; - }): string { - const { providerValue } = parseStickerId({ stickerId }); - return getIconSvgUrl(providerValue, { - width: options?.width, - height: options?.height, - }); - }, -}; diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts index 3c4faee9..b0a298cb 100644 --- a/apps/web/src/lib/stickers/providers/index.ts +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -1,13 +1,11 @@ -import { hasProvider, registerProvider } from "../registry"; -import type { StickerProvider } from "@/types/stickers"; -import { emojiProvider } from "./emoji"; +import { stickersRegistry } from "../registry"; +import type { StickerProvider } from "@/lib/stickers/types"; import { flagsProvider } from "./flags"; -import { iconsProvider } from "./icons"; +import { logosProvider } from "./logos"; import { shapesProvider } from "./shapes"; const defaultProviders: StickerProvider[] = [ - iconsProvider, - emojiProvider, + logosProvider, flagsProvider, shapesProvider, ]; @@ -18,9 +16,9 @@ export function registerDefaultStickerProviders({ providersToRegister?: StickerProvider[]; } = {}): void { for (const provider of providersToRegister) { - if (hasProvider({ providerId: provider.id })) { + if (stickersRegistry.has(provider.id)) { continue; } - registerProvider({ provider }); + 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 new file mode 100644 index 00000000..5be2e9a2 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/logos.ts @@ -0,0 +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; + }, +}; diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts index 81873dcb..a23f14a7 100644 --- a/apps/web/src/lib/stickers/providers/shapes.ts +++ b/apps/web/src/lib/stickers/providers/shapes.ts @@ -1,5 +1,8 @@ +import { buildGraphicPreviewUrl, buildDefaultGraphicInstance, graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import type { ParamValues } from "@/lib/params"; import { buildStickerId, parseStickerId } from "../sticker-id"; import type { + StickerBrowseResult, StickerItem, StickerProvider, StickerSearchResult, @@ -7,60 +10,140 @@ import type { const SHAPES_PROVIDER_ID = "shapes"; -const SHAPES = [ - { key: "circle", name: "Circle" }, - { key: "square", name: "Square" }, - { key: "triangle", name: "Triangle" }, - { key: "star", name: "Star" }, - { key: "hexagon", name: "Hexagon" }, - { key: "diamond", name: "Diamond" }, -] as const; +type ShapeGraphicPreset = { + shapeKey: string; + name: string; + definitionId: string; + params?: ParamValues; +}; + +const LEGACY_SHAPE_PRESETS: Record = { + square: { shapeKey: "square", name: "Square", definitionId: "rectangle" }, + circle: { shapeKey: "circle", name: "Circle", definitionId: "ellipse" }, + triangle: { + shapeKey: "triangle", + name: "Triangle", + definitionId: "polygon", + params: { sides: 3 }, + }, + hexagon: { + shapeKey: "hexagon", + name: "Hexagon", + definitionId: "polygon", + params: { sides: 6 }, + }, + diamond: { + shapeKey: "diamond", + name: "Diamond", + definitionId: "polygon", + params: { sides: 4 }, + }, + star: { shapeKey: "star", name: "Star", definitionId: "star" }, +}; + +function getShapePresets(): ShapeGraphicPreset[] { + registerDefaultGraphics(); + return graphicsRegistry.getAll().map((definition) => ({ + shapeKey: definition.id, + name: definition.name, + definitionId: definition.id, + })); +} + +function getShapePreset({ + shapeKey, +}: { + shapeKey: string; +}): ShapeGraphicPreset | null { + return ( + getShapePresets().find((preset) => preset.shapeKey === shapeKey) ?? + LEGACY_SHAPE_PRESETS[shapeKey] ?? + null + ); +} + +function getShapeParams({ + preset, +}: { + preset: ShapeGraphicPreset; +}): ParamValues { + return { + ...buildDefaultGraphicInstance({ definitionId: preset.definitionId }).params, + ...preset.params, + }; +} + +export function parseShapeStickerId({ + stickerId, +}: { + stickerId: string; +}): ShapeGraphicPreset | null { + try { + const { providerValue } = parseStickerId({ stickerId }); + return getShapePreset({ shapeKey: providerValue }); + } catch { + return null; + } +} function buildShapeUrl({ shapeKey }: { shapeKey: string }): string { - return `/shapes/${shapeKey}.svg`; + const preset = getShapePreset({ shapeKey }); + if (!preset) { + return buildGraphicPreviewUrl({ definitionId: "rectangle" }); + } + return buildGraphicPreviewUrl({ + definitionId: preset.definitionId, + params: getShapeParams({ preset }), + }); } function toStickerItem({ - shape, + preset, }: { - shape: (typeof SHAPES)[number]; + preset: ShapeGraphicPreset; }): StickerItem { return { id: buildStickerId({ providerId: SHAPES_PROVIDER_ID, - providerValue: shape.key, + providerValue: preset.shapeKey, }), provider: SHAPES_PROVIDER_ID, - name: shape.name, - previewUrl: buildShapeUrl({ shapeKey: shape.key }), - metadata: { shape: shape.key }, + name: preset.name, + previewUrl: buildShapeUrl({ shapeKey: preset.shapeKey }), + metadata: { + definitionId: preset.definitionId, + params: preset.params ?? {}, + }, }; } -function filterShapesByQuery({ - query, -}: { - query: string; -}): Array<(typeof SHAPES)[number]> { +function filterShapesByQuery({ query }: { query: string }): ShapeGraphicPreset[] { const normalizedQuery = query.trim().toLowerCase(); + const presets = getShapePresets(); if (!normalizedQuery) { - return [...SHAPES]; + return presets; } - return SHAPES.filter((shape) => - shape.name.toLowerCase().includes(normalizedQuery), - ); + return presets.filter((preset) => { + const definition = graphicsRegistry.get(preset.definitionId); + return ( + preset.name.toLowerCase().includes(normalizedQuery) || + definition.keywords.some((keyword) => + keyword.toLowerCase().includes(normalizedQuery), + ) + ); + }); } function paginateShapes({ shapes, options, }: { - shapes: Array<(typeof SHAPES)[number]>; + shapes: ShapeGraphicPreset[]; options?: { page?: number; limit?: number }; -}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } { +}): { items: ShapeGraphicPreset[]; hasMore: boolean; total: number } { const page = Math.max(1, options?.page ?? 1); - const limit = Math.max(1, options?.limit ?? SHAPES.length); + const limit = Math.max(1, options?.limit ?? getShapePresets().length); const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const pagedItems = shapes.slice(startIndex, endIndex); @@ -83,10 +166,10 @@ export const shapesProvider: StickerProvider = { const filteredShapes = filterShapesByQuery({ query }); const paged = paginateShapes({ shapes: filteredShapes, - options: { page: 1, limit: options?.limit ?? SHAPES.length }, + options: { page: 1, limit: options?.limit ?? getShapePresets().length }, }); return { - items: paged.items.map((shape) => toStickerItem({ shape })), + items: paged.items.map((preset) => toStickerItem({ preset })), total: paged.total, hasMore: paged.hasMore, }; @@ -95,15 +178,20 @@ export const shapesProvider: StickerProvider = { options, }: { options?: { page?: number; limit?: number }; - }): Promise { + }): Promise { const paged = paginateShapes({ - shapes: [...SHAPES], + shapes: getShapePresets(), options, }); return { - items: paged.items.map((shape) => toStickerItem({ shape })), - total: paged.total, - hasMore: paged.hasMore, + sections: [ + { + id: "all", + items: paged.items.map((preset) => toStickerItem({ preset })), + hasMore: paged.hasMore, + layout: "grid", + }, + ], }; }, resolveUrl({ @@ -112,7 +200,7 @@ export const shapesProvider: StickerProvider = { stickerId: string; options?: { width?: number; height?: number }; }): string { - const { providerValue } = parseStickerId({ stickerId }); - return buildShapeUrl({ shapeKey: providerValue }); + const preset = parseShapeStickerId({ stickerId }); + return buildShapeUrl({ shapeKey: preset?.shapeKey ?? "rectangle" }); }, }; diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts index 0fbad594..7148c09d 100644 --- a/apps/web/src/lib/stickers/registry.ts +++ b/apps/web/src/lib/stickers/registry.ts @@ -1,31 +1,10 @@ -import type { StickerProvider } from "@/types/stickers"; +import type { StickerProvider } from "@/lib/stickers/types"; +import { DefinitionRegistry } from "@/lib/registry"; -const providers = new Map(); - -export function registerProvider({ - provider, -}: { - provider: StickerProvider; -}): void { - providers.set(provider.id, provider); -} - -export function hasProvider({ providerId }: { providerId: string }): boolean { - return providers.has(providerId); -} - -export function getProvider({ - providerId, -}: { - providerId: string; -}): StickerProvider { - const provider = providers.get(providerId); - if (!provider) { - throw new Error(`Unknown sticker provider: ${providerId}`); +export class StickersRegistry extends DefinitionRegistry { + constructor() { + super("sticker provider"); } - return provider; } -export function getAllProviders(): StickerProvider[] { - return Array.from(providers.values()); -} +export const stickersRegistry = new StickersRegistry(); diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts index 3252cd60..0002e716 100644 --- a/apps/web/src/lib/stickers/resolver.ts +++ b/apps/web/src/lib/stickers/resolver.ts @@ -1,7 +1,7 @@ -import { getProvider } from "./registry"; +import { stickersRegistry } from "./registry"; import { parseStickerId } from "./sticker-id"; import { registerDefaultStickerProviders } from "./providers"; -import type { StickerResolveOptions } from "@/types/stickers"; +import type { StickerResolveOptions } from "@/lib/stickers/types"; export function resolveStickerId({ stickerId, @@ -13,9 +13,7 @@ export function resolveStickerId({ registerDefaultStickerProviders(); const parsedStickerId = parseStickerId({ stickerId }); - return getProvider({ - providerId: parsedStickerId.providerId, - }).resolveUrl({ + return stickersRegistry.get(parsedStickerId.providerId).resolveUrl({ stickerId, options, }); diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts index 91cbcaae..9d16e4be 100644 --- a/apps/web/src/lib/stickers/types.ts +++ b/apps/web/src/lib/stickers/types.ts @@ -1,8 +1,71 @@ -export type { - StickerItem, - StickerProvider, - StickerProviderBrowseOptions, - StickerProviderSearchOptions, - StickerResolveOptions, - StickerSearchResult, -} from "@/types/stickers"; +import type { STICKER_CATEGORIES } from "@/constants/sticker-constants"; + +export type StickerCategory = keyof typeof STICKER_CATEGORIES; + +export interface StickerItem { + id: string; + provider: string; + name: string; + previewUrl: string; + metadata: Record; +} + +export interface StickerSearchResult { + items: StickerItem[]; + total: number; + hasMore: boolean; +} + +export interface StickerBrowseSection { + id: string; + title?: string; + items: StickerItem[]; + hasMore?: boolean; + layout?: "grid" | "row"; + action?: { + type: "see-all"; + category?: StickerCategory; + sectionId?: string; + }; +} + +export interface StickerBrowseResult { + sections: StickerBrowseSection[]; +} + +export interface StickerProviderSearchOptions { + limit?: number; +} + +export interface StickerProviderBrowseOptions { + page?: number; + limit?: number; +} + +export interface StickerResolveOptions { + width?: number; + height?: number; +} + +export interface StickerProvider { + id: string; + search({ + query, + options, + }: { + query: string; + options?: StickerProviderSearchOptions; + }): Promise; + browse({ + options, + }: { + options?: StickerProviderBrowseOptions; + }): Promise; + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: StickerResolveOptions; + }): string; +} diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 3eadcec2..32d0d80e 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -1,5 +1,5 @@ -import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants"; -import type { TextBackground, TextElement } from "@/types/timeline"; +import type { TextBackground, TextElement } from "@/lib/timeline"; +import { DEFAULTS } from "@/lib/timeline/defaults"; type TextRect = { left: number; @@ -14,6 +14,23 @@ export interface TextBlockMeasurement { maxWidth: number; } +type CanvasContext = + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + +export function setCanvasLetterSpacing({ + ctx, + letterSpacingPx, +}: { + ctx: CanvasContext; + letterSpacingPx: number; +}): void { + if ("letterSpacing" in ctx) { + (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = + `${letterSpacingPx}px`; + } +} + export function getMetricAscent({ metrics, fallbackFontSize, @@ -118,11 +135,11 @@ export function getTextBackgroundRect({ const textRect = getTextRect({ textAlign, block }); const paddingX = - (background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio; + (background.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio; const paddingY = - (background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio; - const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX; - const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY; + (background.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio; + const offsetX = background.offsetX ?? DEFAULTS.text.background.offsetX; + const offsetY = background.offsetY ?? DEFAULTS.text.background.offsetY; return { left: textRect.left - paddingX + offsetX, diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts new file mode 100644 index 00000000..44b2b1ff --- /dev/null +++ b/apps/web/src/lib/text/measure-element.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index b6761b0d..a6602566 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -1,4 +1,8 @@ -import type { TTimeCode } from "@/types/time"; +export type TTimeCode = + | "MM:SS" + | "HH:MM:SS" + | "HH:MM:SS:CS" + | "HH:MM:SS:FF"; export function roundToFrame({ time, diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts new file mode 100644 index 00000000..44592df4 --- /dev/null +++ b/apps/web/src/lib/timeline/audio-constants.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..90d34668 --- /dev/null +++ b/apps/web/src/lib/timeline/audio-state.ts @@ -0,0 +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 Math.pow(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 efaa8f74..f5c034c7 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,4 +1,4 @@ -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; import { roundToFrame } from "@/lib/time"; export const BOOKMARK_TIME_EPSILON = 0.001; diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts new file mode 100644 index 00000000..7f52905f --- /dev/null +++ b/apps/web/src/lib/timeline/defaults.ts @@ -0,0 +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, + }, +}; diff --git a/apps/web/src/lib/timeline/drag.ts b/apps/web/src/lib/timeline/drag.ts new file mode 100644 index 00000000..89b1593f --- /dev/null +++ b/apps/web/src/lib/timeline/drag.ts @@ -0,0 +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; diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index b502974d..b4f53948 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -2,10 +2,10 @@ import type { TimelineTrack, ElementType, TimelineElement, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { wouldElementOverlap } from "./element-utils"; -import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; +import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; import { isMainTrack, enforceMainTrackStart } from "./track-utils"; function findElementAtPosition({ diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 11564376..c44ae044 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,30 +1,33 @@ -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { - DEFAULT_BLEND_MODE, - DEFAULT_OPACITY, - DEFAULT_TRANSFORM, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import type { - CreateEffectElement, - CreateTimelineElement, - CreateVideoElement, - CreateImageElement, - CreateStickerElement, - CreateUploadAudioElement, - CreateLibraryAudioElement, - TextBackground, - TextElement, - TimelineElement, - TimelineTrack, - AudioElement, - VideoElement, - ImageElement, - VisualElement, - UploadAudioElement, -} from "@/types/timeline"; -import type { MediaType } from "@/types/assets"; + 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( @@ -36,12 +39,19 @@ export function canElementHaveAudio( export function isVisualElement( element: TimelineElement, ): element is VisualElement { - return ( - element.type === "video" || - element.type === "image" || - element.type === "text" || - element.type === "sticker" - ); + 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( @@ -50,6 +60,14 @@ export function canElementBeHidden( 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 { @@ -68,55 +86,6 @@ export function requiresMediaId({ ); } -export function checkElementOverlaps({ - elements, -}: { - elements: TimelineElement[]; -}): boolean { - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - - for (let i = 0; i < sortedElements.length - 1; i++) { - const current = sortedElements[i]; - const next = sortedElements[i + 1]; - - const currentEnd = current.startTime + current.duration; - - if (currentEnd > next.startTime) return true; - } - - return false; -} - -export function resolveElementOverlaps({ - elements, -}: { - elements: TimelineElement[]; -}): TimelineElement[] { - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - const resolvedElements: TimelineElement[] = []; - - for (let i = 0; i < sortedElements.length; i++) { - const current = { ...sortedElements[i] }; - - if (resolvedElements.length > 0) { - const previous = resolvedElements[resolvedElements.length - 1]; - const previousEnd = previous.startTime + previous.duration; - - if (current.startTime < previousEnd) { - current.startTime = previousEnd; - } - } - - resolvedElements.push(current); - } - - return resolvedElements; -} - export function wouldElementOverlap({ elements, startTime, @@ -138,9 +107,8 @@ export function wouldElementOverlap({ function buildTextBackground( raw: Partial | undefined, ): TextBackground { - const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color; - const enabled = - typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent"; + const color = raw?.color ?? DEFAULTS.text.element.background.color; + const enabled = raw?.enabled ?? color !== "transparent"; return { enabled, color, @@ -163,28 +131,25 @@ export function buildTextElement({ return { type: "text", - name: t.name ?? DEFAULT_TEXT_ELEMENT.name, - content: t.content ?? DEFAULT_TEXT_ELEMENT.content, + 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: - typeof t.fontSize === "number" - ? t.fontSize - : DEFAULT_TEXT_ELEMENT.fontSize, - fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, - color: t.color ?? DEFAULT_TEXT_ELEMENT.color, + 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 ?? DEFAULT_TEXT_ELEMENT.textAlign, - fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, - fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, - textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight, - transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, - opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, - blendMode: t.blendMode ?? DEFAULT_BLEND_MODE, + 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, }; } @@ -214,10 +179,14 @@ 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; @@ -225,17 +194,52 @@ export function buildStickerElement({ 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: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, }; } -export function buildVideoElement({ +function buildVideoElement({ mediaId, name, duration, @@ -257,13 +261,17 @@ export function buildVideoElement({ sourceDuration: duration, muted: false, hidden: false, - transform: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + volume: DEFAULTS.element.volume, }; } -export function buildImageElement({ +function buildImageElement({ mediaId, name, duration, @@ -283,13 +291,16 @@ export function buildImageElement({ trimStart: 0, trimEnd: 0, hidden: false, - transform: { ...DEFAULT_TRANSFORM }, - opacity: DEFAULT_OPACITY, - blendMode: DEFAULT_BLEND_MODE, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, }; } -export function buildUploadAudioElement({ +function buildUploadAudioElement({ mediaId, name, duration, @@ -312,7 +323,7 @@ export function buildUploadAudioElement({ trimStart: 0, trimEnd: 0, sourceDuration: duration, - volume: 1, + volume: DEFAULTS.element.volume, muted: false, }; if (buffer) { @@ -375,7 +386,7 @@ export function buildLibraryAudioElement({ trimStart: 0, trimEnd: 0, sourceDuration: duration, - volume: 1, + volume: DEFAULTS.element.volume, muted: false, }; if (buffer) { @@ -407,7 +418,7 @@ export function getElementsAtTime({ return result; } -export function collectFontFamilies({ +export function getElementFontFamilies({ tracks, }: { tracks: TimelineTrack[]; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 887ff7ab..29414722 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,5 +1,7 @@ -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "./types"; +export * from "./types"; +export * from "./drag"; export * from "./track-utils"; export * from "./track-element-update"; export * from "./element-utils"; diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts index 5f37bf29..369759f3 100644 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -1,4 +1,4 @@ -import type { TimelineElement } from "@/types/timeline"; +import type { TimelineElement } from "@/lib/timeline"; export function rippleShiftElements({ elements, diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts new file mode 100644 index 00000000..ac527600 --- /dev/null +++ b/apps/web/src/lib/timeline/selection-hit-testing.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index c7fac090..ce2e9b1f 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,4 +1,4 @@ -import type { Bookmark, TimelineTrack } from "@/types/timeline"; +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"; diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 6118b327..5230d4e7 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,4 +1,4 @@ -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; export function updateElementInTracks({ tracks, diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 94ff21ba..cde2c814 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -4,13 +4,15 @@ import type { ElementType, VideoTrack, AudioTrack, - StickerTrack, + GraphicTrack, TextTrack, EffectTrack, TimelineElement, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { + ELEMENT_TRACK_MAP, TRACK_CONFIG, + ELEMENT_TYPE_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; import { generateUUID } from "@/utils/id"; @@ -23,16 +25,12 @@ export function canTracktHaveAudio( export function canTrackBeHidden( track: TimelineTrack, -): track is VideoTrack | TextTrack | StickerTrack | EffectTrack { +): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { return track.type !== "audio"; } -export function getTrackColor({ type }: { type: TrackType }) { - return TRACK_CONFIG[type]; -} - -export function getTrackClasses({ type }: { type: TrackType }) { - return TRACK_CONFIG[type].background.trim(); +export function getElementClasses({ type }: { type: TrackType }) { + return ELEMENT_TYPE_CONFIG[type].background.trim(); } export function getTrackHeight({ type }: { type: TrackType }): number { @@ -97,11 +95,11 @@ export function buildEmptyTrack({ elements: [], hidden: false, }; - case "sticker": + case "graphic": return { id, name: trackName, - type: "sticker", + type: "graphic", elements: [], hidden: false, }; @@ -214,14 +212,7 @@ export function canElementGoOnTrack({ elementType: ElementType; trackType: TrackType; }): boolean { - if (elementType === "text") return trackType === "text"; - if (elementType === "audio") return trackType === "audio"; - if (elementType === "sticker") return trackType === "sticker"; - if (elementType === "effect") return trackType === "effect"; - if (elementType === "video" || elementType === "image") { - return trackType === "video"; - } - return false; + return ELEMENT_TRACK_MAP[elementType] === trackType; } export function validateElementTrackCompatibility({ diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts new file mode 100644 index 00000000..16e06bf1 --- /dev/null +++ b/apps/web/src/lib/timeline/types.ts @@ -0,0 +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; +} diff --git a/apps/web/src/lib/transcription/caption.ts b/apps/web/src/lib/transcription/caption.ts index c773b7b0..c7c1b7b4 100644 --- a/apps/web/src/lib/transcription/caption.ts +++ b/apps/web/src/lib/transcription/caption.ts @@ -1,4 +1,4 @@ -import type { TranscriptionSegment, CaptionChunk } from "@/types/transcription"; +import type { TranscriptionSegment, CaptionChunk } from "@/lib/transcription/types"; import { DEFAULT_WORDS_PER_CAPTION, MIN_CAPTION_DURATION_SECONDS, diff --git a/apps/web/src/lib/transcription/types.ts b/apps/web/src/lib/transcription/types.ts new file mode 100644 index 00000000..9724ad70 --- /dev/null +++ b/apps/web/src/lib/transcription/types.ts @@ -0,0 +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; +} -- cgit v1.3.1