summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/actions/definitions.ts209
-rw-r--r--apps/web/src/lib/actions/index.ts3
-rw-r--r--apps/web/src/lib/actions/keybinding.ts79
-rw-r--r--apps/web/src/lib/actions/registry.ts63
-rw-r--r--apps/web/src/lib/actions/types.ts44
-rw-r--r--apps/web/src/lib/animation/__tests__/binding-values.test.ts26
-rw-r--r--apps/web/src/lib/animation/bezier.ts90
-rw-r--r--apps/web/src/lib/animation/binding-values.ts344
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts95
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts100
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts99
-rw-r--r--apps/web/src/lib/animation/graphic-param-channel.ts73
-rw-r--r--apps/web/src/lib/animation/index.ts96
-rw-r--r--apps/web/src/lib/animation/interpolation.ts375
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts294
-rw-r--r--apps/web/src/lib/animation/keyframes.ts1338
-rw-r--r--apps/web/src/lib/animation/property-groups.ts39
-rw-r--r--apps/web/src/lib/animation/property-registry.ts390
-rw-r--r--apps/web/src/lib/animation/resolve.ts184
-rw-r--r--apps/web/src/lib/animation/target-resolver.ts285
-rw-r--r--apps/web/src/lib/animation/transform.ts1
-rw-r--r--apps/web/src/lib/animation/types.ts222
-rw-r--r--apps/web/src/lib/auth/client.ts6
-rw-r--r--apps/web/src/lib/auth/server.ts43
-rw-r--r--apps/web/src/lib/background/blur.ts10
-rw-r--r--apps/web/src/lib/background/color.ts1
-rw-r--r--apps/web/src/lib/blog/query.ts133
-rw-r--r--apps/web/src/lib/blog/types.ts92
-rw-r--r--apps/web/src/lib/cancel-interaction.ts24
-rw-r--r--apps/web/src/lib/canvas/sizes.ts10
-rw-r--r--apps/web/src/lib/changelog/components/changelog-notification.tsx87
-rw-r--r--apps/web/src/lib/changelog/components/copy-markdown-button.tsx98
-rw-r--r--apps/web/src/lib/changelog/components/release.tsx153
-rw-r--r--apps/web/src/lib/changelog/entries/0.1.0.md37
-rw-r--r--apps/web/src/lib/changelog/entries/0.2.0.md47
-rw-r--r--apps/web/src/lib/changelog/entries/0.3.0.md114
-rw-r--r--apps/web/src/lib/changelog/entries/0.4.0.md19
-rw-r--r--apps/web/src/lib/changelog/utils.ts82
-rw-r--r--apps/web/src/lib/clipboard/handlers/elements.ts48
-rw-r--r--apps/web/src/lib/clipboard/handlers/index.ts49
-rw-r--r--apps/web/src/lib/clipboard/handlers/keyframes.ts170
-rw-r--r--apps/web/src/lib/clipboard/index.ts2
-rw-r--r--apps/web/src/lib/clipboard/types.ts79
-rw-r--r--apps/web/src/lib/commands/base-command.ts31
-rw-r--r--apps/web/src/lib/commands/batch-command.ts39
-rw-r--r--apps/web/src/lib/commands/index.ts8
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts135
-rw-r--r--apps/web/src/lib/commands/media/index.ts2
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts111
-rw-r--r--apps/web/src/lib/commands/preview-tracker.ts23
-rw-r--r--apps/web/src/lib/commands/project/index.ts1
-rw-r--r--apps/web/src/lib/commands/project/update-project-settings.ts46
-rw-r--r--apps/web/src/lib/commands/scene/create-scene.ts41
-rw-r--r--apps/web/src/lib/commands/scene/delete-scene.ts59
-rw-r--r--apps/web/src/lib/commands/scene/index.ts7
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts59
-rw-r--r--apps/web/src/lib/commands/scene/remove-bookmark.ts59
-rw-r--r--apps/web/src/lib/commands/scene/rename-scene.ts46
-rw-r--r--apps/web/src/lib/commands/scene/toggle-bookmark.ts52
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts135
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts72
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/index.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts66
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts74
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts68
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts87
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts11
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts291
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts6
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts69
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts107
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts85
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts104
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts96
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts131
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/index.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts169
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts65
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts76
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts145
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-elements.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/index.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts115
-rw-r--r--apps/web/src/lib/commands/timeline/track/index.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts30
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts41
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts45
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts21
-rw-r--r--apps/web/src/lib/db/index.ts19
-rw-r--r--apps/web/src/lib/db/schema.ts70
-rw-r--r--apps/web/src/lib/diagnostics/types.ts8
-rw-r--r--apps/web/src/lib/drag-data.ts48
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts110
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts13
-rw-r--r--apps/web/src/lib/effects/index.ts47
-rw-r--r--apps/web/src/lib/effects/registry.ts10
-rw-r--r--apps/web/src/lib/effects/types.ts41
-rw-r--r--apps/web/src/lib/env/web.ts30
-rw-r--r--apps/web/src/lib/export/defaults.ts7
-rw-r--r--apps/web/src/lib/export/index.ts70
-rw-r--r--apps/web/src/lib/export/mime-types.ts4
-rw-r--r--apps/web/src/lib/feedback/components/feedback-popover.tsx242
-rw-r--r--apps/web/src/lib/feedback/index.ts3
-rw-r--r--apps/web/src/lib/feedback/queries.ts14
-rw-r--r--apps/web/src/lib/feedback/types.ts11
-rw-r--r--apps/web/src/lib/fonts/google-fonts.ts88
-rw-r--r--apps/web/src/lib/fonts/system-fonts.ts11
-rw-r--r--apps/web/src/lib/fonts/types.ts24
-rw-r--r--apps/web/src/lib/fps/__tests__/fps.test.ts65
-rw-r--r--apps/web/src/lib/fps/defaults.ts3
-rw-r--r--apps/web/src/lib/fps/presets.ts7
-rw-r--r--apps/web/src/lib/fps/utils.ts95
-rw-r--r--apps/web/src/lib/gradients/canvas.ts923
-rw-r--r--apps/web/src/lib/gradients/index.ts2
-rw-r--r--apps/web/src/lib/gradients/parser.ts652
-rw-r--r--apps/web/src/lib/graphics/definitions/ellipse.ts73
-rw-r--r--apps/web/src/lib/graphics/definitions/index.ts29
-rw-r--r--apps/web/src/lib/graphics/definitions/polygon.ts211
-rw-r--r--apps/web/src/lib/graphics/definitions/rectangle.ts85
-rw-r--r--apps/web/src/lib/graphics/definitions/shared.ts16
-rw-r--r--apps/web/src/lib/graphics/definitions/star.ts109
-rw-r--r--apps/web/src/lib/graphics/index.ts125
-rw-r--r--apps/web/src/lib/graphics/registry.ts10
-rw-r--r--apps/web/src/lib/graphics/stroke.ts102
-rw-r--r--apps/web/src/lib/graphics/types.ts23
-rw-r--r--apps/web/src/lib/guides/definitions/custom.tsx24
-rw-r--r--apps/web/src/lib/guides/definitions/grid.tsx125
-rw-r--r--apps/web/src/lib/guides/definitions/platforms.tsx65
-rw-r--r--apps/web/src/lib/guides/definitions/tiktok-layout.tsx335
-rw-r--r--apps/web/src/lib/guides/grid.ts4
-rw-r--r--apps/web/src/lib/guides/index.ts13
-rw-r--r--apps/web/src/lib/guides/registry.tsx28
-rw-r--r--apps/web/src/lib/guides/types.ts20
-rw-r--r--apps/web/src/lib/masks/__tests__/snap.test.ts487
-rw-r--r--apps/web/src/lib/masks/custom-path.ts842
-rw-r--r--apps/web/src/lib/masks/definitions/box-like.ts313
-rw-r--r--apps/web/src/lib/masks/definitions/cinematic-bars.ts135
-rw-r--r--apps/web/src/lib/masks/definitions/custom.ts652
-rw-r--r--apps/web/src/lib/masks/definitions/diamond.ts100
-rw-r--r--apps/web/src/lib/masks/definitions/ellipse.ts75
-rw-r--r--apps/web/src/lib/masks/definitions/heart.ts142
-rw-r--r--apps/web/src/lib/masks/definitions/index.ts74
-rw-r--r--apps/web/src/lib/masks/definitions/rectangle.ts97
-rw-r--r--apps/web/src/lib/masks/definitions/split.ts382
-rw-r--r--apps/web/src/lib/masks/definitions/star.ts140
-rw-r--r--apps/web/src/lib/masks/definitions/text.ts438
-rw-r--r--apps/web/src/lib/masks/dimensions.ts2
-rw-r--r--apps/web/src/lib/masks/feather.ts2
-rw-r--r--apps/web/src/lib/masks/geometry.ts121
-rw-r--r--apps/web/src/lib/masks/handle-positions.ts497
-rw-r--r--apps/web/src/lib/masks/index.ts18
-rw-r--r--apps/web/src/lib/masks/param-update.ts26
-rw-r--r--apps/web/src/lib/masks/registry.ts122
-rw-r--r--apps/web/src/lib/masks/snap.ts336
-rw-r--r--apps/web/src/lib/masks/types.ts298
-rw-r--r--apps/web/src/lib/masks/utils.ts64
-rw-r--r--apps/web/src/lib/media/audio-mastering.ts103
-rw-r--r--apps/web/src/lib/media/audio.ts868
-rw-r--r--apps/web/src/lib/media/media-utils.ts32
-rw-r--r--apps/web/src/lib/media/mediabunny.ts168
-rw-r--r--apps/web/src/lib/media/processing.ts296
-rw-r--r--apps/web/src/lib/media/types.ts9
-rw-r--r--apps/web/src/lib/media/upload-toast.ts51
-rw-r--r--apps/web/src/lib/media/waveform-summary.ts233
-rw-r--r--apps/web/src/lib/panels/layout.ts9
-rw-r--r--apps/web/src/lib/params.ts50
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts308
-rw-r--r--apps/web/src/lib/preview/hit-test.ts102
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts101
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts539
-rw-r--r--apps/web/src/lib/preview/zoom.ts7
-rw-r--r--apps/web/src/lib/project/types.ts54
-rw-r--r--apps/web/src/lib/rate-limit.ts21
-rw-r--r--apps/web/src/lib/registry.ts40
-rw-r--r--apps/web/src/lib/rendering.ts28
-rw-r--r--apps/web/src/lib/retime/__tests__/resolve.test.ts61
-rw-r--r--apps/web/src/lib/retime/__tests__/split.test.ts31
-rw-r--r--apps/web/src/lib/retime/audio-stretch.ts182
-rw-r--r--apps/web/src/lib/retime/index.ts5
-rw-r--r--apps/web/src/lib/retime/presets.ts12
-rw-r--r--apps/web/src/lib/retime/rate.ts25
-rw-r--r--apps/web/src/lib/retime/resolve.ts48
-rw-r--r--apps/web/src/lib/retime/split.ts34
-rw-r--r--apps/web/src/lib/ripple/apply.ts77
-rw-r--r--apps/web/src/lib/ripple/diff.ts279
-rw-r--r--apps/web/src/lib/ripple/index.ts4
-rw-r--r--apps/web/src/lib/ripple/shift.ts17
-rw-r--r--apps/web/src/lib/scenes.ts113
-rw-r--r--apps/web/src/lib/selection/attributes.ts1
-rw-r--r--apps/web/src/lib/selection/context.tsx52
-rw-r--r--apps/web/src/lib/selection/editor-selection.ts25
-rw-r--r--apps/web/src/lib/selection/hit-testing.ts50
-rw-r--r--apps/web/src/lib/selection/hooks/use-box-select.ts170
-rw-r--r--apps/web/src/lib/selection/hooks/use-selection-scope.ts28
-rw-r--r--apps/web/src/lib/selection/index.ts4
-rw-r--r--apps/web/src/lib/selection/scope.ts30
-rw-r--r--apps/web/src/lib/selection/selectable-item.tsx107
-rw-r--r--apps/web/src/lib/selection/selectable-surface.tsx288
-rw-r--r--apps/web/src/lib/selection/selection-box.tsx50
-rw-r--r--apps/web/src/lib/selection/state.ts165
-rw-r--r--apps/web/src/lib/selection/types.ts39
-rw-r--r--apps/web/src/lib/site/brand.ts13
-rw-r--r--apps/web/src/lib/site/external-tools.ts24
-rw-r--r--apps/web/src/lib/site/social.ts5
-rw-r--r--apps/web/src/lib/site/sponsors.ts24
-rw-r--r--apps/web/src/lib/sounds/types.ts39
-rw-r--r--apps/web/src/lib/stickers/__tests__/sticker-id.test.ts34
-rw-r--r--apps/web/src/lib/stickers/categories.ts7
-rw-r--r--apps/web/src/lib/stickers/index.ts317
-rw-r--r--apps/web/src/lib/stickers/intrinsic-size.ts1
-rw-r--r--apps/web/src/lib/stickers/providers/countries-data.ts1819
-rw-r--r--apps/web/src/lib/stickers/providers/flags.ts202
-rw-r--r--apps/web/src/lib/stickers/providers/index.ts24
-rw-r--r--apps/web/src/lib/stickers/providers/logos.ts31
-rw-r--r--apps/web/src/lib/stickers/providers/shapes.ts206
-rw-r--r--apps/web/src/lib/stickers/registry.ts10
-rw-r--r--apps/web/src/lib/stickers/resolver.ts20
-rw-r--r--apps/web/src/lib/stickers/sticker-id.ts34
-rw-r--r--apps/web/src/lib/stickers/types.ts71
-rw-r--r--apps/web/src/lib/subtitles/ass.ts514
-rw-r--r--apps/web/src/lib/subtitles/build-subtitle-text-element.ts333
-rw-r--r--apps/web/src/lib/subtitles/insert.ts40
-rw-r--r--apps/web/src/lib/subtitles/parse.ts28
-rw-r--r--apps/web/src/lib/subtitles/srt.ts98
-rw-r--r--apps/web/src/lib/subtitles/types.ts46
-rw-r--r--apps/web/src/lib/text/background.ts2
-rw-r--r--apps/web/src/lib/text/layout.ts226
-rw-r--r--apps/web/src/lib/text/measure-element.ts133
-rw-r--r--apps/web/src/lib/text/primitives.ts241
-rw-r--r--apps/web/src/lib/text/typography.ts9
-rw-r--r--apps/web/src/lib/timeline/audio-constants.ts2
-rw-r--r--apps/web/src/lib/timeline/audio-display.ts64
-rw-r--r--apps/web/src/lib/timeline/audio-separation/index.ts143
-rw-r--r--apps/web/src/lib/timeline/audio-state.ts116
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts145
-rw-r--r--apps/web/src/lib/timeline/creation.ts3
-rw-r--r--apps/web/src/lib/timeline/defaults.ts77
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts21
-rw-r--r--apps/web/src/lib/timeline/drag.ts42
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts426
-rw-r--r--apps/web/src/lib/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/lib/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/lib/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/lib/timeline/group-move/snap.ts71
-rw-r--r--apps/web/src/lib/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/lib/timeline/group-move/types.ts36
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts249
-rw-r--r--apps/web/src/lib/timeline/group-resize/index.ts9
-rw-r--r--apps/web/src/lib/timeline/group-resize/types.ts36
-rw-r--r--apps/web/src/lib/timeline/index.ts29
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts80
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts661
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts158
-rw-r--r--apps/web/src/lib/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts12
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts88
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts54
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts44
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts277
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts123
-rw-r--r--apps/web/src/lib/timeline/placement/types.ts39
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts257
-rw-r--r--apps/web/src/lib/timeline/scale.ts3
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts182
-rw-r--r--apps/web/src/lib/timeline/track-capabilities.ts20
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts147
-rw-r--r--apps/web/src/lib/timeline/tracks.ts9
-rw-r--r--apps/web/src/lib/timeline/types.ts314
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts211
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts90
-rw-r--r--apps/web/src/lib/transcription/audio.ts4
-rw-r--r--apps/web/src/lib/transcription/caption-defaults.ts2
-rw-r--r--apps/web/src/lib/transcription/caption.ts49
-rw-r--r--apps/web/src/lib/transcription/diagnostics.ts25
-rw-r--r--apps/web/src/lib/transcription/languages.ts14
-rw-r--r--apps/web/src/lib/transcription/models.ts34
-rw-r--r--apps/web/src/lib/transcription/supported-languages.ts1
-rw-r--r--apps/web/src/lib/transcription/types.ts47
-rw-r--r--apps/web/src/lib/wasm/index.ts1
-rw-r--r--apps/web/src/lib/wasm/ticks.ts3
284 files changed, 0 insertions, 32846 deletions
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
deleted file mode 100644
index f6846846..00000000
--- a/apps/web/src/lib/actions/definitions.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import type {
- KeybindingConfig,
- ShortcutKey,
-} from "@/lib/actions/keybinding";
-import type { TActionWithOptionalArgs } from "./types";
-
-export type TActionCategory =
- | "playback"
- | "navigation"
- | "editing"
- | "selection"
- | "history"
- | "timeline"
- | "controls"
- | "assets";
-
-export interface TActionBaseDefinition {
- description: string;
- category: TActionCategory;
- args?: Record<string, unknown>;
-}
-
-export interface TActionDefinition extends TActionBaseDefinition {
- defaultShortcuts?: readonly ShortcutKey[];
-}
-
-export const ACTIONS = {
- "toggle-play": {
- description: "Play/Pause",
- category: "playback",
- },
- "stop-playback": {
- description: "Stop playback",
- category: "playback",
- },
- "seek-forward": {
- description: "Seek forward 1 second",
- category: "playback",
- args: { seconds: "number" },
- },
- "seek-backward": {
- description: "Seek backward 1 second",
- category: "playback",
- args: { seconds: "number" },
- },
- "frame-step-forward": {
- description: "Frame step forward",
- category: "navigation",
- },
- "frame-step-backward": {
- description: "Frame step backward",
- category: "navigation",
- },
- "jump-forward": {
- description: "Jump forward 5 seconds",
- category: "navigation",
- args: { seconds: "number" },
- },
- "jump-backward": {
- description: "Jump backward 5 seconds",
- category: "navigation",
- args: { seconds: "number" },
- },
- "goto-start": {
- description: "Go to timeline start",
- category: "navigation",
- },
- "goto-end": {
- description: "Go to timeline end",
- category: "navigation",
- },
- split: {
- description: "Split elements at playhead",
- category: "editing",
- },
- "split-left": {
- description: "Split and remove left",
- category: "editing",
- },
- "split-right": {
- description: "Split and remove right",
- category: "editing",
- },
- "delete-selected": {
- description: "Delete current selection",
- category: "editing",
- },
- "copy-selected": {
- description: "Copy selected elements",
- category: "editing",
- },
- "paste-copied": {
- description: "Paste elements at playhead",
- category: "editing",
- },
- "toggle-snapping": {
- description: "Toggle snapping",
- category: "editing",
- },
- "toggle-ripple-editing": {
- description: "Toggle ripple editing",
- category: "editing",
- },
- "toggle-source-audio": {
- description: "Extract or recover source audio",
- category: "editing",
- },
- "select-all": {
- description: "Select all elements",
- category: "selection",
- },
- "cancel-interaction": {
- description: "Cancel current interaction",
- category: "controls",
- },
- "deselect-all": {
- description: "Deselect all elements",
- category: "selection",
- },
- "duplicate-selected": {
- description: "Duplicate selected element",
- category: "selection",
- },
- "toggle-elements-muted-selected": {
- description: "Mute/unmute selected elements",
- category: "selection",
- },
- "toggle-elements-visibility-selected": {
- description: "Show/hide selected elements",
- category: "selection",
- },
- "toggle-bookmark": {
- description: "Toggle bookmark at playhead",
- category: "timeline",
- },
- undo: {
- description: "Undo",
- category: "history",
- },
- redo: {
- description: "Redo",
- category: "history",
- },
- "remove-media-asset": {
- description: "Remove media asset",
- category: "assets",
- args: { projectId: "string", assetId: "string" },
- },
- "remove-media-assets": {
- description: "Remove media assets",
- category: "assets",
- args: { projectId: "string", assetIds: "string[]" },
- },
-} as const satisfies Record<string, TActionBaseDefinition>;
-
-export type TAction = keyof typeof ACTIONS;
-
-const ACTION_DEFAULT_SHORTCUTS = {
- "toggle-play": ["space", "k"],
- "seek-forward": ["l"],
- "seek-backward": ["j"],
- "frame-step-forward": ["right"],
- "frame-step-backward": ["left"],
- "jump-forward": ["shift+right"],
- "jump-backward": ["shift+left"],
- "goto-start": ["home", "enter"],
- "goto-end": ["end"],
- split: ["s"],
- "split-left": ["q"],
- "split-right": ["w"],
- "delete-selected": ["backspace", "delete"],
- "copy-selected": ["ctrl+c"],
- "paste-copied": ["ctrl+v"],
- "toggle-snapping": ["n"],
- "select-all": ["ctrl+a"],
- "cancel-interaction": ["escape"],
- "duplicate-selected": ["ctrl+d"],
- undo: ["ctrl+z"],
- redo: ["ctrl+shift+z", "ctrl+y"],
-} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
-
-const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
- Record<TAction, readonly ShortcutKey[]>
-> = ACTION_DEFAULT_SHORTCUTS;
-
-export function getActionDefinition({
- action,
-}: {
- action: TAction;
-}): TActionDefinition {
- return {
- ...ACTIONS[action],
- defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
- };
-}
-
-export function getDefaultShortcuts(): KeybindingConfig {
- const shortcuts: KeybindingConfig = {};
-
- for (const [action, defaultShortcuts] of Object.entries(
- ACTION_DEFAULT_SHORTCUTS,
- ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
- for (const shortcut of defaultShortcuts) {
- shortcuts[shortcut] = action;
- }
- }
-
- return shortcuts;
-}
diff --git a/apps/web/src/lib/actions/index.ts b/apps/web/src/lib/actions/index.ts
deleted file mode 100644
index 801a846a..00000000
--- a/apps/web/src/lib/actions/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./definitions";
-export * from "./types";
-export * from "./registry";
diff --git a/apps/web/src/lib/actions/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts
deleted file mode 100644
index 65ef4538..00000000
--- a/apps/web/src/lib/actions/keybinding.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index e69b3b9f..00000000
--- a/apps/web/src/lib/actions/registry.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import type {
- TAction,
- TActionFunc,
- TActionWithArgs,
- TActionWithOptionalArgs,
- TActionArgsMap,
- TArgOfAction,
- TInvocationTrigger,
-} from "./types";
-
-type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
-const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
-
-export function bindAction<A extends TAction>(
- action: A,
- handler: TActionFunc<A>,
-) {
- const handlers = boundActions[action];
- const typedHandler = handler as ActionHandler;
- if (handlers) {
- handlers.push(typedHandler);
- } else {
- boundActions[action] = [typedHandler];
- }
-}
-
-export function unbindAction<A extends TAction>(
- action: A,
- handler: TActionFunc<A>,
-) {
- const handlers = boundActions[action];
- if (!handlers) return;
-
- const typedHandler = handler as ActionHandler;
- boundActions[action] = handlers.filter((h) => h !== typedHandler);
-
- if (boundActions[action]?.length === 0) {
- delete boundActions[action];
- }
-}
-
-type InvokeActionFunc = {
- (
- action: TActionWithOptionalArgs,
- args?: undefined,
- trigger?: TInvocationTrigger,
- ): void;
- <A extends TActionWithArgs>(
- action: A,
- args: TActionArgsMap[A],
- trigger?: TInvocationTrigger,
- ): void;
-};
-
-export const invokeAction: InvokeActionFunc = <A extends TAction>(
- action: A,
- args?: TArgOfAction<A>,
- trigger?: TInvocationTrigger,
-) => {
- 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
deleted file mode 100644
index 41258c7b..00000000
--- a/apps/web/src/lib/actions/types.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { MutableRefObject } from "react";
-import type { TAction } from "./definitions";
-
-export type { TAction };
-
-export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | undefined;
- "jump-forward": { seconds: number } | undefined;
- "jump-backward": { seconds: number } | undefined;
- "remove-media-asset": { projectId: string; assetId: string };
- "remove-media-assets": { projectId: string; assetIds: string[] };
-};
-
-type TKeysWithValueUndefined<T> = {
- [K in keyof T]: undefined extends T[K] ? K : never;
-}[keyof T];
-
-export type TActionWithArgs = keyof TActionArgsMap;
-
-export type TActionWithOptionalArgs =
- | TActionWithNoArgs
- | TKeysWithValueUndefined<TActionArgsMap>;
-
-export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
-
-export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
- : undefined;
-
-export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_?: undefined, trigger?: TInvocationTrigger) => void;
-
-export type TInvocationTrigger = "keypress" | "mouseclick";
-
-export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
-};
-
-export type TActionHandlerOptions =
- | MutableRefObject<boolean>
- | boolean
- | undefined;
diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts
deleted file mode 100644
index c5a354e4..00000000
--- a/apps/web/src/lib/animation/__tests__/binding-values.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- composeAnimationValue,
- createAnimationBinding,
-} from "@/lib/animation/binding-values";
-
-describe("binding values", () => {
- test("formats composed animated colors as hex", () => {
- const binding = createAnimationBinding({
- path: "color",
- kind: "color",
- });
-
- expect(
- composeAnimationValue({
- binding,
- componentValues: {
- r: 1,
- g: 0,
- b: 0,
- a: 1,
- },
- }),
- ).toBe("#ff0000");
- });
-});
diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts
deleted file mode 100644
index 5a9758ed..00000000
--- a/apps/web/src/lib/animation/bezier.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { ScalarAnimationKey } from "@/lib/animation/types";
-
-const BEZIER_SOLVE_ITERATIONS = 20;
-
-export function getBezierPoint({
- progress,
- p0,
- p1,
- p2,
- p3,
-}: {
- progress: number;
- p0: number;
- p1: number;
- p2: number;
- p3: number;
-}) {
- const mt = 1 - progress;
- return (
- mt * mt * mt * p0 +
- 3 * mt * mt * progress * p1 +
- 3 * mt * progress * progress * p2 +
- progress * progress * progress * p3
- );
-}
-
-export function getDefaultRightHandle({
- leftKey,
- rightKey,
-}: {
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
-}) {
- const span = rightKey.time - leftKey.time;
- const valueDelta = rightKey.value - leftKey.value;
- return {
- dt: span / 3,
- dv: valueDelta / 3,
- };
-}
-
-export function getDefaultLeftHandle({
- leftKey,
- rightKey,
-}: {
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
-}) {
- const span = rightKey.time - leftKey.time;
- const valueDelta = rightKey.value - leftKey.value;
- return {
- dt: -span / 3,
- dv: -valueDelta / 3,
- };
-}
-
-export function solveBezierProgressForTime({
- time,
- leftKey,
- rightKey,
-}: {
- time: number;
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
-}) {
- let lower = 0;
- let upper = 1;
- const rightHandle =
- leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
- const leftHandle =
- rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
-
- for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
- const mid = (lower + upper) / 2;
- const estimate = getBezierPoint({
- progress: mid,
- p0: leftKey.time,
- p1: leftKey.time + rightHandle.dt,
- p2: rightKey.time + leftHandle.dt,
- p3: rightKey.time,
- });
- if (estimate < time) {
- lower = mid;
- } else {
- upper = mid;
- }
- }
-
- return (lower + upper) / 2;
-}
diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts
deleted file mode 100644
index 66fb2b5d..00000000
--- a/apps/web/src/lib/animation/binding-values.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { converter, formatHex, formatHex8, parse } from "culori";
-import type {
- AnimationBindingComponent,
- AnimationBindingOfKind,
- AnimationBindingInstance,
- AnimationBindingKind,
- ColorAnimationBinding,
- DiscreteAnimationBinding,
- NumberAnimationBinding,
- AnimationPath,
- AnimationValue,
- DiscreteValue,
- Vector2AnimationBinding,
- VectorValue,
-} from "@/lib/animation/types";
-import { clamp } from "@/utils/math";
-
-interface LinearRgba {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-export type AnimationComponentValue = number | DiscreteValue;
-
-const toRgb = converter("rgb");
-
-function srgbToLinear({ value }: { value: number }): number {
- return value <= 0.04045
- ? value / 12.92
- : Math.pow((value + 0.055) / 1.055, 2.4);
-}
-
-function linearToSrgb({ value }: { value: number }): number {
- const clamped = clamp({ value, min: 0, max: 1 });
- return clamped <= 0.0031308
- ? clamped * 12.92
- : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null;
-}
-
-export function isVectorValue(value: unknown): value is VectorValue {
- return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
-}
-
-export type EasingMode = "independent" | "shared";
-
-/**
- * Declares how easing curves apply to a binding's components.
- * "shared" means all components always use the same curve (e.g. color — you never
- * want to ease R independently from G/B/A). "independent" means each component
- * can have its own curve.
- */
-export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
- return kind === "color" ? "shared" : "independent";
-}
-
-export function getBindingComponentKeys({
- kind,
-}: {
- kind: AnimationBindingKind;
-}): string[] {
- if (kind === "vector2") {
- return ["x", "y"];
- }
-
- if (kind === "color") {
- return ["r", "g", "b", "a"];
- }
-
- return ["value"];
-}
-
-export function buildBindingChannelId({
- path,
- componentKey,
-}: {
- path: AnimationPath;
- componentKey: string;
-}): string {
- return `${path}:${componentKey}`;
-}
-
-function createBindingComponent<TKey extends string>({
- path,
- key,
-}: {
- path: AnimationPath;
- key: TKey;
-}): AnimationBindingComponent<TKey> {
- return {
- key,
- channelId: buildBindingChannelId({ path, componentKey: key }),
- };
-}
-
-function cloneBindingComponents<TKey extends string>({
- components,
-}: {
- components: AnimationBindingComponent<TKey>[];
-}): AnimationBindingComponent<TKey>[] {
- return components.map((component) => ({ ...component }));
-}
-
-const animationBindingFactories = {
- color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
- path,
- kind: "color",
- colorSpace: "srgb-linear",
- components: [
- createBindingComponent({ path, key: "r" }),
- createBindingComponent({ path, key: "g" }),
- createBindingComponent({ path, key: "b" }),
- createBindingComponent({ path, key: "a" }),
- ],
- }),
- vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
- path,
- kind: "vector2",
- components: [
- createBindingComponent({ path, key: "x" }),
- createBindingComponent({ path, key: "y" }),
- ],
- }),
- number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
- path,
- kind: "number",
- components: [createBindingComponent({ path, key: "value" })],
- }),
- discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
- path,
- kind: "discrete",
- components: [createBindingComponent({ path, key: "value" })],
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- path,
- }: {
- path: AnimationPath;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function createAnimationBinding<TKind extends AnimationBindingKind>({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: TKind;
-}): AnimationBindingOfKind<TKind>;
-export function createAnimationBinding({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: AnimationBindingKind;
-}): AnimationBindingInstance {
- return animationBindingFactories[kind]({ path });
-}
-
-const animationBindingCloners = {
- color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- vector2: ({
- binding,
- }: {
- binding: Vector2AnimationBinding;
- }): Vector2AnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- number: ({
- binding,
- }: {
- binding: NumberAnimationBinding;
- }): NumberAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- discrete: ({
- binding,
- }: {
- binding: DiscreteAnimationBinding;
- }): DiscreteAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- binding,
- }: {
- binding: AnimationBindingOfKind<K>;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
- binding,
-}: {
- binding: AnimationBindingOfKind<TKind>;
-}): AnimationBindingOfKind<TKind>;
-export function cloneAnimationBinding({
- binding,
-}: {
- binding: AnimationBindingInstance;
-}): AnimationBindingInstance {
- switch (binding.kind) {
- case "color":
- return animationBindingCloners.color({ binding });
- case "vector2":
- return animationBindingCloners.vector2({ binding });
- case "number":
- return animationBindingCloners.number({ binding });
- case "discrete":
- return animationBindingCloners.discrete({ binding });
- }
-}
-
-export function parseColorToLinearRgba({
- color,
-}: {
- color: string;
-}): LinearRgba | null {
- const parsed = parse(color);
- const rgb = parsed ? toRgb(parsed) : null;
- if (!rgb) {
- return null;
- }
-
- return {
- r: srgbToLinear({ value: rgb.r ?? 0 }),
- g: srgbToLinear({ value: rgb.g ?? 0 }),
- b: srgbToLinear({ value: rgb.b ?? 0 }),
- a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
- };
-}
-
-export function formatLinearRgba({
- color,
-}: {
- color: LinearRgba;
-}): string {
- const rgb = {
- mode: "rgb",
- r: linearToSrgb({ value: color.r }),
- g: linearToSrgb({ value: color.g }),
- b: linearToSrgb({ value: color.b }),
- alpha: clamp({ value: color.a, min: 0, max: 1 }),
- } as const;
- return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
-}
-
-export function decomposeAnimationValue({
- kind,
- value,
-}: {
- kind: AnimationBindingKind;
- value: AnimationValue;
-}): Record<string, AnimationComponentValue> | null {
- if (kind === "number") {
- return typeof value === "number" ? { value } : null;
- }
-
- if (kind === "vector2") {
- return isVectorValue(value) ? { x: value.x, y: value.y } : null;
- }
-
- if (kind === "color") {
- if (typeof value !== "string") {
- return null;
- }
- const parsed = parseColorToLinearRgba({ color: value });
- if (!parsed) {
- return null;
- }
- return {
- r: parsed.r,
- g: parsed.g,
- b: parsed.b,
- a: parsed.a,
- };
- }
-
- return typeof value === "string" || typeof value === "boolean"
- ? { value }
- : null;
-}
-
-export function composeAnimationValue({
- binding,
- componentValues,
-}: {
- binding: AnimationBindingInstance;
- componentValues: Record<string, AnimationComponentValue | undefined>;
-}): AnimationValue | null {
- if (binding.kind === "number") {
- const value = componentValues.value;
- return typeof value === "number" ? value : null;
- }
-
- if (binding.kind === "vector2") {
- const x = componentValues.x;
- const y = componentValues.y;
- return typeof x === "number" && typeof y === "number" ? { x, y } : null;
- }
-
- if (binding.kind === "color") {
- const r = componentValues.r;
- const g = componentValues.g;
- const b = componentValues.b;
- const a = componentValues.a;
- if (
- typeof r !== "number" ||
- typeof g !== "number" ||
- typeof b !== "number" ||
- typeof a !== "number"
- ) {
- return null;
- }
- return formatLinearRgba({
- color: {
- r,
- g,
- b,
- a,
- },
- });
- }
-
- const value = componentValues.value;
- return typeof value === "string" || typeof value === "boolean" ? value : null;
-}
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
deleted file mode 100644
index 385fb448..00000000
--- a/apps/web/src/lib/animation/curve-bridge.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- getDefaultLeftHandle,
- getDefaultRightHandle,
-} from "@/lib/animation/bezier";
-import type {
- CurveHandle,
- NormalizedCubicBezier,
- ScalarAnimationKey,
-} from "@/lib/animation/types";
-
-const VALUE_EPSILON = 1e-6;
-
-function clamp01({ value }: { value: number }): number {
- return Math.max(0, Math.min(1, value));
-}
-
-export function getNormalizedCubicBezierForScalarSegment({
- leftKey,
- rightKey,
- referenceSpanValue,
-}: {
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
- /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
- referenceSpanValue?: number;
-}): NormalizedCubicBezier | null {
- const spanTime = rightKey.time - leftKey.time;
- const spanValue = rightKey.value - leftKey.value;
- const effectiveSpanValue =
- Math.abs(spanValue) > VALUE_EPSILON
- ? spanValue
- : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
- ? referenceSpanValue
- : null;
-
- if (spanTime === 0 || effectiveSpanValue === null) {
- return null;
- }
-
- const rightHandle =
- leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
- const leftHandle =
- rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
-
- return [
- clamp01({ value: rightHandle.dt / spanTime }),
- rightHandle.dv / effectiveSpanValue,
- clamp01({ value: 1 + leftHandle.dt / spanTime }),
- 1 + leftHandle.dv / effectiveSpanValue,
- ];
-}
-
-export function getCurveHandlesForNormalizedCubicBezier({
- leftKey,
- rightKey,
- cubicBezier,
- referenceSpanValue,
-}: {
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
- cubicBezier: NormalizedCubicBezier;
- /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
- referenceSpanValue?: number;
-}): {
- rightHandle: CurveHandle;
- leftHandle: CurveHandle;
-} | null {
- const spanTime = rightKey.time - leftKey.time;
- const spanValue = rightKey.value - leftKey.value;
- const effectiveSpanValue =
- Math.abs(spanValue) > VALUE_EPSILON
- ? spanValue
- : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
- ? referenceSpanValue
- : null;
-
- if (spanTime === 0 || effectiveSpanValue === null) {
- return null;
- }
-
- const [rawX1, y1, rawX2, y2] = cubicBezier;
- const x1 = clamp01({ value: rawX1 });
- const x2 = clamp01({ value: rawX2 });
-
- return {
- rightHandle: {
- dt: spanTime * x1,
- dv: effectiveSpanValue * y1,
- },
- leftHandle: {
- dt: spanTime * (x2 - 1),
- dv: effectiveSpanValue * (y2 - 1),
- },
- };
-}
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
deleted file mode 100644
index 3080e6ff..00000000
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { ParamValues } from "@/lib/params";
-import type { Effect } from "@/lib/effects/types";
-import type {
- ElementAnimations,
- EffectParamPath,
-} from "@/lib/animation/types";
-import { removeElementKeyframe } from "./keyframes";
-import { resolveAnimationPathValueAtTime } from "./resolve";
-
-export const EFFECT_PARAM_PATH_PREFIX = "effects.";
-export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
-
-export function buildEffectParamPath({
- effectId,
- paramKey,
-}: {
- effectId: string;
- paramKey: string;
-}): EffectParamPath {
- return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
-}
-
-export function isEffectParamPath(
- 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,
- localTime,
-}: {
- effect: Effect;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): ParamValues {
- const resolved: ParamValues = {};
-
- for (const [paramKey, staticValue] of Object.entries(effect.params)) {
- const path = buildEffectParamPath({ effectId: effect.id, paramKey });
- resolved[paramKey] = animations?.bindings[path]
- ? resolveAnimationPathValueAtTime({
- animations,
- propertyPath: path,
- localTime,
- fallbackValue: staticValue,
- })
- : staticValue;
- }
-
- return resolved;
-}
-
-export function removeEffectParamKeyframe({
- animations,
- effectId,
- paramKey,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- effectId: string;
- paramKey: string;
- keyframeId: string;
-}): ElementAnimations | undefined {
- return removeElementKeyframe({
- animations,
- propertyPath: buildEffectParamPath({ effectId, paramKey }),
- keyframeId,
- });
-}
diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts
deleted file mode 100644
index 19d53e27..00000000
--- a/apps/web/src/lib/animation/graph-channels.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationPath,
- ElementAnimations,
- ScalarAnimationChannel,
- ScalarGraphChannel,
- ScalarGraphKeyframeContext,
-} from "@/lib/animation/types";
-
-export interface EditableScalarChannels {
- binding: AnimationBindingInstance;
- channels: ScalarGraphChannel[];
-}
-
-function isScalarAnimationChannel(
- channel: ElementAnimations["channels"][string],
-): channel is ScalarAnimationChannel {
- return channel?.kind === "scalar";
-}
-
-export function getEditableScalarChannels({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): EditableScalarChannels | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
- return null;
- }
-
- const channels = binding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
- if (!isScalarAnimationChannel(channel)) {
- return [];
- }
-
- return [
- {
- propertyPath,
- componentKey: component.key,
- channelId: component.channelId,
- channel,
- } satisfies ScalarGraphChannel,
- ];
- });
-
- return { binding, channels };
-}
-
-export function getEditableScalarChannel({
- animations,
- propertyPath,
- componentKey,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- componentKey: string;
-}): ScalarGraphChannel | null {
- const result = getEditableScalarChannels({ animations, propertyPath });
- return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
-}
-
-export function getScalarKeyframeContext({
- animations,
- propertyPath,
- componentKey,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- componentKey: string;
- keyframeId: string;
-}): ScalarGraphKeyframeContext | null {
- const scalarChannel = getEditableScalarChannel({
- animations,
- propertyPath,
- componentKey,
- });
- if (!scalarChannel) {
- return null;
- }
-
- const keyframeIndex = scalarChannel.channel.keys.findIndex(
- (keyframe) => keyframe.id === keyframeId,
- );
- if (keyframeIndex < 0) {
- return null;
- }
-
- return {
- ...scalarChannel,
- keyframe: scalarChannel.channel.keys[keyframeIndex],
- keyframeIndex,
- previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
- nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
- };
-}
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
deleted file mode 100644
index de595060..00000000
--- a/apps/web/src/lib/animation/graphic-param-channel.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import type {
- ElementAnimations,
- GraphicParamPath,
-} from "@/lib/animation/types";
-import type { ParamValues } from "@/lib/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/lib/graphics";
-import { resolveAnimationPathValueAtTime } from "./resolve";
-
-export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
-
-export function buildGraphicParamPath({
- paramKey,
-}: {
- paramKey: string;
-}): GraphicParamPath {
- return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
-}
-
-export function isGraphicParamPath(
- propertyPath: string,
-): propertyPath is GraphicParamPath {
- return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
-}
-
-export function parseGraphicParamPath({
- propertyPath,
-}: {
- propertyPath: string;
-}): { paramKey: string } | null {
- if (!isGraphicParamPath(propertyPath)) {
- return null;
- }
-
- const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
- return paramKey.length > 0 ? { paramKey } : null;
-}
-
-export function resolveGraphicParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const baseParams = resolveGraphicParams(definition, element.params);
- const resolved: ParamValues = { ...baseParams };
-
- for (const param of definition.params) {
- const path = buildGraphicParamPath({ paramKey: param.key });
- if (!element.animations?.bindings[path]) {
- continue;
- }
-
- resolved[param.key] = resolveAnimationPathValueAtTime({
- animations: element.animations,
- propertyPath: path,
- localTime: Math.max(0, localTime),
- fallbackValue: baseParams[param.key] ?? param.default,
- });
- }
-
- return resolved;
-}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
deleted file mode 100644
index 384f3d17..00000000
--- a/apps/web/src/lib/animation/index.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-export {
- getChannelValueAtTime,
- getDiscreteChannelValueAtTime,
- getScalarChannelValueAtTime,
- getScalarSegmentInterpolation,
- normalizeChannel,
-} from "./interpolation";
-
-export {
- clampAnimationsToDuration,
- cloneAnimations,
- getChannel,
- removeElementKeyframe,
- retimeElementKeyframe,
- setBindingComponentChannel,
- setChannel,
- splitAnimationsAtTime,
- updateScalarKeyframeCurve,
- upsertElementKeyframe,
- upsertPathKeyframe,
-} from "./keyframes";
-
-export {
- getElementLocalTime,
- resolveAnimationPathValueAtTime,
- resolveColorAtTime,
- resolveNumberAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
-} from "./resolve";
-
-export {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getDefaultInterpolationForProperty,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- supportsAnimationProperty,
- type AnimationPropertyDefinition,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export {
- getElementKeyframes,
- getKeyframeById,
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "./keyframe-query";
-
-export {
- type EditableScalarChannels,
- getEditableScalarChannel,
- getEditableScalarChannels,
- getScalarKeyframeContext,
-} from "./graph-channels";
-
-export {
- getCurveHandlesForNormalizedCubicBezier,
- getNormalizedCubicBezierForScalarSegment,
-} from "./curve-bridge";
-
-export {
- buildGraphicParamPath,
- isGraphicParamPath,
- parseGraphicParamPath,
- resolveGraphicParamsAtTime,
-} from "./graphic-param-channel";
-
-export {
- buildEffectParamPath,
- isEffectParamPath,
- parseEffectParamPath,
- removeEffectParamKeyframe,
- resolveEffectParamsAtTime,
-} from "./effect-param-channel";
-
-export {
- isAnimationPath,
- coerceAnimationValueForParam,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- type GroupKeyframeRef,
-} from "./property-groups";
-
-export {
- type EasingMode,
- getEasingModeForKind,
-} from "./binding-values";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
deleted file mode 100644
index 1a3c0fed..00000000
--- a/apps/web/src/lib/animation/interpolation.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import type {
- AnimationChannel,
- AnimationInterpolation,
- AnimationValue,
- DiscreteAnimationChannel,
- DiscreteValue,
- ScalarAnimationChannel,
- ScalarAnimationKey,
- ScalarSegmentType,
-} from "@/lib/animation/types";
-import { clamp } from "@/utils/math";
-import {
- getBezierPoint,
- getDefaultLeftHandle,
- getDefaultRightHandle,
- solveBezierProgressForTime,
-} from "./bezier";
-
-function byTimeAscending({
- leftTime,
- rightTime,
-}: {
- leftTime: number;
- rightTime: number;
-}): number {
- return leftTime - rightTime;
-}
-
-function isWithinTimePair({
- time,
- leftTime,
- rightTime,
-}: {
- time: number;
- leftTime: number;
- rightTime: number;
-}): boolean {
- return time >= leftTime && time <= rightTime;
-}
-
-function lerpNumber({
- leftValue,
- rightValue,
- progress,
-}: {
- leftValue: number;
- rightValue: number;
- progress: number;
-}): number {
- return leftValue + (rightValue - leftValue) * progress;
-}
-
-function normalizeRightHandle({
- handle,
- leftKey,
- rightKey,
-}: {
- handle: ScalarAnimationKey["rightHandle"];
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
-}) {
- if (!handle) {
- return undefined;
- }
-
- const span = Math.max(1, rightKey.time - leftKey.time);
- return {
- dt: Math.min(span, Math.max(0, handle.dt)),
- dv: handle.dv,
- };
-}
-
-function normalizeLeftHandle({
- handle,
- leftKey,
- rightKey,
-}: {
- handle: ScalarAnimationKey["leftHandle"];
- leftKey: ScalarAnimationKey;
- rightKey: ScalarAnimationKey;
-}) {
- if (!handle) {
- return undefined;
- }
-
- const span = Math.max(1, rightKey.time - leftKey.time);
- return {
- dt: Math.max(-span, Math.min(0, handle.dt)),
- dv: handle.dv,
- };
-}
-
-function normalizeScalarKey({
- key,
-}: {
- key: ScalarAnimationKey;
-}): ScalarAnimationKey {
- return {
- ...key,
- tangentMode: key.tangentMode ?? "flat",
- segmentToNext: key.segmentToNext ?? "linear",
- };
-}
-
-function normalizeScalarChannel({
- channel,
-}: {
- channel: ScalarAnimationChannel;
-}): ScalarAnimationChannel {
- const sortedKeys = [...channel.keys]
- .map((key) => normalizeScalarKey({ key }))
- .sort((leftKey, rightKey) =>
- byTimeAscending({
- leftTime: leftKey.time,
- rightTime: rightKey.time,
- }),
- );
- const nextKeys = sortedKeys.map((key, index) => {
- const previousKey = sortedKeys[index - 1];
- const nextKey = sortedKeys[index + 1];
- return {
- ...key,
- leftHandle:
- previousKey != null
- ? normalizeLeftHandle({
- handle: key.leftHandle,
- leftKey: previousKey,
- rightKey: key,
- })
- : undefined,
- rightHandle:
- nextKey != null
- ? normalizeRightHandle({
- handle: key.rightHandle,
- leftKey: key,
- rightKey: nextKey,
- })
- : undefined,
- };
- });
-
- return {
- ...channel,
- keys: nextKeys,
- };
-}
-
-export function normalizeChannel<TChannel extends AnimationChannel>({
- channel,
-}: {
- channel: TChannel;
-}): TChannel {
- if (channel.kind === "scalar") {
- return normalizeScalarChannel({
- channel,
- }) as TChannel;
- }
-
- return {
- ...channel,
- keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
- byTimeAscending({
- leftTime: leftKeyframe.time,
- rightTime: rightKeyframe.time,
- }),
- ),
- } as TChannel;
-}
-
-function extrapolateScalarEdge({
- mode,
- edgeKey,
- neighborKey,
- time,
-}: {
- mode: "hold" | "linear";
- edgeKey: ScalarAnimationKey;
- neighborKey: ScalarAnimationKey | undefined;
- time: number;
-}) {
- if (mode === "hold" || !neighborKey) {
- return edgeKey.value;
- }
-
- const span = neighborKey.time - edgeKey.time;
- if (span === 0) {
- return edgeKey.value;
- }
-
- return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
-}
-
-export function getScalarSegmentInterpolation({
- segment,
-}: {
- segment: ScalarSegmentType;
-}): AnimationInterpolation {
- if (segment === "step") {
- return "hold";
- }
-
- return segment === "bezier" ? "bezier" : "linear";
-}
-
-export function getScalarChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: ScalarAnimationChannel | undefined;
- time: number;
- fallbackValue: number;
-}): number {
- if (!channel || channel.keys.length === 0) {
- return fallbackValue;
- }
-
- const normalizedChannel = normalizeChannel({
- channel,
- });
- const firstKey = normalizedChannel.keys[0];
- const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
- if (!firstKey || !lastKey) {
- return fallbackValue;
- }
-
- if (time <= firstKey.time) {
- if (time < firstKey.time) {
- return extrapolateScalarEdge({
- mode: normalizedChannel.extrapolation?.before ?? "hold",
- edgeKey: firstKey,
- neighborKey: normalizedChannel.keys[1],
- time,
- });
- }
-
- return firstKey.value;
- }
-
- if (time >= lastKey.time) {
- if (time > lastKey.time) {
- return extrapolateScalarEdge({
- mode: normalizedChannel.extrapolation?.after ?? "hold",
- edgeKey: lastKey,
- neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
- time,
- });
- }
-
- return lastKey.value;
- }
-
- for (
- let keyIndex = 0;
- keyIndex < normalizedChannel.keys.length - 1;
- keyIndex++
- ) {
- const leftKey = normalizedChannel.keys[keyIndex];
- const rightKey = normalizedChannel.keys[keyIndex + 1];
- if (time === rightKey.time) {
- return rightKey.value;
- }
-
- if (
- !isWithinTimePair({
- time,
- leftTime: leftKey.time,
- rightTime: rightKey.time,
- })
- ) {
- continue;
- }
-
- if (leftKey.segmentToNext === "step") {
- return leftKey.value;
- }
-
- const span = rightKey.time - leftKey.time;
- if (span === 0) {
- return rightKey.value;
- }
-
- const progress = clamp({
- value: (time - leftKey.time) / span,
- min: 0,
- max: 1,
- });
- if (leftKey.segmentToNext === "linear") {
- return lerpNumber({
- leftValue: leftKey.value,
- rightValue: rightKey.value,
- progress,
- });
- }
-
- const curveProgress = solveBezierProgressForTime({
- time,
- leftKey,
- rightKey,
- });
- const rightHandle =
- leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
- const leftHandle =
- rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
- return getBezierPoint({
- progress: curveProgress,
- p0: leftKey.value,
- p1: leftKey.value + rightHandle.dv,
- p2: rightKey.value + leftHandle.dv,
- p3: rightKey.value,
- });
- }
-
- return lastKey.value;
-}
-
-export function getDiscreteChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: DiscreteAnimationChannel | undefined;
- time: number;
- fallbackValue: DiscreteValue;
-}): DiscreteValue {
- if (!channel || channel.keys.length === 0) {
- return fallbackValue;
- }
-
- const normalizedChannel = normalizeChannel({
- channel,
- });
- let currentValue = fallbackValue;
- for (const key of normalizedChannel.keys) {
- if (time < key.time) {
- break;
- }
- currentValue = key.value;
- }
- return currentValue;
-}
-
-export function getChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: AnimationChannel | undefined;
- time: number;
- fallbackValue: AnimationValue;
-}): AnimationValue {
- if (!channel || channel.keys.length === 0) {
- return fallbackValue;
- }
-
- if (channel.kind === "scalar") {
- return typeof fallbackValue === "number"
- ? getScalarChannelValueAtTime({
- channel,
- time,
- fallbackValue,
- })
- : fallbackValue;
- }
-
- if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
- return fallbackValue;
- }
-
- return getDiscreteChannelValueAtTime({
- channel,
- time,
- fallbackValue,
- });
-}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
deleted file mode 100644
index dbcd041c..00000000
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationChannel,
- AnimationPath,
- ElementAnimations,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import {
- type AnimationComponentValue,
- composeAnimationValue,
-} from "./binding-values";
-import {
- getChannelValueAtTime,
- getScalarSegmentInterpolation,
-} from "./interpolation";
-import { isAnimationPath } from "./target-resolver";
-
-function getBindingFallbackValue({
- channel,
-}: {
- channel: ElementAnimations["channels"][string];
-}) {
- if (!channel || channel.keys.length === 0) {
- return channel?.kind === "discrete" ? false : 0;
- }
-
- return channel.keys[0].value;
-}
-
-interface BindingKeyframeMatch {
- componentIndex: number;
- channel: AnimationChannel;
- keyframe: AnimationChannel["keys"][number];
-}
-
-function getBindingKeyframeMatches({
- animations,
- binding,
-}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- return binding.components.flatMap((component, componentIndex) => {
- const channel = animations.channels[component.channelId];
- if (!channel || channel.keys.length === 0) {
- return [];
- }
-
- return channel.keys.map((keyframe) => ({
- componentIndex,
- channel,
- keyframe,
- }));
- });
-}
-
-function getUniqueBindingKeyframeMatches({
- animations,
- binding,
-}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- const sortedMatches = getBindingKeyframeMatches({
- animations,
- binding,
- }).sort(
- (leftMatch, rightMatch) =>
- leftMatch.keyframe.time - rightMatch.keyframe.time ||
- leftMatch.componentIndex - rightMatch.componentIndex,
- );
- const uniqueMatches: BindingKeyframeMatch[] = [];
-
- for (const match of sortedMatches) {
- const previousMatch = uniqueMatches[uniqueMatches.length - 1];
- if (
- !previousMatch ||
- previousMatch.keyframe.time !== match.keyframe.time
- ) {
- uniqueMatches.push(match);
- continue;
- }
-
- if (
- previousMatch.componentIndex !== 0 &&
- match.componentIndex === 0
- ) {
- uniqueMatches[uniqueMatches.length - 1] = match;
- }
- }
-
- return uniqueMatches;
-}
-
-function getPreferredBindingKeyframeMatch({
- matches,
-}: {
- matches: BindingKeyframeMatch[];
-}): BindingKeyframeMatch | null {
- return (
- matches.find((match) => match.componentIndex === 0) ??
- matches[0] ??
- null
- );
-}
-
-function getComposedBindingValueAtTime({
- animations,
- binding,
- time,
-}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
- time: number;
-}) {
- const componentValues = Object.fromEntries(
- binding.components.map((component) => {
- const channel = animations.channels[component.channelId];
- return [
- component.key,
- getChannelValueAtTime({
- channel,
- time,
- fallbackValue: getBindingFallbackValue({ channel }),
- }),
- ];
- }),
- ) as Record<string, AnimationComponentValue | undefined>;
-
- return composeAnimationValue({
- binding,
- componentValues,
- });
-}
-
-function getKeyframeInterpolation({
- channel,
- keyframe,
-}: {
- channel: AnimationChannel;
- keyframe: AnimationChannel["keys"][number];
-}) {
- return channel.kind === "scalar" && "segmentToNext" in keyframe
- ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
- : "hold";
-}
-
-function toElementKeyframe({
- animations,
- binding,
- propertyPath,
- keyframeMatch,
-}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
- propertyPath: AnimationPath;
- keyframeMatch: BindingKeyframeMatch;
-}): ElementKeyframe | null {
- const value = getComposedBindingValueAtTime({
- animations,
- binding,
- time: keyframeMatch.keyframe.time,
- });
- if (value === null) {
- return null;
- }
-
- return {
- propertyPath,
- id: keyframeMatch.keyframe.id,
- time: keyframeMatch.keyframe.time,
- value,
- interpolation: getKeyframeInterpolation({
- channel: keyframeMatch.channel,
- keyframe: keyframeMatch.keyframe,
- }),
- };
-}
-
-export function getElementKeyframes({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementKeyframe[] {
- if (!animations) {
- return [];
- }
-
- return Object.entries(animations.bindings).flatMap(
- ([propertyPath, binding]) => {
- if (!binding || !isAnimationPath(propertyPath)) {
- return [];
- }
-
- return getUniqueBindingKeyframeMatches({
- animations,
- binding,
- }).flatMap((keyframeMatch) => {
- const keyframe = toElementKeyframe({
- animations,
- binding,
- propertyPath,
- keyframeMatch,
- });
- if (!keyframe) {
- return [];
- }
-
- return [keyframe];
- });
- },
- );
-}
-
-export function hasKeyframesForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): boolean {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
- return false;
- }
-
- return binding.components.some((component) =>
- Boolean(animations?.channels[component.channelId]?.keys.length),
- );
-}
-
-export function getKeyframeAtTime({
- animations,
- propertyPath,
- time,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- time: number;
-}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
- return null;
- }
-
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.time === time),
- });
- if (!keyframeMatch) {
- return null;
- }
-
- return toElementKeyframe({
- animations,
- binding,
- propertyPath,
- keyframeMatch,
- });
-}
-
-export function getKeyframeById({
- animations,
- propertyPath,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- keyframeId: string;
-}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
- return null;
- }
-
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.id === keyframeId),
- });
- if (!keyframeMatch) {
- return null;
- }
-
- return toElementKeyframe({
- animations,
- binding,
- propertyPath,
- keyframeMatch,
- });
-}
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
deleted file mode 100644
index a5efb6ee..00000000
--- a/apps/web/src/lib/animation/keyframes.ts
+++ /dev/null
@@ -1,1338 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationBindingKind,
- AnimationChannel,
- AnimationInterpolation,
- AnimationPath,
- AnimationPropertyPath,
- AnimationValue,
- DiscreteAnimationChannel,
- DiscreteAnimationKey,
- ElementAnimations,
- ScalarAnimationChannel,
- ScalarAnimationKey,
- ScalarCurveKeyframePatch,
- ScalarSegmentType,
-} from "@/lib/animation/types";
-import { generateUUID } from "@/utils/id";
-import {
- cloneAnimationBinding,
- createAnimationBinding,
- decomposeAnimationValue,
-} from "./binding-values";
-import {
- getBezierPoint,
- getDefaultLeftHandle,
- getDefaultRightHandle,
- solveBezierProgressForTime,
-} from "./bezier";
-import {
- getChannelValueAtTime,
- getScalarSegmentInterpolation,
- normalizeChannel,
-} from "./interpolation";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
-} from "./property-registry";
-
-function isNearlySameTime({
- leftTime,
- rightTime,
-}: {
- leftTime: number;
- rightTime: number;
-}): boolean {
- return leftTime === rightTime;
-}
-
-function hasChannelKeys({
- channel,
-}: {
- channel: AnimationChannel | undefined;
-}): boolean {
- return Boolean(channel && channel.keys.length > 0);
-}
-
-function toAnimation({
- animations,
-}: {
- animations: ElementAnimations;
-}): ElementAnimations | undefined {
- const nextBindings = Object.fromEntries(
- Object.entries(animations.bindings).filter(([, binding]) => binding),
- );
- const nextChannels = Object.fromEntries(
- Object.entries(animations.channels).filter(([, channel]) =>
- hasChannelKeys({ channel }),
- ),
- );
- if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
- return undefined;
- }
-
- return {
- bindings: nextBindings,
- channels: nextChannels,
- };
-}
-
-function cloneAnimationsState({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementAnimations {
- return {
- bindings: { ...(animations?.bindings ?? {}) },
- channels: { ...(animations?.channels ?? {}) },
- };
-}
-
-function getBindingChannelKind({
- kind,
-}: {
- kind: AnimationBindingKind;
-}): AnimationChannel["kind"] {
- return kind === "discrete" ? "discrete" : "scalar";
-}
-
-function getPrimaryComponent({
- binding,
-}: {
- binding: AnimationBindingInstance;
-}) {
- return binding.components[0] ?? null;
-}
-
-function getPrimaryChannelId({
- binding,
-}: {
- binding: AnimationBindingInstance;
-}) {
- return getPrimaryComponent({ binding })?.channelId ?? null;
-}
-
-function getScalarSegmentType({
- interpolation,
-}: {
- interpolation: AnimationInterpolation;
-}): ScalarSegmentType {
- if (interpolation === "hold") {
- return "step";
- }
- return interpolation === "bezier" ? "bezier" : "linear";
-}
-
-function getInterpolationForBinding({
- kind,
- interpolation,
-}: {
- kind: AnimationBindingKind;
- interpolation: AnimationInterpolation | undefined;
-}): AnimationInterpolation {
- if (kind === "discrete") {
- return "hold";
- }
-
- if (
- interpolation === "linear" ||
- interpolation === "hold" ||
- interpolation === "bezier"
- ) {
- return interpolation;
- }
-
- return "linear";
-}
-
-function createEmptyChannelForBindingKind({
- kind,
-}: {
- kind: AnimationBindingKind;
-}): AnimationChannel {
- if (kind === "discrete") {
- return {
- kind: "discrete",
- keys: [],
- } satisfies DiscreteAnimationChannel;
- }
-
- return {
- kind: "scalar",
- keys: [],
- } satisfies ScalarAnimationChannel;
-}
-
-function createScalarKey({
- id,
- time,
- value,
- interpolation,
- previousKey,
-}: {
- id: string;
- time: number;
- value: number;
- interpolation?: AnimationInterpolation;
- previousKey?: ScalarAnimationKey;
-}): ScalarAnimationKey {
- return {
- id,
- time,
- value,
- leftHandle: previousKey?.leftHandle,
- rightHandle: previousKey?.rightHandle,
- segmentToNext:
- previousKey?.segmentToNext ??
- getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
- tangentMode: previousKey?.tangentMode ?? "flat",
- };
-}
-
-function createDiscreteKey({
- id,
- time,
- value,
-}: {
- id: string;
- time: number;
- value: string | boolean;
-}): DiscreteAnimationKey {
- return {
- id,
- time,
- value,
- };
-}
-
-function getBinding({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): AnimationBindingInstance | undefined {
- return animations?.bindings[propertyPath];
-}
-
-function getChannelById({
- animations,
- channelId,
-}: {
- animations: ElementAnimations | undefined;
- channelId: string;
-}): AnimationChannel | undefined {
- return animations?.channels[channelId];
-}
-
-function getBindingComponent({
- binding,
- componentKey,
-}: {
- binding: AnimationBindingInstance;
- componentKey: string;
-}) {
- return binding.components.find((component) => component.key === componentKey) ?? null;
-}
-
-function getTargetKeyMetadata({
- channel,
- time,
- keyframeId,
-}: {
- channel: AnimationChannel | undefined;
- time: number;
- keyframeId?: string;
-}) {
- const normalizedChannel =
- channel != null ? normalizeChannel({ channel }) : undefined;
- const keys = normalizedChannel?.keys ?? [];
- if (keyframeId) {
- const keyById = keys.find((key) => key.id === keyframeId);
- if (keyById) {
- return {
- id: keyById.id,
- time,
- };
- }
- }
-
- const keyAtTime = keys.find((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: time }),
- );
- if (keyAtTime) {
- return {
- id: keyAtTime.id,
- time: keyAtTime.time,
- };
- }
-
- return {
- id: keyframeId ?? generateUUID(),
- time,
- };
-}
-
-function upsertDiscreteChannelKey({
- channel,
- time,
- value,
- keyframeId,
-}: {
- channel: DiscreteAnimationChannel | undefined;
- time: number;
- value: string | boolean;
- keyframeId?: string;
-}): DiscreteAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "discrete", keys: [] },
- });
- const keys = [...normalizedChannel.keys];
- if (keyframeId) {
- const existingIndex = keys.findIndex((key) => key.id === keyframeId);
- if (existingIndex >= 0) {
- keys[existingIndex] = createDiscreteKey({
- id: keys[existingIndex].id,
- time,
- value,
- });
- return normalizeChannel({
- channel: { kind: "discrete", keys },
- });
- }
- }
-
- const existingAtTimeIndex = keys.findIndex((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: time }),
- );
- if (existingAtTimeIndex >= 0) {
- keys[existingAtTimeIndex] = createDiscreteKey({
- id: keys[existingAtTimeIndex].id,
- time: keys[existingAtTimeIndex].time,
- value,
- });
- return normalizeChannel({
- channel: { kind: "discrete", keys },
- });
- }
-
- keys.push(
- createDiscreteKey({
- id: keyframeId ?? generateUUID(),
- time,
- value,
- }),
- );
- return normalizeChannel({
- channel: { kind: "discrete", keys },
- });
-}
-
-function upsertScalarChannelKey({
- channel,
- time,
- value,
- interpolation,
- defaultInterpolation,
- keyframeId,
-}: {
- channel: ScalarAnimationChannel | undefined;
- time: number;
- value: number;
- interpolation?: AnimationInterpolation;
- defaultInterpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ScalarAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "scalar", keys: [] },
- });
- const keys = [...normalizedChannel.keys];
- if (keyframeId) {
- const existingIndex = keys.findIndex((key) => key.id === keyframeId);
- if (existingIndex >= 0) {
- keys[existingIndex] = createScalarKey({
- id: keys[existingIndex].id,
- time,
- value,
- interpolation,
- previousKey:
- interpolation != null
- ? {
- ...keys[existingIndex],
- segmentToNext: getScalarSegmentType({ interpolation }),
- }
- : keys[existingIndex],
- });
- return normalizeChannel({
- channel: {
- kind: "scalar",
- keys,
- extrapolation: normalizedChannel.extrapolation,
- },
- });
- }
- }
-
- const existingAtTimeIndex = keys.findIndex((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: time }),
- );
- if (existingAtTimeIndex >= 0) {
- keys[existingAtTimeIndex] = createScalarKey({
- id: keys[existingAtTimeIndex].id,
- time: keys[existingAtTimeIndex].time,
- value,
- interpolation,
- previousKey:
- interpolation != null
- ? {
- ...keys[existingAtTimeIndex],
- segmentToNext: getScalarSegmentType({ interpolation }),
- }
- : keys[existingAtTimeIndex],
- });
- return normalizeChannel({
- channel: {
- kind: "scalar",
- keys,
- extrapolation: normalizedChannel.extrapolation,
- },
- });
- }
-
- keys.push(
- createScalarKey({
- id: keyframeId ?? generateUUID(),
- time,
- value,
- interpolation: interpolation ?? defaultInterpolation,
- }),
- );
- return normalizeChannel({
- channel: {
- kind: "scalar",
- keys,
- extrapolation: normalizedChannel.extrapolation,
- },
- });
-}
-
-export function getChannel({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): AnimationChannel | undefined {
- const binding = getBinding({ animations, propertyPath });
- const primaryChannelId =
- binding != null ? getPrimaryChannelId({ binding }) : null;
- return primaryChannelId ? animations?.channels[primaryChannelId] : undefined;
-}
-
-export function upsertPathKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
- kind,
- defaultInterpolation,
- coerceValue,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
-}): ElementAnimations | undefined {
- const coercedValue = coerceValue({ value });
- if (coercedValue === null) {
- return animations;
- }
-
- const nextAnimations = cloneAnimationsState({ animations });
- const existingBinding = getBinding({
- animations,
- propertyPath,
- });
- const binding =
- existingBinding && existingBinding.kind === kind
- ? cloneAnimationBinding({ binding: existingBinding })
- : createAnimationBinding({ path: propertyPath, kind });
- const primaryChannel = getChannel({
- animations,
- propertyPath,
- });
- const targetKey = getTargetKeyMetadata({
- channel: primaryChannel,
- time,
- keyframeId,
- });
- const componentValues = decomposeAnimationValue({
- kind,
- value: coercedValue,
- });
- if (!componentValues) {
- return animations;
- }
-
- const explicitInterpolation =
- interpolation != null
- ? getInterpolationForBinding({ kind, interpolation })
- : undefined;
- const validatedDefaultInterpolation = getInterpolationForBinding({
- kind,
- interpolation: defaultInterpolation,
- });
- nextAnimations.bindings[propertyPath] = binding;
- for (const component of binding.components) {
- const nextValue = componentValues[component.key];
- if (nextValue == null) {
- continue;
- }
-
- const currentChannel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- const targetChannel =
- currentChannel?.kind === getBindingChannelKind({ kind })
- ? currentChannel
- : createEmptyChannelForBindingKind({ kind });
- nextAnimations.channels[component.channelId] =
- targetChannel.kind === "discrete"
- ? upsertDiscreteChannelKey({
- channel: targetChannel,
- time: targetKey.time,
- value: nextValue as string | boolean,
- keyframeId: targetKey.id,
- })
- : upsertScalarChannelKey({
- channel: targetChannel,
- time: targetKey.time,
- value: nextValue as number,
- interpolation: explicitInterpolation,
- defaultInterpolation: validatedDefaultInterpolation,
- keyframeId: targetKey.id,
- });
- }
-
- return toAnimation({
- animations: nextAnimations,
- });
-}
-
-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,
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- coerceValue: ({ value: nextValue }) =>
- coerceAnimationValueForProperty({
- propertyPath,
- value: nextValue,
- }),
- });
-}
-
-export function upsertKeyframe({
- channel,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- channel: AnimationChannel | undefined;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): AnimationChannel | undefined {
- if (!channel) {
- return undefined;
- }
-
- if (channel.kind === "discrete") {
- if (typeof value !== "string" && typeof value !== "boolean") {
- return channel;
- }
-
- return upsertDiscreteChannelKey({
- channel,
- time,
- value,
- keyframeId,
- });
- }
-
- if (typeof value !== "number") {
- return channel;
- }
-
- return upsertScalarChannelKey({
- channel,
- time,
- value,
- interpolation,
- keyframeId,
- });
-}
-
-export function removeKeyframe({
- channel,
- keyframeId,
-}: {
- channel: AnimationChannel | undefined;
- keyframeId: string;
-}): AnimationChannel | undefined {
- if (!channel) {
- return undefined;
- }
-
- const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
- if (nextKeys.length === 0) {
- return undefined;
- }
-
- return normalizeChannel({
- channel: {
- ...channel,
- keys: nextKeys,
- } as AnimationChannel,
- });
-}
-
-export function retimeKeyframe({
- channel,
- keyframeId,
- time,
-}: {
- channel: AnimationChannel | undefined;
- keyframeId: string;
- time: number;
-}): AnimationChannel | undefined {
- if (!channel) {
- return undefined;
- }
-
- const keyframeByIdIndex = channel.keys.findIndex(
- (keyframe) => keyframe.id === keyframeId,
- );
- if (keyframeByIdIndex < 0) {
- return channel;
- }
-
- const nextKeys = [...channel.keys];
- nextKeys[keyframeByIdIndex] = {
- ...nextKeys[keyframeByIdIndex],
- time,
- };
-
- return normalizeChannel({
- channel: {
- ...channel,
- keys: nextKeys,
- } as AnimationChannel,
- });
-}
-
-export function setChannel({
- animations,
- propertyPath,
- channel,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- channel: AnimationChannel | undefined;
-}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- if (binding.components.length !== 1) {
- throw new Error(
- `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
- );
- }
-
- const primaryComponent = getPrimaryComponent({ binding });
- if (!primaryComponent) {
- return animations;
- }
-
- return setBindingComponentChannel({
- animations,
- propertyPath,
- componentKey: primaryComponent.key,
- channel,
- });
-}
-
-export function setBindingComponentChannel({
- animations,
- propertyPath,
- componentKey,
- channel,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- componentKey: string;
- channel: AnimationChannel | undefined;
-}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const component = getBindingComponent({
- binding,
- componentKey,
- });
- if (!component) {
- return animations;
- }
-
- const nextAnimations = cloneAnimationsState({ animations });
- if (!channel || !hasChannelKeys({ channel })) {
- delete nextAnimations.channels[component.channelId];
- const hasRemainingKeys = binding.components.some((candidate) =>
- hasChannelKeys({
- channel: nextAnimations.channels[candidate.channelId],
- }),
- );
- if (!hasRemainingKeys) {
- delete nextAnimations.bindings[propertyPath];
- }
- return toAnimation({
- animations: nextAnimations,
- });
- }
-
- nextAnimations.channels[component.channelId] = normalizeChannel({
- channel,
- });
- return toAnimation({
- animations: nextAnimations,
- });
-}
-
-export function updateScalarKeyframeCurve({
- animations,
- propertyPath,
- componentKey,
- keyframeId,
- patch,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- componentKey: string;
- keyframeId: string;
- patch: ScalarCurveKeyframePatch;
-}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const component = getBindingComponent({
- binding,
- componentKey,
- });
- if (!component) {
- return animations;
- }
-
- const channel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- if (channel?.kind !== "scalar") {
- return animations;
- }
-
- const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
- if (keyframeIndex < 0) {
- return animations;
- }
-
- const nextKeys = [...channel.keys];
- const currentKey = nextKeys[keyframeIndex];
- nextKeys[keyframeIndex] = {
- ...currentKey,
- leftHandle:
- patch.leftHandle === undefined
- ? currentKey.leftHandle
- : patch.leftHandle ?? undefined,
- rightHandle:
- patch.rightHandle === undefined
- ? currentKey.rightHandle
- : patch.rightHandle ?? undefined,
- segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
- tangentMode: patch.tangentMode ?? currentKey.tangentMode,
- };
-
- return setBindingComponentChannel({
- animations,
- propertyPath,
- componentKey,
- channel: {
- kind: "scalar",
- keys: nextKeys,
- extrapolation: channel.extrapolation,
- },
- });
-}
-
-export function cloneAnimations({
- animations,
- shouldRegenerateKeyframeIds = false,
-}: {
- animations: ElementAnimations | undefined;
- shouldRegenerateKeyframeIds?: boolean;
-}): ElementAnimations | undefined {
- if (!animations) {
- return undefined;
- }
-
- const nextAnimations = cloneAnimationsState({ animations });
- nextAnimations.bindings = Object.fromEntries(
- Object.entries(animations.bindings).map(([path, binding]) => [
- path,
- binding ? cloneAnimationBinding({ binding }) : binding,
- ]),
- );
- nextAnimations.channels = {};
-
- for (const binding of Object.values(nextAnimations.bindings)) {
- if (!binding) {
- continue;
- }
-
- const primaryChannel = getChannelById({
- animations,
- channelId: getPrimaryChannelId({ binding }) ?? "",
- });
- const keyIdMap = new Map<string, string>();
- if (primaryChannel) {
- for (const key of primaryChannel.keys) {
- keyIdMap.set(
- key.id,
- shouldRegenerateKeyframeIds ? generateUUID() : key.id,
- );
- }
- }
-
- for (const component of binding.components) {
- const currentChannel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- if (!currentChannel) {
- continue;
- }
-
- nextAnimations.channels[component.channelId] = normalizeChannel({
- channel: {
- ...currentChannel,
- keys: currentChannel.keys.map((key) => ({
- ...key,
- id: keyIdMap.get(key.id) ?? key.id,
- })),
- } as AnimationChannel,
- });
- }
- }
-
- return toAnimation({
- animations: nextAnimations,
- });
-}
-
-export function clampAnimationsToDuration({
- animations,
- duration,
-}: {
- animations: ElementAnimations | undefined;
- duration: number;
-}): ElementAnimations | undefined {
- if (!animations || duration <= 0) {
- return undefined;
- }
-
- return splitAnimationsAtTime({
- animations,
- splitTime: duration,
- shouldIncludeSplitBoundary: true,
- }).leftAnimations;
-}
-
-function lerpPoint({
- left,
- right,
- progress,
-}: {
- left: { x: number; y: number };
- right: { x: number; y: number };
- progress: number;
-}) {
- return {
- x: left.x + (right.x - left.x) * progress,
- y: left.y + (right.y - left.y) * progress,
- };
-}
-
-function splitDiscreteChannelAtTime({
- channel,
- splitTime,
- leftBoundaryId,
- rightBoundaryId,
- shouldIncludeSplitBoundary,
-}: {
- channel: DiscreteAnimationChannel | undefined;
- splitTime: number;
- leftBoundaryId: string;
- rightBoundaryId: string;
- shouldIncludeSplitBoundary: boolean;
-}) {
- if (!channel || channel.keys.length === 0) {
- return {
- leftChannel: undefined,
- rightChannel: undefined,
- };
- }
-
- const normalizedChannel = normalizeChannel({ channel });
- let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
- let rightKeys = normalizedChannel.keys
- .filter((key) => key.time >= splitTime)
- .map((key) => ({ ...key, time: key.time - splitTime }));
-
- if (shouldIncludeSplitBoundary) {
- const hasBoundaryOnLeft = leftKeys.some((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
- );
- const hasBoundaryOnRight = rightKeys.some((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
- );
- const boundaryValue = getChannelValueAtTime({
- channel: normalizedChannel,
- time: splitTime,
- fallbackValue: normalizedChannel.keys[0].value,
- });
- if (!hasBoundaryOnLeft) {
- leftKeys = [
- ...leftKeys,
- createDiscreteKey({
- id: leftBoundaryId,
- time: splitTime,
- value: boundaryValue as string | boolean,
- }),
- ];
- }
- if (!hasBoundaryOnRight) {
- rightKeys = [
- createDiscreteKey({
- id: rightBoundaryId,
- time: 0,
- value: boundaryValue as string | boolean,
- }),
- ...rightKeys,
- ];
- }
- }
-
- return {
- leftChannel: leftKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
- : undefined,
- rightChannel: rightKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } })
- : undefined,
- };
-}
-
-function splitScalarChannelAtTime({
- channel,
- splitTime,
- leftBoundaryId,
- rightBoundaryId,
- shouldIncludeSplitBoundary,
-}: {
- channel: ScalarAnimationChannel | undefined;
- splitTime: number;
- leftBoundaryId: string;
- rightBoundaryId: string;
- shouldIncludeSplitBoundary: boolean;
-}) {
- if (!channel || channel.keys.length === 0) {
- return {
- leftChannel: undefined,
- rightChannel: undefined,
- };
- }
-
- const normalizedChannel = normalizeChannel({ channel });
- let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
- let rightKeys = normalizedChannel.keys
- .filter((key) => key.time >= splitTime)
- .map((key) => ({ ...key, time: key.time - splitTime }));
-
- const hasBoundaryOnLeft = leftKeys.some((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
- );
- const hasBoundaryOnRight = rightKeys.some((key) =>
- isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
- );
- if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) {
- return {
- leftChannel: leftKeys.length
- ? normalizeChannel({
- channel: {
- kind: "scalar",
- keys: leftKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- })
- : undefined,
- rightChannel: rightKeys.length
- ? normalizeChannel({
- channel: {
- kind: "scalar",
- keys: rightKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- })
- : undefined,
- };
- }
-
- for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) {
- const leftKey = normalizedChannel.keys[keyIndex];
- const rightKey = normalizedChannel.keys[keyIndex + 1];
- if (
- !(
- splitTime > leftKey.time &&
- splitTime < rightKey.time
- )
- ) {
- continue;
- }
-
- const boundaryValue = getChannelValueAtTime({
- channel: normalizedChannel,
- time: splitTime,
- fallbackValue: leftKey.value,
- }) as number;
-
- if (leftKey.segmentToNext === "bezier") {
- const rightHandle =
- leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
- const leftHandle =
- rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
- const progress = solveBezierProgressForTime({
- time: splitTime,
- leftKey,
- rightKey,
- });
- const p0 = { x: leftKey.time, y: leftKey.value };
- const p1 = {
- x: leftKey.time + rightHandle.dt,
- y: leftKey.value + rightHandle.dv,
- };
- const p2 = {
- x: rightKey.time + leftHandle.dt,
- y: rightKey.value + leftHandle.dv,
- };
- const p3 = { x: rightKey.time, y: rightKey.value };
- const q0 = lerpPoint({ left: p0, right: p1, progress });
- const q1 = lerpPoint({ left: p1, right: p2, progress });
- const q2 = lerpPoint({ left: p2, right: p3, progress });
- const r0 = lerpPoint({ left: q0, right: q1, progress });
- const r1 = lerpPoint({ left: q1, right: q2, progress });
- const splitPoint = lerpPoint({ left: r0, right: r1, progress });
- leftKeys = [
- ...normalizedChannel.keys.filter((key) => key.time < splitTime),
- {
- ...leftKey,
- rightHandle: {
- dt: q0.x - p0.x,
- dv: q0.y - p0.y,
- },
- },
- {
- id: leftBoundaryId,
- time: splitTime,
- value: boundaryValue,
- leftHandle: {
- dt: r0.x - splitPoint.x,
- dv: r0.y - splitPoint.y,
- },
- segmentToNext: leftKey.segmentToNext,
- tangentMode: leftKey.tangentMode,
- },
- ];
- rightKeys = [
- {
- id: rightBoundaryId,
- time: 0,
- value: boundaryValue,
- rightHandle: {
- dt: r1.x - splitPoint.x,
- dv: r1.y - splitPoint.y,
- },
- segmentToNext: "bezier",
- tangentMode: leftKey.tangentMode,
- },
- {
- ...rightKey,
- time: rightKey.time - splitTime,
- leftHandle: {
- dt: q2.x - p3.x,
- dv: q2.y - p3.y,
- },
- },
- ...normalizedChannel.keys
- .filter((key) => key.time > rightKey.time)
- .map((key) => ({
- ...key,
- time: key.time - splitTime,
- })),
- ];
- } else {
- leftKeys = [
- ...leftKeys,
- createScalarKey({
- id: leftBoundaryId,
- time: splitTime,
- value: boundaryValue,
- interpolation: "linear",
- }),
- ];
- rightKeys = [
- createScalarKey({
- id: rightBoundaryId,
- time: 0,
- value: boundaryValue,
- interpolation: getScalarSegmentInterpolation({
- segment: leftKey.segmentToNext,
- }),
- }),
- ...rightKeys,
- ];
- }
-
- return {
- leftChannel: normalizeChannel({
- channel: {
- kind: "scalar",
- keys: leftKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- }),
- rightChannel: normalizeChannel({
- channel: {
- kind: "scalar",
- keys: rightKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- }),
- };
- }
-
- return {
- leftChannel: leftKeys.length
- ? normalizeChannel({
- channel: {
- kind: "scalar",
- keys: leftKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- })
- : undefined,
- rightChannel: rightKeys.length
- ? normalizeChannel({
- channel: {
- kind: "scalar",
- keys: rightKeys,
- extrapolation: normalizedChannel.extrapolation,
- },
- })
- : undefined,
- };
-}
-
-export function splitAnimationsAtTime({
- animations,
- splitTime,
- shouldIncludeSplitBoundary = true,
-}: {
- animations: ElementAnimations | undefined;
- splitTime: number;
- shouldIncludeSplitBoundary?: boolean;
-}): {
- leftAnimations: ElementAnimations | undefined;
- rightAnimations: ElementAnimations | undefined;
-} {
- if (!animations) {
- return { leftAnimations: undefined, rightAnimations: undefined };
- }
-
- const leftAnimations = cloneAnimationsState({ animations: undefined });
- const rightAnimations = cloneAnimationsState({ animations: undefined });
-
- for (const [propertyPath, binding] of Object.entries(animations.bindings)) {
- if (!binding) {
- continue;
- }
-
- const leftBinding = cloneAnimationBinding({ binding });
- const rightBinding = cloneAnimationBinding({ binding });
- const leftBoundaryId = generateUUID();
- const rightBoundaryId = generateUUID();
- let hasLeftKeys = false;
- let hasRightKeys = false;
-
- for (const component of binding.components) {
- const channel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- const splitResult =
- channel?.kind === "discrete"
- ? splitDiscreteChannelAtTime({
- channel,
- splitTime,
- leftBoundaryId,
- rightBoundaryId,
- shouldIncludeSplitBoundary,
- })
- : splitScalarChannelAtTime({
- channel: channel as ScalarAnimationChannel | undefined,
- splitTime,
- leftBoundaryId,
- rightBoundaryId,
- shouldIncludeSplitBoundary,
- });
- if (splitResult.leftChannel) {
- leftAnimations.channels[component.channelId] = splitResult.leftChannel;
- hasLeftKeys = true;
- }
- if (splitResult.rightChannel) {
- rightAnimations.channels[component.channelId] = splitResult.rightChannel;
- hasRightKeys = true;
- }
- }
-
- if (hasLeftKeys) {
- leftAnimations.bindings[propertyPath] = leftBinding;
- }
- if (hasRightKeys) {
- rightAnimations.bindings[propertyPath] = rightBinding;
- }
- }
-
- return {
- leftAnimations: toAnimation({ animations: leftAnimations }),
- rightAnimations: toAnimation({ animations: rightAnimations }),
- };
-}
-
-export function removeElementKeyframe({
- animations,
- propertyPath,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- keyframeId: string;
-}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const nextAnimations = cloneAnimationsState({ animations });
- for (const component of binding.components) {
- nextAnimations.channels[component.channelId] = removeKeyframe({
- channel: nextAnimations.channels[component.channelId],
- keyframeId,
- });
- }
- const hasRemainingKeys = binding.components.some((component) =>
- hasChannelKeys({
- channel: nextAnimations.channels[component.channelId],
- }),
- );
- if (!hasRemainingKeys) {
- delete nextAnimations.bindings[propertyPath];
- for (const component of binding.components) {
- delete nextAnimations.channels[component.channelId];
- }
- }
- return toAnimation({
- animations: nextAnimations,
- });
-}
-
-export function retimeElementKeyframe({
- animations,
- propertyPath,
- keyframeId,
- time,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- keyframeId: string;
- time: number;
-}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const nextAnimations = cloneAnimationsState({ animations });
- for (const component of binding.components) {
- nextAnimations.channels[component.channelId] = retimeKeyframe({
- channel: nextAnimations.channels[component.channelId],
- keyframeId,
- time,
- });
- }
- return toAnimation({
- animations: nextAnimations,
- });
-}
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
deleted file mode 100644
index 1e475dac..00000000
--- a/apps/web/src/lib/animation/property-groups.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type {
- AnimationPropertyGroup,
- AnimationPropertyPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
-import { getKeyframeAtTime } from "./keyframe-query";
-
-export interface GroupKeyframeRef {
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
-}
-
-export function getGroupKeyframesAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): GroupKeyframeRef[] {
- return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
- const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
- return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
- });
-}
-
-export function hasGroupKeyframeAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): boolean {
- return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
-}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
deleted file mode 100644
index 56340fd8..00000000
--- a/apps/web/src/lib/animation/property-registry.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
-} from "@/lib/animation/types";
-import { parseColorToLinearRgba } from "./binding-values";
-import type { TimelineElement } from "@/lib/timeline";
-import { MIN_TRANSFORM_SCALE } from "@/lib/animation/transform";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/lib/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-export interface NumericSpec {
- min?: number;
- max?: number;
- step?: number;
-}
-
-export interface AnimationPropertyDefinition {
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- numericRanges?: Partial<Record<string, NumericSpec>>;
- supportsElement: ({ element }: { element: TimelineElement }) => boolean;
- getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- setValue: ({
- element,
- value,
- }: {
- element: TimelineElement;
- value: AnimationValue;
- }) => TimelineElement;
-}
-
-function applyNumericSpec({
- value,
- numericRange,
-}: {
- value: number;
- numericRange: NumericSpec | undefined;
-}): number {
- if (!numericRange) {
- return value;
- }
-
- const steppedValue =
- numericRange.step != null
- ? snapToStep({ value, step: numericRange.step })
- : value;
- const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
- const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
-}
-
-function coerceNumberValue({
- value,
- numericRange,
-}: {
- value: AnimationValue;
- numericRange?: NumericSpec;
-}): number | null {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- return applyNumericSpec({ value, numericRange });
-}
-
-function coerceColorValue({
- value,
-}: {
- value: AnimationValue;
-}): string | null {
- return typeof value === "string" && parseColorToLinearRgba({ color: value })
- ? value
- : null;
-}
-
-function createNumberPropertyDefinition({
- numericRange,
- supportsElement,
- getValue,
- setValue,
-}: {
- numericRange?: NumericSpec;
- supportsElement: AnimationPropertyDefinition["supportsElement"];
- getValue: AnimationPropertyDefinition["getValue"];
- setValue: AnimationPropertyDefinition["setValue"];
-}): AnimationPropertyDefinition {
- return {
- kind: "number",
- defaultInterpolation: "linear",
- numericRanges: numericRange ? { value: numericRange } : undefined,
- supportsElement,
- getValue,
- coerceValue: ({ value }) =>
- coerceNumberValue({
- value,
- numericRange,
- }),
- setValue,
- };
-}
-
-const ANIMATION_PROPERTY_REGISTRY: Record<
- AnimationPropertyPath,
- AnimationPropertyDefinition
-> = {
- "transform.positionX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.x : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, x: value as number },
- },
- }
- : element,
- }),
- "transform.positionY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.y : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value as number },
- },
- }
- : element,
- }),
- "transform.scaleX": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleX : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleX: value as number },
- }
- : element,
- }),
- "transform.scaleY": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleY : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleY: value as number },
- }
- : element,
- }),
- "transform.rotate": createNumberPropertyDefinition({
- numericRange: { min: -360, max: 360, step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.rotate : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, rotate: value as number },
- }
- : element,
- }),
- opacity: createNumberPropertyDefinition({
- numericRange: { min: 0, max: 1, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.opacity : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? { ...element, opacity: value as number }
- : element,
- }),
- volume: createNumberPropertyDefinition({
- numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
- supportsElement: ({ element }) => canElementHaveAudio(element),
- getValue: ({ element }) =>
- canElementHaveAudio(element) ? element.volume ?? 0 : null,
- setValue: ({ element, value }) =>
- canElementHaveAudio(element)
- ? { ...element, volume: value as number }
- : element,
- }),
- color: {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) => (element.type === "text" ? element.color : null),
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? { ...element, color: value as string }
- : element,
- },
- "background.color": {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text" ? element.background.color : null,
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, color: value as string },
- }
- : element,
- },
- "background.paddingX": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingX: value as number },
- }
- : element,
- }),
- "background.paddingY": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingY: value as number },
- }
- : element,
- }),
- "background.offsetX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetX: value as number },
- }
- : element,
- }),
- "background.offsetY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetY: value as number },
- }
- : element,
- }),
- "background.cornerRadius": createNumberPropertyDefinition({
- numericRange: {
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- step: 1,
- },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, cornerRadius: value as number },
- }
- : element,
- }),
-};
-
-export function isAnimationPropertyPath(
- propertyPath: string,
-): propertyPath is AnimationPropertyPath {
- return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
-}
-
-export function getAnimationPropertyDefinition({
- propertyPath,
-}: {
- propertyPath: AnimationPropertyPath;
-}): AnimationPropertyDefinition {
- return ANIMATION_PROPERTY_REGISTRY[propertyPath];
-}
-
-export function supportsAnimationProperty({
- element,
- propertyPath,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
-}): boolean {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.supportsElement({ element });
-}
-
-export function getElementBaseValueForProperty({
- element,
- propertyPath,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
-}): AnimationValue | null {
- const definition = getAnimationPropertyDefinition({ propertyPath });
- if (!definition.supportsElement({ element })) {
- return null;
- }
- return definition.getValue({ element });
-}
-
-export function withElementBaseValueForProperty({
- element,
- propertyPath,
- value,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPropertyPath;
- value: AnimationValue;
-}): TimelineElement {
- const definition = getAnimationPropertyDefinition({ propertyPath });
- const coercedValue = definition.coerceValue({ value });
- if (coercedValue === null || !definition.supportsElement({ element })) {
- return element;
- }
- return definition.setValue({ element, value: coercedValue });
-}
-
-export function getDefaultInterpolationForProperty({
- propertyPath,
-}: {
- propertyPath: AnimationPropertyPath;
-}): AnimationInterpolation {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.defaultInterpolation;
-}
-
-export function coerceAnimationValueForProperty({
- propertyPath,
- value,
-}: {
- propertyPath: AnimationPropertyPath;
- value: AnimationValue;
-}): AnimationValue | null {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.coerceValue({ value });
-}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
deleted file mode 100644
index 42c1c73c..00000000
--- a/apps/web/src/lib/animation/resolve.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import type {
- AnimationColorPropertyPath,
- AnimationNumericPropertyPath,
- AnimationPath,
- AnimationPropertyPath,
- AnimationValueForPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { Transform } from "@/lib/rendering";
-import {
- type AnimationComponentValue,
- composeAnimationValue,
- decomposeAnimationValue,
-} from "./binding-values";
-import {
- getChannelValueAtTime,
-} from "./interpolation";
-
-export function getElementLocalTime({
- timelineTime,
- elementStartTime,
- elementDuration,
-}: {
- timelineTime: number;
- elementStartTime: number;
- elementDuration: number;
-}): number {
- const localTime = timelineTime - elementStartTime;
- if (localTime <= 0) {
- return 0;
- }
-
- if (localTime >= elementDuration) {
- return elementDuration;
- }
-
- return localTime;
-}
-
-export function resolveTransformAtTime({
- baseTransform,
- animations,
- localTime,
-}: {
- baseTransform: Transform;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): Transform {
- const safeLocalTime = Math.max(0, localTime);
- return {
- position: {
- x: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.x,
- }),
- y: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.y,
- }),
- },
- scaleX: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleX,
- }),
- scaleY: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleY,
- }),
- rotate: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.rotate",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.rotate,
- }),
- };
-}
-
-export function resolveOpacityAtTime({
- baseOpacity,
- animations,
- localTime,
-}: {
- baseOpacity: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "opacity",
- localTime: Math.max(0, localTime),
- fallbackValue: baseOpacity,
- });
-}
-
-export function resolveNumberAtTime({
- baseValue,
- animations,
- propertyPath,
- localTime,
-}: {
- baseValue: number;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationNumericPropertyPath;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseValue,
- });
-}
-
-export function resolveColorAtTime({
- baseColor,
- animations,
- propertyPath,
- localTime,
-}: {
- baseColor: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationColorPropertyPath;
- localTime: number;
-}): string {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseColor,
- });
-}
-
-export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
- animations,
- propertyPath,
- localTime,
- fallbackValue,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: TPath;
- localTime: number;
- fallbackValue: AnimationValueForPath<TPath>;
-}): AnimationValueForPath<TPath> {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
- return fallbackValue;
- }
-
- const fallbackComponents = decomposeAnimationValue({
- kind: binding.kind,
- value: fallbackValue,
- });
- if (!fallbackComponents) {
- return fallbackValue;
- }
-
- const componentValues = Object.fromEntries(
- binding.components.map((component) => {
- const channel = animations?.channels[component.channelId];
- return [
- component.key,
- getChannelValueAtTime({
- channel,
- time: localTime,
- fallbackValue:
- fallbackComponents[component.key] ??
- (channel?.kind === "discrete" ? false : 0),
- }),
- ];
- }),
- ) as Record<string, AnimationComponentValue | undefined>;
- return (composeAnimationValue({
- binding,
- componentValues,
- }) ?? fallbackValue) as AnimationValueForPath<TPath>;
-}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
deleted file mode 100644
index 64bb8709..00000000
--- a/apps/web/src/lib/animation/target-resolver.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
-} 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";
-import { parseColorToLinearRgba } from "./binding-values";
-
-export interface AnimationPathDescriptor {
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- numericRanges?: Partial<Record<string, NumericSpec>>;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- getBaseValue: () => AnimationValue | null;
- setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
-}
-
-export function getParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationBindingKind {
- 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;
-}): Partial<Record<string, NumericSpec>> | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- value: {
- min: param.min,
- max: param.max,
- step: param.step,
- },
- };
-}
-
-export function coerceAnimationValueForParam({
- 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 {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
- getBaseValue: () => element.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ 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 {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- effects:
- element.effects?.map((candidate) =>
- candidate.id !== effectId
- ? candidate
- : {
- ...candidate,
- params: {
- ...candidate.params,
- [param.key]: coercedValue,
- },
- },
- ) ?? element.effects,
- };
- },
- };
-}
-
-export function isAnimationPath(
- propertyPath: string,
-): propertyPath is AnimationPath {
- return (
- isAnimationPropertyPath(propertyPath) ||
- isGraphicParamPath(propertyPath) ||
- isEffectParamPath(propertyPath)
- );
-}
-
-export function resolveAnimationTarget({
- element,
- path,
-}: {
- element: TimelineElement;
- path: AnimationPath;
-}): AnimationPathDescriptor | null {
- if (isAnimationPropertyPath(path)) {
- const propertyDefinition = getAnimationPropertyDefinition({
- propertyPath: path,
- });
- if (!propertyDefinition.supportsElement({ element })) {
- return null;
- }
-
- return {
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- numericRanges: propertyDefinition.numericRanges,
- coerceValue: ({ value }) =>
- coerceAnimationValueForProperty({
- propertyPath: path,
- value,
- }),
- getBaseValue: () =>
- getElementBaseValueForProperty({
- element,
- propertyPath: path,
- }),
- setBaseValue: ({ value }) => {
- const coercedValue = propertyDefinition.coerceValue({ 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/transform.ts b/apps/web/src/lib/animation/transform.ts
deleted file mode 100644
index ab932ea2..00000000
--- a/apps/web/src/lib/animation/transform.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const MIN_TRANSFORM_SCALE = 0.01;
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
deleted file mode 100644
index 115b4dbf..00000000
--- a/apps/web/src/lib/animation/types.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import type { ParamValues } from "@/lib/params";
-
-export const ANIMATION_PROPERTY_PATHS = [
- "transform.positionX",
- "transform.positionY",
- "transform.scaleX",
- "transform.scaleY",
- "transform.rotate",
- "opacity",
- "volume",
- "color",
- "background.color",
- "background.paddingX",
- "background.paddingY",
- "background.offsetX",
- "background.offsetY",
- "background.cornerRadius",
-] as const;
-
-export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
-export type GraphicParamPath = `params.${string}`;
-export type EffectParamPath = `effects.${string}.params.${string}`;
-export type AnimationPath =
- | AnimationPropertyPath
- | GraphicParamPath
- | EffectParamPath;
-
-export const ANIMATION_PROPERTY_GROUPS = {
- "transform.scale": ["transform.scaleX", "transform.scaleY"],
-} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
-
-export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
-
-export type VectorValue = { x: number; y: number };
-export type DiscreteValue = boolean | string;
-export type AnimationValue = number | string | boolean | VectorValue;
-export interface AnimationPropertyValueMap {
- "transform.positionX": number;
- "transform.positionY": number;
- "transform.scaleX": number;
- "transform.scaleY": number;
- "transform.rotate": number;
- opacity: number;
- volume: number;
- color: string;
- "background.color": string;
- "background.paddingX": number;
- "background.paddingY": number;
- "background.offsetX": number;
- "background.offsetY": number;
- "background.cornerRadius": number;
-}
-export type DynamicAnimationPathValue = ParamValues[string];
-export type AnimationValueForPath<TPath extends AnimationPath> =
- TPath extends AnimationPropertyPath
- ? AnimationPropertyValueMap[TPath]
- : TPath extends GraphicParamPath | EffectParamPath
- ? DynamicAnimationPathValue
- : never;
-export type AnimationNumericPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
-}[AnimationPropertyPath];
-export type AnimationColorPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
-}[AnimationPropertyPath];
-
-export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
-export type DiscreteKeyframeInterpolation = "hold";
-export type AnimationInterpolation =
- | ContinuousKeyframeInterpolation
- | DiscreteKeyframeInterpolation;
-
-export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
-export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
-export type ScalarSegmentType = "step" | "linear" | "bezier";
-export type TangentMode = "auto" | "aligned" | "broken" | "flat";
-export type ChannelExtrapolationMode = "hold" | "linear";
-
-export interface CurveHandle {
- dt: number;
- dv: number;
-}
-
-interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
- id: string;
- time: number; // relative to element start time
- value: TValue;
-}
-
-export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
- leftHandle?: CurveHandle;
- rightHandle?: CurveHandle;
- segmentToNext: ScalarSegmentType;
- tangentMode: TangentMode;
-}
-
-export interface DiscreteAnimationKey
- extends BaseAnimationKeyframe<DiscreteValue> {}
-
-export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
-
-export interface ScalarAnimationChannel {
- kind: "scalar";
- keys: ScalarAnimationKey[];
- extrapolation?: {
- before: ChannelExtrapolationMode;
- after: ChannelExtrapolationMode;
- };
-}
-
-export interface DiscreteAnimationChannel {
- kind: "discrete";
- keys: DiscreteAnimationKey[];
-}
-
-export type AnimationChannel =
- | ScalarAnimationChannel
- | DiscreteAnimationChannel;
-
-export type ElementAnimationChannelMap = Record<
- string,
- AnimationChannel | undefined
->;
-
-export interface AnimationBindingComponent<TKey extends string = string> {
- key: TKey;
- channelId: string;
-}
-
-interface BaseAnimationBinding<
- TKind extends AnimationBindingKind,
- TComponentKey extends string,
-> {
- path: AnimationPath;
- kind: TKind;
- components: AnimationBindingComponent<TComponentKey>[];
-}
-
-export interface NumberAnimationBinding
- extends BaseAnimationBinding<"number", "value"> {}
-
-export interface Vector2AnimationBinding
- extends BaseAnimationBinding<"vector2", "x" | "y"> {}
-
-export interface ColorAnimationBinding
- extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
- colorSpace: "srgb-linear";
-}
-
-export interface DiscreteAnimationBinding
- extends BaseAnimationBinding<"discrete", "value"> {}
-
-export type AnimationBindingInstance =
- | NumberAnimationBinding
- | Vector2AnimationBinding
- | ColorAnimationBinding
- | DiscreteAnimationBinding;
-
-export interface AnimationBindingByKind {
- number: NumberAnimationBinding;
- vector2: Vector2AnimationBinding;
- color: ColorAnimationBinding;
- discrete: DiscreteAnimationBinding;
-}
-
-export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
- AnimationBindingByKind[TKind];
-
-export type ElementAnimationBindingMap = Record<
- string,
- AnimationBindingInstance | undefined
->;
-
-export interface ElementAnimations {
- bindings: ElementAnimationBindingMap;
- channels: ElementAnimationChannelMap;
-}
-
-export type NormalizedCubicBezier = [number, number, number, number];
-
-export interface ScalarGraphChannelTarget {
- propertyPath: AnimationPath;
- componentKey: string;
- channelId: string;
-}
-
-export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
- channel: ScalarAnimationChannel;
-}
-
-export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
- keyframeId: string;
-}
-
-export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
- keyframe: ScalarAnimationKey;
- keyframeIndex: number;
- previousKey: ScalarAnimationKey | null;
- nextKey: ScalarAnimationKey | null;
-}
-
-export interface ScalarCurveKeyframePatch {
- leftHandle?: CurveHandle | null;
- rightHandle?: CurveHandle | null;
- segmentToNext?: ScalarSegmentType;
- tangentMode?: TangentMode;
-}
-
-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/auth/client.ts b/apps/web/src/lib/auth/client.ts
deleted file mode 100644
index 3bec5072..00000000
--- a/apps/web/src/lib/auth/client.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { createAuthClient } from "better-auth/react";
-import { webEnv } from "@/lib/env/web";
-
-export const { signIn, signUp, useSession } = createAuthClient({
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
-});
diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts
deleted file mode 100644
index 39fce38f..00000000
--- a/apps/web/src/lib/auth/server.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { betterAuth, type RateLimit } from "better-auth";
-import { drizzleAdapter } from "better-auth/adapters/drizzle";
-import { Redis } from "@upstash/redis";
-import { db } from "@/lib/db";
-import { webEnv } from "@/lib/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const auth = betterAuth({
- database: drizzleAdapter(db, {
- provider: "pg",
- usePlural: true,
- }),
- secret: webEnv.BETTER_AUTH_SECRET,
- user: {
- deleteUser: {
- enabled: true,
- },
- },
- emailAndPassword: {
- enabled: true,
- },
- rateLimit: {
- storage: "secondary-storage",
- customStorage: {
- get: async (key) => {
- const value = await redis.get(key);
- return value as RateLimit | undefined;
- },
- set: async (key, value) => {
- await redis.set(key, value);
- },
- },
- },
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
- appName: "OpenCut",
- trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
-});
-
-export type Auth = typeof auth;
diff --git a/apps/web/src/lib/background/blur.ts b/apps/web/src/lib/background/blur.ts
deleted file mode 100644
index 3b111482..00000000
--- a/apps/web/src/lib/background/blur.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
- label: string;
- value: number;
-}> = [
- { label: "Light", value: 100 },
- { label: "Medium", value: 200 },
- { label: "Heavy", value: 500 },
-] as const;
-
-export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
diff --git a/apps/web/src/lib/background/color.ts b/apps/web/src/lib/background/color.ts
deleted file mode 100644
index 7b86faf5..00000000
--- a/apps/web/src/lib/background/color.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const DEFAULT_BACKGROUND_COLOR = "#000000";
diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts
deleted file mode 100644
index 91c2a514..00000000
--- a/apps/web/src/lib/blog/query.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import type {
- MarbleAuthorList,
- MarbleCategoryList,
- Pagination,
- MarblePost,
- MarblePostList,
- MarbleTagList,
-} from "@/lib/blog/types";
-import { unified } from "unified";
-import rehypeParse from "rehype-parse";
-import rehypeStringify from "rehype-stringify";
-import rehypeSlug from "rehype-slug";
-import rehypeAutolinkHeadings from "rehype-autolink-headings";
-import rehypeSanitize from "rehype-sanitize";
-
-const url =
- process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
-const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
-
-const EMPTY_PAGINATION: Pagination = {
- limit: 0,
- currpage: 1,
- nextPage: null,
- prevPage: null,
- totalItems: 0,
- totalPages: 0,
-};
-
-const EMPTY_POSTS: MarblePostList = {
- posts: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_TAGS: MarbleTagList = {
- tags: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_CATEGORIES: MarbleCategoryList = {
- categories: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_AUTHORS: MarbleAuthorList = {
- authors: [],
- pagination: EMPTY_PAGINATION,
-};
-
-function isMarbleConfigured() {
- return ![
- "",
- "placeholder",
- "build-placeholder",
- "your_workspace_key_here",
- ].includes(key);
-}
-
-async function fetchFromMarble<T>({
- endpoint,
- fallback,
-}: {
- endpoint: string;
- fallback: T;
-}): Promise<T> {
- if (!isMarbleConfigured()) {
- return fallback;
- }
-
- try {
- const response = await fetch(`${url}/${key}/${endpoint}`);
- if (!response.ok) {
- console.warn(
- `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
- );
- return fallback;
- }
- return (await response.json()) as T;
- } catch (error) {
- console.error(`Error fetching ${endpoint}:`, error);
- return fallback;
- }
-}
-
-export async function getPosts() {
- return fetchFromMarble<MarblePostList>({
- endpoint: "posts",
- fallback: EMPTY_POSTS,
- });
-}
-
-export async function getTags() {
- return fetchFromMarble<MarbleTagList>({
- endpoint: "tags",
- fallback: EMPTY_TAGS,
- });
-}
-
-export async function getSinglePost({ slug }: { slug: string }) {
- return fetchFromMarble<MarblePost | null>({
- endpoint: `posts/${slug}`,
- fallback: null,
- });
-}
-
-export async function getCategories() {
- return fetchFromMarble<MarbleCategoryList>({
- endpoint: "categories",
- fallback: EMPTY_CATEGORIES,
- });
-}
-
-export async function getAuthors() {
- return fetchFromMarble<MarbleAuthorList>({
- endpoint: "authors",
- fallback: EMPTY_AUTHORS,
- });
-}
-
-export async function processHtmlContent({
- html,
-}: {
- html: string;
-}): Promise<string> {
- const processor = unified()
- .use(rehypeSanitize)
- .use(rehypeParse, { fragment: true })
- .use(rehypeSlug)
- .use(rehypeAutolinkHeadings, { behavior: "append" })
- .use(rehypeStringify);
-
- const file = await processor.process({ value: html, type: "html" });
- return String(file);
-}
diff --git a/apps/web/src/lib/blog/types.ts b/apps/web/src/lib/blog/types.ts
deleted file mode 100644
index 7b2f450a..00000000
--- a/apps/web/src/lib/blog/types.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-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
deleted file mode 100644
index 297a2062..00000000
--- a/apps/web/src/lib/cancel-interaction.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-type CancelFn = () => void;
-
-const cancellers = new Set<CancelFn>();
-
-export function registerCanceller({ fn }: { fn: CancelFn }): () => void {
- cancellers.add(fn);
-
- return () => {
- cancellers.delete(fn);
- };
-}
-
-export function cancelInteraction(): boolean {
- if (cancellers.size === 0) return false;
-
- const activeCancellers = Array.from(cancellers);
- cancellers.clear();
-
- for (const cancel of activeCancellers) {
- cancel();
- }
-
- return true;
-}
diff --git a/apps/web/src/lib/canvas/sizes.ts b/apps/web/src/lib/canvas/sizes.ts
deleted file mode 100644
index dce75bcb..00000000
--- a/apps/web/src/lib/canvas/sizes.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { TCanvasSize } from "@/lib/project/types";
-
-export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
- { width: 1920, height: 1080 },
- { width: 1080, height: 1920 },
- { width: 1080, height: 1080 },
- { width: 1440, height: 1080 },
-];
-
-export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
diff --git a/apps/web/src/lib/changelog/components/changelog-notification.tsx b/apps/web/src/lib/changelog/components/changelog-notification.tsx
deleted file mode 100644
index 4433df30..00000000
--- a/apps/web/src/lib/changelog/components/changelog-notification.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import Link from "next/link";
-import { Cancel01Icon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Button } from "@/components/ui/button";
-import { getSortedReleases } from "../utils";
-import type { Release } from "../utils";
-
-const STORAGE_KEY = "last-seen-version";
-
-export function ChangelogNotification() {
- const [release, setRelease] = useState<Release | null>(null);
-
- useEffect(() => {
- const releases = getSortedReleases();
- const latest = releases[0];
- if (!latest) return;
-
- let storedVersion: string | null = null;
- try {
- storedVersion = localStorage.getItem(STORAGE_KEY);
- } catch {
- // localStorage unavailable
- }
-
- const isOutdated =
- storedVersion === null ||
- storedVersion.localeCompare(latest.version, undefined, {
- numeric: true,
- }) < 0;
-
- // TODO(v0.4): revert to the standard "null = first-time visitor, record silently"
- // path. The null case intentionally shows the card for this release so existing
- // users who never had the key get the 0.3.0 announcement.
- if (!isOutdated) return;
-
- try {
- localStorage.setItem(STORAGE_KEY, latest.version);
- } catch {
- // ignore
- }
-
- setRelease(latest);
- }, []);
-
- if (!release) return null;
-
- return (
- <div className="fixed bottom-5 left-5 z-50 flex w-72 flex-col gap-3 rounded-xl border bg-card p-4 shadow-lg">
- <div className="flex items-start justify-between gap-2">
- <div className="flex flex-col gap-1">
- <span className="text-sm font-semibold leading-snug">
- {release.title}
- </span>
- <span className="text-xs text-muted-foreground">
- v{release.version}
- </span>
- </div>
- <Button
- variant="ghost"
- size="icon"
- className="-mr-1 -mt-1 shrink-0"
- onClick={() => setRelease(null)}
- aria-label="Dismiss"
- >
- <HugeiconsIcon icon={Cancel01Icon} className="size-4" />
- </Button>
- </div>
-
- {release.summary && (
- <p className="text-xs leading-relaxed text-muted-foreground">
- {release.summary}
- </p>
- )}
-
- <div className="flex justify-end">
- <Button asChild size="sm">
- <Link href="/changelog" onClick={() => setRelease(null)}>
- See full changelog
- </Link>
- </Button>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/lib/changelog/components/copy-markdown-button.tsx b/apps/web/src/lib/changelog/components/copy-markdown-button.tsx
deleted file mode 100644
index bc7e94bf..00000000
--- a/apps/web/src/lib/changelog/components/copy-markdown-button.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { CheckIcon, ClipboardIcon } from "lucide-react";
-import {
- getSectionTitle,
- groupAndOrderChanges,
- isSectionCollapsible,
-} from "../utils";
-import type { Change } from "../utils";
-import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-
-function buildMarkdown({
- description,
- changes,
-}: {
- description?: string;
- changes: Change[];
-}): string {
- const lines: string[] = [];
-
- if (description) {
- lines.push(description, "");
- }
-
- const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
-
- for (const type of orderedTypes) {
- if (isSectionCollapsible({ type })) {
- lines.push(
- buildCollapsibleMarkdownSection({
- title: getSectionTitle({ type }),
- changes: grouped[type],
- }),
- "",
- );
- continue;
- }
-
- lines.push(`## ${getSectionTitle({ type })}`);
- for (const change of grouped[type]) {
- lines.push(`- ${change.text}`);
- }
- lines.push("");
- }
-
- return lines.join("\n").trimEnd();
-}
-
-function buildCollapsibleMarkdownSection({
- title,
- changes,
-}: {
- title: string;
- changes: Change[];
-}): string {
- const bulletLines = changes.map((change) => `- ${change.text}`).join("\n");
-
- return `<details>\n<summary>${title}</summary>\n\n${bulletLines}\n\n</details>`;
-}
-
-export function CopyMarkdownButton({
- description,
- changes,
-}: {
- description?: string;
- changes: Change[];
-}) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = async () => {
- const markdown = buildMarkdown({ description, changes });
- await navigator.clipboard.writeText(markdown);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- return (
- <Button
- size="sm"
- variant="text"
- onClick={handleCopy}
- className={cn(
- "flex items-center gap-1.5",
- copied && "pointer-events-none",
- )}
- title="Copy as markdown"
- >
- {copied ? (
- <CheckIcon className="size-4" />
- ) : (
- <ClipboardIcon className="size-4" />
- )}
- {copied ? "Copied!" : "Copy markdown"}
- </Button>
- );
-}
diff --git a/apps/web/src/lib/changelog/components/release.tsx b/apps/web/src/lib/changelog/components/release.tsx
deleted file mode 100644
index 475b0a6d..00000000
--- a/apps/web/src/lib/changelog/components/release.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import type { ReactNode } from "react";
-import Link from "next/link";
-import { cn } from "@/utils/ui";
-import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
-import {
- getSectionTitle,
- groupAndOrderChanges,
- isSectionCollapsible,
- type Change,
- type Release,
-} from "../utils";
-import { ArrowRightIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-export function ReleaseArticle({
- variant,
- isLatest,
- children,
-}: {
- variant: "list" | "detail";
- isLatest?: boolean;
- children: ReactNode;
-}) {
- if (variant === "list") {
- return (
- <article className="relative sm:pl-10">
- <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
- <div
- className={cn(
- "size-[11px] rounded-full border-[1.5px]",
- isLatest
- ? "border-foreground bg-foreground"
- : "border-muted-foreground/30 bg-background",
- )}
- />
- </div>
- <div className="flex flex-col gap-5">{children}</div>
- </article>
- );
- }
-
- return <article className="flex flex-col gap-8">{children}</article>;
-}
-
-export function ReleaseMeta({ release }: { release: Release }) {
- return (
- <span className="text-sm font-medium tracking-widest text-muted-foreground">
- {release.version} — {release.date}
- </span>
- );
-}
-
-const titleSizes: Record<"h1" | "h2", string> = {
- h1: "text-4xl",
- h2: "text-2xl",
-};
-
-export function ReleaseTitle({
- as: As,
- href,
- children,
-}: {
- as: "h1" | "h2";
- href?: string;
- children: ReactNode;
-}) {
- return (
- <As className={cn("font-bold tracking-tight", titleSizes[As])}>
- {href ? (
- <Link href={href} className="hover:underline underline-offset-4">
- {children}
- </Link>
- ) : (
- children
- )}
- </As>
- );
-}
-
-export function ReleaseDescription({ children }: { children: ReactNode }) {
- return (
- <p className="text-base text-foreground leading-relaxed max-w-xl">
- {children}
- </p>
- );
-}
-
-export function ReleaseChanges({ release }: { release: Release }) {
- const { grouped, orderedTypes } = groupAndOrderChanges({
- changes: release.changes,
- });
-
- return (
- <div className="flex flex-col gap-4">
- {orderedTypes.map((type) => (
- <ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
- ))}
- </div>
- );
-}
-
-function ReleaseChangeSection({
- type,
- changes,
-}: {
- type: string;
- changes: Change[];
-}) {
- const title = getSectionTitle({ type });
-
- if (isSectionCollapsible({ type })) {
- return (
- <details className="group flex flex-col gap-1.5">
- <summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden">
- <span>{title}:</span>
- <HugeiconsIcon
- icon={ArrowRightIcon}
- className="size-3 shrink-0 text-muted-foreground group-open:rotate-90"
- />
- </summary>
- <ReleaseChangeList changes={changes} />
- </details>
- );
- }
-
- return (
- <div className="flex flex-col gap-1.5">
- <h3 className="text-base font-semibold text-foreground">{title}:</h3>
- <ReleaseChangeList changes={changes} />
- </div>
- );
-}
-
-function ReleaseChangeList({
- changes,
- className,
-}: {
- changes: Change[];
- className?: string;
-}) {
- return (
- <ul className={cn("list-disc space-y-1.5 pl-5", className)}>
- {changes.map((change) => (
- <li
- key={change.text}
- className="text-base leading-relaxed text-foreground"
- >
- <ReactMarkdownWrapper inline>{change.text}</ReactMarkdownWrapper>
- </li>
- ))}
- </ul>
- );
-}
diff --git a/apps/web/src/lib/changelog/entries/0.1.0.md b/apps/web/src/lib/changelog/entries/0.1.0.md
deleted file mode 100644
index 33c5413c..00000000
--- a/apps/web/src/lib/changelog/entries/0.1.0.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-version: "0.1.0"
-date: "2026-02-23"
-published: true
-title: "Editor foundation"
-description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
-summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview."
-changes:
- - type: new
- text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
- - type: new
- text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation."
- - type: new
- text: "New color picker with eyedropper, opacity control, and multiple color formats."
- - type: new
- text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly."
- - type: new
- text: "Move, scale, and rotate elements directly in the preview panel."
- - type: new
- text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now."
- - type: new
- text: "Paste images, videos, or audio from your clipboard straight into the editor."
- - type: new
- text: "Hold Shift while moving or resizing to temporarily turn off snapping."
- - type: improved
- text: "Expanded font selection from 7 to over 1,000."
- - type: improved
- text: "Fonts load much faster."
- - type: improved
- text: "All asset panel tabs now share a consistent layout."
- - type: improved
- text: "Text rendering properly supports multiple lines, line height, and letter spacing."
- - type: improved
- text: "Better contrast in the dark theme."
- - type: fixed
- text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable."
----
diff --git a/apps/web/src/lib/changelog/entries/0.2.0.md b/apps/web/src/lib/changelog/entries/0.2.0.md
deleted file mode 100644
index 261b47d2..00000000
--- a/apps/web/src/lib/changelog/entries/0.2.0.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-version: "0.2.0"
-date: "2026-03-01"
-published: true
-title: "Motion & effects"
-description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
-summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode."
-changes:
- - type: new
- text: "Keyframe animation system for animating element properties over time."
- - type: new
- text: "Entirely new efects system. Effects can be applied per-clip or added as standalone elements on the timeline (with our first effect: Blur!)"
- - type: new
- text: "Added a changelog page to the site."
- - type: new
- text: "Ripple editing mode."
- - type: fixed
- text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below."
- - type: fixed
- text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio."
- - type: fixed
- text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos."
- - type: fixed
- text: "A few values in the properties panel were previously showing incorrect values. This has been fixed."
- - type: improved
- text: "New text elements no longer have a black background by default."
- - type: improved
- text: "Selection outline in the preview is now dashed."
- - type: improved
- text: "Clips sitting next to each other on the timeline now have a small gap between them so selected clips are always clearly visible."
- - type: fixed
- text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends."
- - type: fixed
- text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them."
- - type: fixed
- text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right."
- - type: fixed
- text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize."
- - type: improved
- text: "Press Escape to deselect the current element."
- - type: fixed
- text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge."
- - type: fixed
- text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving."
- - type: fixed
- text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip."
----
diff --git a/apps/web/src/lib/changelog/entries/0.3.0.md b/apps/web/src/lib/changelog/entries/0.3.0.md
deleted file mode 100644
index 86c62cff..00000000
--- a/apps/web/src/lib/changelog/entries/0.3.0.md
+++ /dev/null
@@ -1,114 +0,0 @@
----
-version: "0.3.0"
-date: "2026-04-15"
-published: true
-title: "Masks, animation & more"
-description: "This release adds masks, a graph editor for keyframe curves, volume and speed controls, preview zoom, canvas backgrounds, stickers, and a lot more."
-summary: "Masks, graph editor for curves, volume & speed controls, preview zoom, canvas backgrounds, and stickers."
-changes:
- - type: new
- text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
- - type: fixed
- text: "Sponsor logos now correctly invert in dark mode."
- - type: improved
- text: "Added a changelog link to the footer."
- - type: fixed
- text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
- - type: improved
- text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed."
- - type: fixed
- text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
- - type: fixed
- text: "Clicking a selected element no longer keeps the rest of the selection."
- - type: improved
- text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
- - type: fixed
- text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
- - type: improved
- text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
- - type: improved
- text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
- - type: fixed
- text: "Clicking in the empty space below timeline tracks now seeks the playhead."
- - type: fixed
- text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
- - type: new
- text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
- - type: improved
- text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
- - type: fixed
- text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
- - type: improved
- text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
- - type: improved
- text: "The properties panel has been redesigned with a tabbed layout."
- - type: new
- text: "Volume control for audio and video elements."
- - type: new
- text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
- - type: improved
- text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
- - type: improved
- text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
- - type: fixed
- text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame."
- - type: fixed
- text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
- - type: fixed
- text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
- - type: fixed
- text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
- - type: new
- text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, and cinematic bars. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
- - type: improved
- text: "The settings panel has been redesigned with a cleaner layout and better organization."
- - type: new
- text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
- - type: fixed
- text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
- - type: new
- text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
- - type: new
- text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
- - type: new
- text: "Stickers panel now has content. Browse and add country flags and shapes."
- - type: improved
- text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
- - type: fixed
- text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
- - type: new
- text: "You can now import transcript files to generate captions instead of running auto-transcription."
- - type: new
- text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles."
- - type: fixed
- text: "Text element handles no longer shift position as you type."
- - type: new
- text: "Multiple selected timeline clips move and resize together."
- - type: fixed
- text: "The rotation handle drifted further and further from the element as the canvas size got smaller. On a 100x100 project it was almost off-screen. The gap is now a fixed size regardless of canvas dimensions or zoom."
- - type: new
- text: "Position X and Y can now be animated independently."
- - type: new
- text: "Keyframes can be copied and pasted between elements."
- - type: new
- text: 'Timeline clips can expand to show a lane for each animated property. Right-click a clip -> "Expand keyframes" to see all properties in their own rows.'
- - type: improved
- text: "If your browser doesn't support GPU-accelerated rendering, the editor now shows a notice explaining why and suggests switching to another browser."
- - type: technical
- text: "Ripple editing now has a proper algorithmic foundation. The old inline approach was replaced with a diff-based system in `src/lib/ripple/`: track snapshots before and after an operation are compared as interval sets, vacated intervals are computed (accounting for elements moved to other tracks), and the result is a list of explicit `RippleAdjustment` values applied in a separate pass. Composable, testable, and independent of the command that triggered it."
- - type: technical
- text: "Time is now represented as `MediaTime`, an integer tick count at 120,000 ticks per second, defined in `rust/crates/time`. Previously, floating-point seconds were used throughout, which accumulated rounding errors and made frame alignment imprecise. 120,000 was chosen because it divides evenly by every standard frame rate denominator, including drop-frame rates: 23.976 maps to 5,005 ticks/frame, 29.97 to 4,004, 30 to 4,000. `FrameRate` is a rational `{numerator, denominator}` type rather than a float. All time arithmetic, frame snapping, and timecode formatting are implemented in Rust and exposed via WASM."
- - type: technical
- text: "Track ordering is now enforced at the type level. `SceneTracks` replaces a flat track array with three explicit fields: `overlay`, `main` (a singleton `VideoTrack`), and `audio`. The type makes it structurally impossible to mix track kinds or misplace a track."
- - type: technical
- text: "The WebGL renderer has been replaced with a Rust/wgpu compositor compiled to WASM. The `rust/` crate tree covers compositing (`rust/crates/compositor`), effects (`rust/crates/effects`), masks with JFA-based feathering (`rust/crates/masks`), GPU context (`rust/crates/gpu`), and time (`rust/crates/time`). WASM bindings live in `rust/wasm/` and are consumed from TypeScript through `src/lib/wasm/`. The old GLSL shaders and WebGL utilities are gone."
- - type: technical
- text: "`src/types/` and `src/constants/` have been dissolved. All types and constants now live alongside the code that owns them inside `src/lib/`. The `packages/env` and `packages/ui` internal packages were also folded into the web app, with icons at `src/components/icons/` and env at `src/lib/env/`."
- - type: technical
- text: "The web app can now deploy to Cloudflare Workers via OpenNext. `wrangler.jsonc` and `apps/web/open-next.config.ts` are included for anyone self-hosting."
- - type: technical
- text: "`apps/desktop/` is a new GPUI + Rust app. It is early: there is a `main.rs` and a working build setup, but no features yet."
- - type: technical
- text: "16 new storage migration steps were added (v9 through v25), covering the schema changes accumulated during this release cycle."
-
----
diff --git a/apps/web/src/lib/changelog/entries/0.4.0.md b/apps/web/src/lib/changelog/entries/0.4.0.md
deleted file mode 100644
index 141df133..00000000
--- a/apps/web/src/lib/changelog/entries/0.4.0.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-version: "0.3.0"
-date: "2026-03-04"
-published: false
-title: "Title"
-description: "Description"
-changes:
-- type: new
- text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
-- type: new
- text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
-- type: fixed
- text: "Preview panning now keeps working while the Masks tab is open."
-- type: new
- text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
-- type: improved
- text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
-- type: fixed
- text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio." \ No newline at end of file
diff --git a/apps/web/src/lib/changelog/utils.ts b/apps/web/src/lib/changelog/utils.ts
deleted file mode 100644
index 682d376a..00000000
--- a/apps/web/src/lib/changelog/utils.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { allChangelogs } from "content-collections";
-
-export type Change = { type: string; text: string };
-export type Release = (typeof allChangelogs)[number];
-
-type ChangeSectionConfig = {
- title: string;
- order: number;
- collapsible?: boolean;
-};
-
-const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
- new: { title: "Features", order: 0 },
- improved: { title: "Improvements", order: 1 },
- fixed: { title: "Fixes", order: 2 },
- breaking: { title: "Breaking Changes", order: 3 },
- technical: {
- title: "Technical details",
- order: 4,
- collapsible: true,
- },
-};
-
-function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
- return (
- knownSectionConfigs[type] ?? {
- title: type.charAt(0).toUpperCase() + type.slice(1),
- order: Number.MAX_SAFE_INTEGER,
- }
- );
-}
-
-export function getSectionTitle({ type }: { type: string }): string {
- return getSectionConfig({ type }).title;
-}
-
-export function isSectionCollapsible({ type }: { type: string }): boolean {
- return getSectionConfig({ type }).collapsible ?? false;
-}
-
-export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
- const typeEncounterOrder = new Map<string, number>();
- const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
- if (!typeEncounterOrder.has(change.type)) {
- typeEncounterOrder.set(change.type, typeEncounterOrder.size);
- }
- if (!acc[change.type]) acc[change.type] = [];
- acc[change.type].push(change);
- return acc;
- }, {});
-
- const orderedTypes = Object.keys(grouped).sort((left, right) => {
- const orderDifference =
- getSectionConfig({ type: left }).order -
- getSectionConfig({ type: right }).order;
- if (orderDifference !== 0) {
- return orderDifference;
- }
- return (
- (typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) -
- (typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER)
- );
- });
-
- return { grouped, orderedTypes };
-}
-
-function isPublishedRelease({ published }: Release) {
- return published !== false;
-}
-
-export function getSortedReleases() {
- return allChangelogs
- .filter(isPublishedRelease)
- .sort((a, b) =>
- b.version.localeCompare(a.version, undefined, { numeric: true }),
- );
-}
-
-export function getReleaseByVersion({ version }: { version: string }) {
- return getSortedReleases().find((release) => release.version === version);
-}
diff --git a/apps/web/src/lib/clipboard/handlers/elements.ts b/apps/web/src/lib/clipboard/handlers/elements.ts
deleted file mode 100644
index 1b58ac78..00000000
--- a/apps/web/src/lib/clipboard/handlers/elements.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { PasteCommand } from "@/lib/commands/timeline";
-import type { ClipboardHandler } from "../types";
-
-export const ElementsClipboardHandler = {
- type: "elements",
-
- canCopy({ selectedElements }) {
- return selectedElements.length > 0;
- },
-
- copy({ editor, selectedElements }) {
- if (selectedElements.length === 0) {
- return null;
- }
-
- const results = editor.timeline.getElementsWithTracks({
- elements: selectedElements,
- });
- const items = results.map(({ track, element }) => {
- const { id: _elementId, ...elementWithoutId } = element;
- return {
- trackId: track.id,
- trackType: track.type,
- element: elementWithoutId,
- };
- });
-
- if (items.length === 0) {
- return null;
- }
-
- return {
- type: "elements",
- items,
- };
- },
-
- paste(entry, context) {
- if (entry.items.length === 0) {
- return null;
- }
-
- return new PasteCommand({
- time: context.time,
- clipboardItems: entry.items,
- });
- },
-} satisfies ClipboardHandler<"elements">;
diff --git a/apps/web/src/lib/clipboard/handlers/index.ts b/apps/web/src/lib/clipboard/handlers/index.ts
deleted file mode 100644
index 29d4d1d0..00000000
--- a/apps/web/src/lib/clipboard/handlers/index.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type {
- ClipboardEntry,
- ClipboardEntryType,
- ClipboardHandler,
- ClipboardHandlerMap,
- CopyContext,
- PasteContext,
-} from "../types";
-import { ElementsClipboardHandler } from "./elements";
-import { KeyframesClipboardHandler } from "./keyframes";
-
-export const clipboardHandlers = {
- elements: ElementsClipboardHandler,
- keyframes: KeyframesClipboardHandler,
-} satisfies ClipboardHandlerMap;
-
-export const clipboardCopyHandlers = [
- KeyframesClipboardHandler,
- ElementsClipboardHandler,
-] as const satisfies readonly ClipboardHandler<ClipboardEntryType>[];
-
-export function copyClipboardEntry({
- context,
-}: {
- context: CopyContext;
-}): ClipboardEntry | null {
- for (const handler of clipboardCopyHandlers) {
- if (!handler.canCopy(context)) {
- continue;
- }
-
- return handler.copy(context);
- }
-
- return null;
-}
-
-export function buildPasteClipboardCommand({
- entry,
- context,
-}: {
- entry: ClipboardEntry;
- context: PasteContext;
-}) {
- const handler = clipboardHandlers[entry.type] as ClipboardHandler<
- typeof entry.type
- >;
- return handler.paste(entry as never, context);
-}
diff --git a/apps/web/src/lib/clipboard/handlers/keyframes.ts b/apps/web/src/lib/clipboard/handlers/keyframes.ts
deleted file mode 100644
index b746c179..00000000
--- a/apps/web/src/lib/clipboard/handlers/keyframes.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { getKeyframeById } from "@/lib/animation";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
-import { PasteKeyframesCommand } from "@/lib/commands/timeline";
-import type {
- ClipboardHandler,
- KeyframeClipboardCurvePatch,
- KeyframeClipboardItem,
-} from "../types";
-
-function resolveSingleSourceElement({
- selectedKeyframes,
-}: {
- selectedKeyframes: SelectedKeyframeRef[];
-}) {
- const firstKeyframe = selectedKeyframes[0];
- if (!firstKeyframe) {
- return null;
- }
-
- const sourceElement = {
- trackId: firstKeyframe.trackId,
- elementId: firstKeyframe.elementId,
- };
- const isSingleSource = selectedKeyframes.every(
- (keyframe) =>
- keyframe.trackId === sourceElement.trackId &&
- keyframe.elementId === sourceElement.elementId,
- );
-
- return isSingleSource ? sourceElement : null;
-}
-
-function getCurvePatches({
- element,
- propertyPath,
- keyframeId,
-}: {
- element: TimelineElement;
- propertyPath: KeyframeClipboardItem["propertyPath"];
- keyframeId: string;
-}): KeyframeClipboardCurvePatch[] {
- const binding = element.animations?.bindings[propertyPath];
- if (!binding) {
- return [];
- }
-
- return binding.components.flatMap((component) => {
- const channel = element.animations?.channels[component.channelId];
- if (channel?.kind !== "scalar") {
- return [];
- }
-
- const keyframe = channel.keys.find(
- (candidate) => candidate.id === keyframeId,
- );
- if (!keyframe) {
- return [];
- }
-
- return [
- {
- componentKey: component.key,
- patch: {
- leftHandle: keyframe.leftHandle ?? null,
- rightHandle: keyframe.rightHandle ?? null,
- segmentToNext: keyframe.segmentToNext,
- tangentMode: keyframe.tangentMode,
- },
- },
- ];
- });
-}
-
-function buildClipboardItem({
- element,
- propertyPath,
- keyframeId,
-}: {
- element: TimelineElement;
- propertyPath: KeyframeClipboardItem["propertyPath"];
- keyframeId: string;
-}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
- const keyframe = getKeyframeById({
- animations: element.animations,
- propertyPath,
- keyframeId,
- });
- if (!keyframe) {
- return null;
- }
-
- return {
- propertyPath,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- curvePatches: getCurvePatches({
- element,
- propertyPath,
- keyframeId,
- }),
- };
-}
-
-export const KeyframesClipboardHandler = {
- type: "keyframes",
-
- canCopy({ selectedKeyframes }) {
- return selectedKeyframes.length > 0;
- },
-
- copy({ editor, selectedKeyframes }) {
- const sourceElement = resolveSingleSourceElement({ selectedKeyframes });
- if (!sourceElement) {
- return null;
- }
-
- const [result] = editor.timeline.getElementsWithTracks({
- elements: [sourceElement],
- });
- if (!result) {
- return null;
- }
-
- const rawItems = selectedKeyframes.flatMap((keyframeRef) => {
- const item = buildClipboardItem({
- element: result.element,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- });
- return item ? [item] : [];
- });
- if (rawItems.length === 0) {
- return null;
- }
-
- const minTime = Math.min(...rawItems.map((item) => item.time));
- const items = rawItems
- .map(({ time, ...item }) => ({
- ...item,
- timeOffset: time - minTime,
- }))
- .sort(
- (left, right) =>
- left.timeOffset - right.timeOffset ||
- left.propertyPath.localeCompare(right.propertyPath),
- );
-
- return {
- type: "keyframes",
- sourceElement,
- items,
- };
- },
-
- paste(entry, { selectedElements, time }) {
- const targetElement = selectedElements[0];
- if (!targetElement || entry.items.length === 0) {
- return null;
- }
-
- return new PasteKeyframesCommand({
- trackId: targetElement.trackId,
- elementId: targetElement.elementId,
- time,
- clipboardItems: entry.items,
- });
- },
-} satisfies ClipboardHandler<"keyframes">;
diff --git a/apps/web/src/lib/clipboard/index.ts b/apps/web/src/lib/clipboard/index.ts
deleted file mode 100644
index 76d5bddf..00000000
--- a/apps/web/src/lib/clipboard/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./types";
-export * from "./handlers";
diff --git a/apps/web/src/lib/clipboard/types.ts b/apps/web/src/lib/clipboard/types.ts
deleted file mode 100644
index 41e107ef..00000000
--- a/apps/web/src/lib/clipboard/types.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import type { EditorCore } from "@/core";
-import type {
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
- ScalarCurveKeyframePatch,
- SelectedKeyframeRef,
-} from "@/lib/animation/types";
-import type { Command } from "@/lib/commands/base-command";
-import type {
- CreateTimelineElement,
- ElementRef,
- TrackType,
-} from "@/lib/timeline";
-
-export interface ElementClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
-
-export interface KeyframeClipboardCurvePatch {
- componentKey: string;
- patch: ScalarCurveKeyframePatch;
-}
-
-export interface KeyframeClipboardItem {
- propertyPath: AnimationPath;
- timeOffset: number;
- value: AnimationValue;
- interpolation: AnimationInterpolation;
- curvePatches: KeyframeClipboardCurvePatch[];
-}
-
-export interface ElementsClipboardEntry {
- type: "elements";
- items: ElementClipboardItem[];
-}
-
-export interface KeyframesClipboardEntry {
- type: "keyframes";
- sourceElement: ElementRef;
- items: KeyframeClipboardItem[];
-}
-
-export interface ClipboardEntryByType {
- elements: ElementsClipboardEntry;
- keyframes: KeyframesClipboardEntry;
-}
-
-export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType];
-export type ClipboardEntryType = keyof ClipboardEntryByType;
-
-export interface CopyContext {
- editor: EditorCore;
- selectedElements: ElementRef[];
- selectedKeyframes: SelectedKeyframeRef[];
-}
-
-export interface PasteContext {
- editor: EditorCore;
- selectedElements: ElementRef[];
- selectedKeyframes: SelectedKeyframeRef[];
- time: number;
-}
-
-export interface ClipboardHandler<TType extends ClipboardEntryType> {
- type: TType;
- canCopy(context: CopyContext): boolean;
- copy(context: CopyContext): ClipboardEntryByType[TType] | null;
- paste(
- entry: ClipboardEntryByType[TType],
- context: PasteContext,
- ): Command | null;
-}
-
-export type ClipboardHandlerMap = {
- [TType in ClipboardEntryType]: ClipboardHandler<TType>;
-};
diff --git a/apps/web/src/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts
deleted file mode 100644
index b244f2f3..00000000
--- a/apps/web/src/lib/commands/base-command.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export interface CommandResult {
- selection?: EditorSelectionPatch;
-}
-
-export function createElementSelectionResult(
- selectedElements: ElementRef[],
-): CommandResult {
- return {
- selection: {
- selectedElements,
- selectedKeyframes: [],
- keyframeSelectionAnchor: null,
- selectedMaskPoints: null,
- },
- };
-}
-
-export abstract class Command {
- abstract execute(): CommandResult | undefined;
-
- undo(): void {
- throw new Error("Undo not implemented for this command");
- }
-
- redo(): CommandResult | undefined {
- return this.execute();
- }
-}
diff --git a/apps/web/src/lib/commands/batch-command.ts b/apps/web/src/lib/commands/batch-command.ts
deleted file mode 100644
index c4ee7e0b..00000000
--- a/apps/web/src/lib/commands/batch-command.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Command, type CommandResult } from "./base-command";
-
-export class BatchCommand extends Command {
- constructor(private commands: Command[]) {
- super();
- }
-
- execute(): CommandResult | undefined {
- let latestSelectionResult: CommandResult | undefined;
-
- for (const command of this.commands) {
- const result = command.execute();
- if (result?.selection !== undefined) {
- latestSelectionResult = result;
- }
- }
-
- return latestSelectionResult;
- }
-
- undo(): void {
- for (const command of [...this.commands].reverse()) {
- command.undo();
- }
- }
-
- redo(): CommandResult | undefined {
- let latestSelectionResult: CommandResult | undefined;
-
- for (const command of this.commands) {
- const result = command.redo();
- if (result?.selection !== undefined) {
- latestSelectionResult = result;
- }
- }
-
- return latestSelectionResult;
- }
-}
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
deleted file mode 100644
index 78927ca9..00000000
--- a/apps/web/src/lib/commands/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export { Command } from "./base-command";
-export type { CommandResult } from "./base-command";
-export { BatchCommand } from "./batch-command";
-
-export * from "./timeline";
-export * from "./media";
-export * from "./scene";
-export * from "./project";
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
deleted file mode 100644
index 12e0906e..00000000
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { toast } from "sonner";
-import type { MediaAsset } from "@/lib/media/types";
-import { generateUUID } from "@/utils/id";
-import { storageService } from "@/services/storage/service";
-import type { FrameRate } from "opencut-wasm";
-import { hasMediaId } from "@/lib/timeline/element-utils";
-import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
-import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
-
-export class AddMediaAssetCommand extends Command {
- private assetId: string;
- private savedAssets: MediaAsset[] | null = null;
- private createdAsset: MediaAsset | null = null;
- private previousProjectFps: FrameRate | null = null;
- private appliedProjectFps: FrameRate | null = null;
-
- constructor(
- private projectId: string,
- private asset: Omit<MediaAsset, "id">,
- ) {
- super();
- this.assetId = generateUUID();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedAssets = [...editor.media.getAssets()];
-
- this.createdAsset = {
- ...this.asset,
- id: this.assetId,
- };
-
- editor.media.setAssets({
- assets: [...this.savedAssets, this.createdAsset],
- });
- this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null;
- this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({
- importedAssets: [this.createdAsset],
- });
-
- storageService
- .saveMediaAsset({
- projectId: this.projectId,
- mediaAsset: this.createdAsset,
- })
- .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.scenes.getActiveScene().tracks;
- const orphanedElements: Array<{ trackId: string; elementId: string }> =
- [];
-
- for (const track of [
- ...currentTracks.overlay,
- currentTracks.main,
- ...currentTracks.audio,
- ]) {
- 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 });
- }
-
- this.restoreProjectFpsAfterFailedSave({ editor });
-
- if (storageService.isQuotaExceededError({ error })) {
- toast.error("Not enough browser storage", {
- description: error instanceof Error ? error.message : undefined,
- });
- }
- });
-
- return undefined;
- }
-
- undo(): void {
- if (this.savedAssets) {
- const editor = EditorCore.getInstance();
- editor.media.setAssets({ assets: this.savedAssets });
-
- if (this.createdAsset) {
- storageService
- .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
- .catch((error) => {
- console.error("Failed to delete media item on undo:", error);
- });
- }
- }
- }
-
- getAssetId(): string {
- return this.assetId;
- }
-
- private restoreProjectFpsAfterFailedSave({
- editor,
- }: {
- editor: EditorCore;
- }): void {
- if (this.previousProjectFps === null || this.appliedProjectFps === null) return;
-
- const activeProject = editor.project.getActiveOrNull();
- if (!activeProject) return;
- if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
-
- const highestRemainingVideoFps = getHighestImportedVideoFps({
- mediaAssets: editor.media.getAssets(),
- });
- const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
- if (
- highestRemainingVideoFps !== null &&
- highestRemainingVideoFps >= appliedFpsFloat
- ) {
- return;
- }
-
- new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute();
- }
-}
diff --git a/apps/web/src/lib/commands/media/index.ts b/apps/web/src/lib/commands/media/index.ts
deleted file mode 100644
index 466222f2..00000000
--- a/apps/web/src/lib/commands/media/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { AddMediaAssetCommand } from "./add-media-asset";
-export { RemoveMediaAssetCommand } from "./remove-media-asset";
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
deleted file mode 100644
index 85d187d9..00000000
--- a/apps/web/src/lib/commands/media/remove-media-asset.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/lib/media/types";
-import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
-import { storageService } from "@/services/storage/service";
-import { videoCache } from "@/services/video-cache/service";
-import { waveformCache } from "@/services/waveform-cache/service";
-import { hasMediaId } from "@/lib/timeline/element-utils";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class RemoveMediaAssetCommand extends Command {
- private savedAssets: MediaAsset[] | null = null;
- private savedTracks: SceneTracks | null = null;
- private removedAsset: MediaAsset | null = null;
-
- constructor(
- private projectId: string,
- private assetId: string,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const assets = editor.media.getAssets();
-
- this.savedAssets = [...assets];
- this.savedTracks = editor.scenes.getActiveScene().tracks;
-
- this.removedAsset =
- assets.find((media) => media.id === this.assetId) ?? null;
-
- if (!this.removedAsset) {
- console.error("Media asset not found:", this.assetId);
- return;
- }
-
- if (this.removedAsset.url) {
- URL.revokeObjectURL(this.removedAsset.url);
- }
- if (this.removedAsset.thumbnailUrl) {
- URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
- }
-
- videoCache.clearVideo({ mediaId: this.assetId });
- waveformCache.clearSource({
- sourceKey: buildWaveformSourceKey({
- kind: "media",
- id: this.assetId,
- }),
- });
-
- editor.media.setAssets({
- assets: assets.filter((media) => media.id !== this.assetId),
- });
-
- const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
-
- for (const track of [
- ...this.savedTracks.overlay,
- this.savedTracks.main,
- ...this.savedTracks.audio,
- ]) {
- for (const element of track.elements) {
- if (hasMediaId(element) && element.mediaId === this.assetId) {
- elementsToRemove.push({ trackId: track.id, elementId: element.id });
- }
- }
- }
-
- if (elementsToRemove.length > 0) {
- editor.timeline.deleteElements({ elements: elementsToRemove });
- }
-
- storageService
- .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
- .catch((error) => {
- console.error("Failed to delete media item:", error);
- });
- }
-
- undo(): void {
- const editor = EditorCore.getInstance();
-
- if (this.savedAssets && this.removedAsset) {
- const restoredAsset: MediaAsset = {
- ...this.removedAsset,
- url: URL.createObjectURL(this.removedAsset.file),
- };
-
- editor.media.setAssets({
- assets: this.savedAssets.map((a) =>
- a.id === this.assetId ? restoredAsset : a,
- ),
- });
-
- storageService
- .saveMediaAsset({
- projectId: this.projectId,
- mediaAsset: restoredAsset,
- })
- .catch((error) => {
- console.error("Failed to restore media item on undo:", error);
- });
- }
-
- if (this.savedTracks) {
- editor.timeline.updateTracks(this.savedTracks);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts
deleted file mode 100644
index 68c8dee3..00000000
--- a/apps/web/src/lib/commands/preview-tracker.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export class PreviewTracker<T> {
- private snapshot: T | null = null;
-
- begin({ state }: { state: T }): void {
- if (this.snapshot === null) {
- this.snapshot = structuredClone(state);
- }
- }
-
- isActive(): boolean {
- return this.snapshot !== null;
- }
-
- getSnapshot(): T | null {
- return this.snapshot;
- }
-
- end(): T | null {
- const snapshot = this.snapshot;
- this.snapshot = null;
- return snapshot;
- }
-}
diff --git a/apps/web/src/lib/commands/project/index.ts b/apps/web/src/lib/commands/project/index.ts
deleted file mode 100644
index a9f26edc..00000000
--- a/apps/web/src/lib/commands/project/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./update-project-settings";
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts
deleted file mode 100644
index 32258e12..00000000
--- a/apps/web/src/lib/commands/project/update-project-settings.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TProject, TProjectSettings } from "@/lib/project/types";
-
-export class UpdateProjectSettingsCommand extends Command {
- private savedSettings: TProjectSettings | null = null;
- private savedUpdatedAt: Date | null = null;
-
- constructor(private updates: Partial<TProjectSettings>) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- this.savedSettings = activeProject.settings;
- this.savedUpdatedAt = activeProject.metadata.updatedAt;
-
- const updatedProject: TProject = {
- ...activeProject,
- settings: { ...activeProject.settings, ...this.updates },
- metadata: { ...activeProject.metadata, updatedAt: new Date() },
- };
-
- editor.project.setActiveProject({ project: updatedProject });
- editor.save.markDirty();
- }
-
- undo(): void {
- if (!this.savedSettings || !this.savedUpdatedAt) return;
- const editor = EditorCore.getInstance();
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const updatedProject: TProject = {
- ...activeProject,
- settings: this.savedSettings,
- metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
- };
-
- editor.project.setActiveProject({ project: updatedProject });
- editor.save.markDirty();
- }
-}
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts
deleted file mode 100644
index 1cc72c0f..00000000
--- a/apps/web/src/lib/commands/scene/create-scene.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { buildDefaultScene } from "@/lib/scenes";
-
-export class CreateSceneCommand extends Command {
- private savedScenes: TScene[] | null = null;
- private createdScene: TScene | null = null;
-
- constructor(
- private name: string,
- private isMain: boolean = false,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedScenes = [...editor.scenes.getScenes()];
-
- this.createdScene = buildDefaultScene({
- name: this.name,
- isMain: this.isMain,
- });
-
- const updatedScenes = [...this.savedScenes, this.createdScene];
- editor.scenes.setScenes({ scenes: updatedScenes });
- return undefined;
- }
-
- undo(): void {
- if (this.savedScenes) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-
- getSceneId(): string {
- return this.createdScene?.id ?? "";
- }
-}
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts
deleted file mode 100644
index ab269b12..00000000
--- a/apps/web/src/lib/commands/scene/delete-scene.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
-
-export class DeleteSceneCommand extends Command {
- private savedScenes: TScene[] | null = null;
- private savedActiveSceneId: string | null = null;
- private deletedScene: TScene | null = null;
-
- constructor(private sceneId: string) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const scenes = editor.scenes.getScenes();
- const activeScene = editor.scenes.getActiveScene();
-
- this.savedScenes = [...scenes];
- this.savedActiveSceneId = activeScene?.id ?? null;
-
- this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
-
- if (!this.deletedScene) {
- console.error("Scene not found:", this.sceneId);
- return;
- }
-
- const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
- if (!canDelete) {
- console.error("Cannot delete scene:", reason);
- return;
- }
-
- const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
-
- const newActiveScene = getFallbackSceneAfterDelete({
- scenes: updatedScenes,
- deletedSceneId: this.sceneId,
- currentSceneId: activeScene?.id ?? null,
- });
-
- editor.scenes.setScenes({
- scenes: updatedScenes,
- activeSceneId: newActiveScene?.id,
- });
- }
-
- undo(): void {
- if (this.savedScenes && this.deletedScene) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({
- scenes: this.savedScenes,
- activeSceneId: this.savedActiveSceneId ?? undefined,
- });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts
deleted file mode 100644
index 2b843c99..00000000
--- a/apps/web/src/lib/commands/scene/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export { CreateSceneCommand } from "./create-scene";
-export { DeleteSceneCommand } from "./delete-scene";
-export { RenameSceneCommand } from "./rename-scene";
-export { ToggleBookmarkCommand } from "./toggle-bookmark";
-export { RemoveBookmarkCommand } from "./remove-bookmark";
-export { UpdateBookmarkCommand } from "./update-bookmark";
-export { MoveBookmarkCommand } from "./move-bookmark";
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts
deleted file mode 100644
index 103caa74..00000000
--- a/apps/web/src/lib/commands/scene/move-bookmark.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { updateSceneInArray } from "@/lib/scenes";
-import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
-
-export class MoveBookmarkCommand extends Command {
- private savedScenes: TScene[] | null = null;
-
- constructor(
- private fromTime: number,
- private toTime: number,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const activeScene = editor.scenes.getActiveScene();
- const activeProject = editor.project.getActive();
-
- if (!activeScene || !activeProject) {
- return;
- }
-
- const scenes = editor.scenes.getScenes();
- this.savedScenes = [...scenes];
-
- const fromFrameTime = getFrameTime({
- time: this.fromTime,
- fps: activeProject.settings.fps,
- });
- const toFrameTime = getFrameTime({
- time: this.toTime,
- fps: activeProject.settings.fps,
- });
-
- const updatedBookmarks = moveBookmarkInArray({
- bookmarks: activeScene.bookmarks,
- fromTime: fromFrameTime,
- toTime: toFrameTime,
- });
-
- const updatedScenes = updateSceneInArray({
- scenes,
- sceneId: activeScene.id,
- updates: { bookmarks: updatedBookmarks },
- });
-
- editor.scenes.setScenes({ scenes: updatedScenes });
- }
-
- undo(): void {
- if (this.savedScenes) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts
deleted file mode 100644
index 5eac920f..00000000
--- a/apps/web/src/lib/commands/scene/remove-bookmark.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { updateSceneInArray } from "@/lib/scenes";
-import {
- getFrameTime,
- removeBookmarkFromArray,
-} from "@/lib/timeline/bookmarks";
-
-export class RemoveBookmarkCommand extends Command {
- private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
-
- constructor(private time: number) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const activeScene = editor.scenes.getActiveScene();
- const activeProject = editor.project.getActive();
-
- if (!activeScene || !activeProject) {
- return;
- }
-
- const scenes = editor.scenes.getScenes();
- this.savedScenes = [...scenes];
-
- this.frameTime = getFrameTime({
- time: this.time,
- fps: activeProject.settings.fps,
- });
-
- const updatedBookmarks = removeBookmarkFromArray({
- bookmarks: activeScene.bookmarks,
- frameTime: this.frameTime,
- });
-
- if (updatedBookmarks.length === activeScene.bookmarks.length) {
- return;
- }
-
- const updatedScenes = updateSceneInArray({
- scenes,
- sceneId: activeScene.id,
- updates: { bookmarks: updatedBookmarks },
- });
-
- editor.scenes.setScenes({ scenes: updatedScenes });
- }
-
- undo(): void {
- if (this.savedScenes) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts
deleted file mode 100644
index ccdc85d6..00000000
--- a/apps/web/src/lib/commands/scene/rename-scene.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { updateSceneInArray } from "@/lib/scenes";
-
-export class RenameSceneCommand extends Command {
- private savedScenes: TScene[] | null = null;
- private previousName: string | null = null;
-
- constructor(
- private sceneId: string,
- private newName: string,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const scenes = editor.scenes.getScenes();
-
- this.savedScenes = [...scenes];
-
- const scene = scenes.find((s) => s.id === this.sceneId);
- if (!scene) {
- console.error("Scene not found:", this.sceneId);
- return;
- }
-
- this.previousName = scene.name;
-
- const updatedScenes = updateSceneInArray({
- scenes,
- sceneId: this.sceneId,
- updates: { name: this.newName, updatedAt: new Date() },
- });
-
- editor.scenes.setScenes({ scenes: updatedScenes });
- }
-
- undo(): void {
- if (this.savedScenes && this.previousName !== null) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
deleted file mode 100644
index cb4fa655..00000000
--- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { TScene } from "@/lib/timeline";
-import { updateSceneInArray } from "@/lib/scenes";
-import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
-
-export class ToggleBookmarkCommand extends Command {
- private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
-
- constructor(private time: number) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const activeScene = editor.scenes.getActiveScene();
- const activeProject = editor.project.getActive();
-
- if (!activeScene || !activeProject) {
- return;
- }
-
- const scenes = editor.scenes.getScenes();
- this.savedScenes = [...scenes];
-
- this.frameTime = getFrameTime({
- time: this.time,
- fps: activeProject.settings.fps,
- });
-
- const updatedBookmarks = toggleBookmarkInArray({
- bookmarks: activeScene.bookmarks,
- frameTime: this.frameTime,
- });
-
- const updatedScenes = updateSceneInArray({
- scenes,
- sceneId: activeScene.id,
- updates: { bookmarks: updatedBookmarks },
- });
-
- editor.scenes.setScenes({ scenes: updatedScenes });
- }
-
- undo(): void {
- if (this.savedScenes) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts
deleted file mode 100644
index 1d1c7283..00000000
--- a/apps/web/src/lib/commands/scene/update-bookmark.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { Bookmark, TScene } from "@/lib/timeline";
-import { updateSceneInArray } from "@/lib/scenes";
-import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
-
-export class UpdateBookmarkCommand extends Command {
- private savedScenes: TScene[] | null = null;
-
- constructor(
- private time: number,
- private updates: Partial<Omit<Bookmark, "time">>,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- const activeScene = editor.scenes.getActiveScene();
- const activeProject = editor.project.getActive();
-
- if (!activeScene || !activeProject) {
- return;
- }
-
- const scenes = editor.scenes.getScenes();
- this.savedScenes = [...scenes];
-
- const frameTime = getFrameTime({
- time: this.time,
- fps: activeProject.settings.fps,
- });
-
- const updatedBookmarks = updateBookmarkInArray({
- bookmarks: activeScene.bookmarks,
- frameTime,
- updates: this.updates,
- });
-
- const updatedScenes = updateSceneInArray({
- scenes,
- sceneId: activeScene.id,
- updates: { bookmarks: updatedBookmarks },
- });
-
- editor.scenes.setScenes({ scenes: updatedScenes });
- }
-
- undo(): void {
- if (this.savedScenes) {
- const editor = EditorCore.getInstance();
- editor.scenes.setScenes({ scenes: this.savedScenes });
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/clipboard/index.ts b/apps/web/src/lib/commands/timeline/clipboard/index.ts
deleted file mode 100644
index e893b62b..00000000
--- a/apps/web/src/lib/commands/timeline/clipboard/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { PasteCommand } from "./paste";
-export { PasteKeyframesCommand } from "./paste-keyframes";
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts
deleted file mode 100644
index 210afbd3..00000000
--- a/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { EditorCore } from "@/core";
-import {
- getKeyframeAtTime,
- resolveAnimationTarget,
- updateScalarKeyframeCurve,
- upsertPathKeyframe,
-} from "@/lib/animation";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { KeyframeClipboardItem } from "@/lib/clipboard";
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-
-function pasteKeyframesIntoElement({
- element,
- time,
- clipboardItems,
-}: {
- element: TimelineElement;
- time: number;
- clipboardItems: KeyframeClipboardItem[];
-}): TimelineElement {
- let nextElement = element;
-
- for (const item of clipboardItems) {
- const target = resolveAnimationTarget({
- element: nextElement,
- path: item.propertyPath,
- });
- if (!target) {
- continue;
- }
-
- const keyframeTime = Math.max(
- 0,
- Math.min(time + item.timeOffset, nextElement.duration),
- );
- const nextAnimations = upsertPathKeyframe({
- animations: nextElement.animations,
- propertyPath: item.propertyPath,
- time: keyframeTime,
- value: item.value,
- interpolation: item.interpolation,
- keyframeId: generateUUID(),
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
- coerceValue: target.coerceValue,
- });
- const pastedKeyframe = getKeyframeAtTime({
- animations: nextAnimations,
- propertyPath: item.propertyPath,
- time: keyframeTime,
- });
-
- let patchedAnimations = nextAnimations;
- if (pastedKeyframe) {
- for (const curvePatch of item.curvePatches) {
- const nextPatchedAnimations = updateScalarKeyframeCurve({
- animations: patchedAnimations,
- propertyPath: item.propertyPath,
- componentKey: curvePatch.componentKey,
- keyframeId: pastedKeyframe.id,
- patch: curvePatch.patch,
- });
- patchedAnimations = nextPatchedAnimations ?? patchedAnimations;
- }
- }
-
- nextElement = {
- ...nextElement,
- animations: patchedAnimations,
- };
- }
-
- return nextElement;
-}
-
-export class PasteKeyframesCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly time: number;
- private readonly clipboardItems: KeyframeClipboardItem[];
-
- constructor({
- trackId,
- elementId,
- time,
- clipboardItems,
- }: {
- trackId: string;
- elementId: string;
- time: number;
- clipboardItems: KeyframeClipboardItem[];
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.time = time;
- this.clipboardItems = clipboardItems;
- }
-
- execute(): CommandResult | undefined {
- if (this.clipboardItems.length === 0) {
- return undefined;
- }
-
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) =>
- pasteKeyframesIntoElement({
- element,
- time: this.time,
- clipboardItems: this.clipboardItems,
- }),
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
deleted file mode 100644
index 0fc7948f..00000000
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import type { TimelineTrack } from "@/lib/timeline";
-
-function removeTrackElements<TTrack extends TimelineTrack>({
- track,
- elements,
-}: {
- track: TTrack;
- elements: { trackId: string; elementId: string }[];
-}): TTrack {
- const nextElements = track.elements.filter(
- (element) =>
- !elements.some(
- (target) =>
- target.trackId === track.id && target.elementId === element.id,
- ),
- );
-
- return { ...track, elements: nextElements } as TTrack;
-}
-
-export class DeleteElementsCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly elements: { trackId: string; elementId: string }[];
-
- constructor({
- elements,
- }: {
- elements: { trackId: string; elementId: string }[];
- }) {
- super();
- this.elements = elements;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks: SceneTracks = {
- overlay: this.savedState.overlay.map((track) =>
- removeTrackElements({ track, elements: this.elements }),
- ),
- main: removeTrackElements({
- track: this.savedState.main,
- elements: this.elements,
- }),
- audio: this.savedState.audio.map((track) =>
- removeTrackElements({ track, elements: this.elements }),
- ),
- };
-
- editor.timeline.updateTracks(updatedTracks);
-
- return {
- selection: {
- selectedElements: [],
- selectedKeyframes: [],
- keyframeSelectionAnchor: null,
- selectedMaskPoints: null,
- },
- };
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
deleted file mode 100644
index 902ca341..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks, VisualElement } from "@/lib/timeline";
-import { buildDefaultEffectInstance } from "@/lib/effects";
-
-function addEffectToElement({
- element,
- effectType,
-}: {
- element: VisualElement;
- effectType: string;
-}): VisualElement {
- const instance = buildDefaultEffectInstance({ effectType });
- const currentEffects = element.effects ?? [];
- return { ...element, effects: [...currentEffects, instance] };
-}
-
-export class AddClipEffectCommand extends Command {
- private savedState: SceneTracks | null = null;
- private effectId: string | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectType: string;
-
- constructor({
- trackId,
- elementId,
- effectType,
- }: {
- trackId: string;
- elementId: string;
- effectType: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectType = effectType;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- const updated = addEffectToElement({
- element: element as VisualElement,
- effectType: this.effectType,
- });
- const effects = updated.effects ?? [];
- this.effectId = effects[effects.length - 1]?.id ?? null;
- return updated;
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getEffectId(): string | null {
- return this.effectId;
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts
deleted file mode 100644
index 133ce661..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { AddClipEffectCommand } from "./add-effect";
-export { RemoveClipEffectCommand } from "./remove-effect";
-export { ToggleClipEffectCommand } from "./toggle-effect";
-export { UpdateClipEffectParamsCommand } from "./update-effect-params";
-export { ReorderClipEffectsCommand } from "./reorder-effect";
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
deleted file mode 100644
index f453f2cc..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks, VisualElement } from "@/lib/timeline";
-
-function removeEffectFromElement({
- element,
- effectId,
-}: {
- element: VisualElement;
- effectId: string;
-}): VisualElement {
- const currentEffects = element.effects ?? [];
- const filtered = currentEffects.filter((effect) => effect.id !== effectId);
- return { ...element, effects: filtered };
-}
-
-export class RemoveClipEffectCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectId: string;
-
- constructor({
- trackId,
- elementId,
- effectId,
- }: {
- trackId: string;
- elementId: string;
- effectId: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectId = effectId;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- return removeEffectFromElement({
- element: element as VisualElement,
- effectId: this.effectId,
- });
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
deleted file mode 100644
index 28b7c4e1..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks, VisualElement } from "@/lib/timeline";
-
-function reorderEffectsOnElement({
- element,
- fromIndex,
- toIndex,
-}: {
- element: VisualElement;
- fromIndex: number;
- toIndex: number;
-}): VisualElement {
- const effects = [...(element.effects ?? [])];
- const [moved] = effects.splice(fromIndex, 1);
- effects.splice(toIndex, 0, moved);
- return { ...element, effects };
-}
-
-export class ReorderClipEffectsCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly fromIndex: number;
- private readonly toIndex: number;
-
- constructor({
- trackId,
- elementId,
- fromIndex,
- toIndex,
- }: {
- trackId: string;
- elementId: string;
- fromIndex: number;
- toIndex: number;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.fromIndex = fromIndex;
- this.toIndex = toIndex;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- return reorderEffectsOnElement({
- element: element as VisualElement,
- fromIndex: this.fromIndex,
- toIndex: this.toIndex,
- });
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
deleted file mode 100644
index a703cd94..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks, VisualElement } from "@/lib/timeline";
-
-export function toggleEffectOnElement({
- element,
- effectId,
-}: {
- element: VisualElement;
- effectId: string;
-}): VisualElement {
- const currentEffects = element.effects ?? [];
- const updated = currentEffects.map((effect) =>
- effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
- );
- return { ...element, effects: updated };
-}
-
-export class ToggleClipEffectCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectId: string;
-
- constructor({
- trackId,
- elementId,
- effectId,
- }: {
- trackId: string;
- elementId: string;
- effectId: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectId = effectId;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- return toggleEffectOnElement({
- element: element as VisualElement,
- effectId: this.effectId,
- });
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
deleted file mode 100644
index 5e75c1f0..00000000
--- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { ParamValues } from "@/lib/params";
-import type { SceneTracks, VisualElement } from "@/lib/timeline";
-
-function updateEffectParamsOnElement({
- element,
- effectId,
- params,
-}: {
- element: VisualElement;
- effectId: string;
- params: Partial<ParamValues>;
-}): VisualElement {
- const currentEffects = element.effects ?? [];
- const updated = currentEffects.map((effect) => {
- if (effect.id !== effectId) {
- return effect;
- }
-
- const nextParams = { ...effect.params };
- for (const [key, value] of Object.entries(params)) {
- if (value !== undefined) {
- nextParams[key] = value;
- }
- }
-
- return { ...effect, params: nextParams };
- });
- return { ...element, effects: updated };
-}
-
-export class UpdateClipEffectParamsCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectId: string;
- private readonly params: Partial<ParamValues>;
-
- constructor({
- trackId,
- elementId,
- effectId,
- params,
- }: {
- trackId: string;
- elementId: string;
- effectId: string;
- params: Partial<ParamValues>;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectId = effectId;
- this.params = params;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- return updateEffectParamsOnElement({
- element: element as VisualElement,
- effectId: this.effectId,
- params: this.params,
- });
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
deleted file mode 100644
index ff365801..00000000
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export { InsertElementCommand } from "./insert-element";
-export { DeleteElementsCommand } from "./delete-elements";
-export { DuplicateElementsCommand } from "./duplicate-elements";
-export { SplitElementsCommand } from "./split-elements";
-export { UpdateElementsCommand } from "./update-elements";
-export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
-export { MoveElementCommand } from "./move-elements";
-
-export * from "./keyframes";
-export * from "./effects";
-export * from "./masks";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
deleted file mode 100644
index ed05b8c6..00000000
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ /dev/null
@@ -1,291 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- CreateTimelineElement,
- SceneTracks,
- TimelineElement,
- TrackType,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { requiresMediaId } from "@/lib/timeline/element-utils";
-import type { MediaAsset } from "@/lib/media/types";
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import { floatToFrameRate } from "@/lib/fps/utils";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
-import {
- applyPlacement,
- canElementGoOnTrack,
- resolveTrackPlacement,
- validateElementTrackCompatibility,
-} from "@/lib/timeline/placement";
-
-type InsertElementPlacement =
- | { mode: "explicit"; trackId: string }
- | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
-
-export interface InsertElementParams {
- element: CreateTimelineElement;
- placement: InsertElementPlacement;
-}
-
-export class InsertElementCommand extends Command {
- private elementId: string;
- private savedState: SceneTracks | null = null;
- private targetTrackId: string | null = null;
-
- constructor({ element, placement }: InsertElementParams) {
- super();
- this.elementId = generateUUID();
- this.element = element;
- this.placement = placement;
- }
-
- private element: CreateTimelineElement;
- private placement: InsertElementPlacement;
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- if (!this.validateElementBasics({ element: this.element })) {
- return;
- }
-
- const totalElementsInTimeline =
- this.savedState.main.elements.length +
- this.savedState.overlay.reduce(
- (total, track) => total + track.elements.length,
- 0,
- ) +
- this.savedState.audio.reduce(
- (total, track) => total + track.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
-
- const newElement = this.buildElement({ element: this.element });
- const updateResult = this.applyPlacementResult({
- tracks: this.savedState,
- element: newElement,
- });
-
- if (!updateResult) {
- return;
- }
-
- const { updatedTracks, targetTrackId } = updateResult;
- this.targetTrackId = targetTrackId;
-
- const isVisualMedia =
- newElement.type === "video" || newElement.type === "image";
-
- if (isFirstElement && isVisualMedia) {
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
- const asset = mediaAssets.find(
- (item: MediaAsset) => item.id === newElement.mediaId,
- );
-
- if (asset?.width && asset?.height) {
- const nextCanvasSize = { width: asset.width, height: asset.height };
- const shouldSetOriginalCanvasSize =
- !activeProject?.settings.originalCanvasSize;
- editor.project.updateSettings({
- settings: {
- canvasSize: nextCanvasSize,
- ...(shouldSetOriginalCanvasSize
- ? { originalCanvasSize: nextCanvasSize }
- : {}),
- },
- pushHistory: false,
- });
- }
-
- if (asset?.type === "video" && asset?.fps) {
- editor.project.updateSettings({
- settings: { fps: floatToFrameRate(asset.fps) },
- pushHistory: false,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
-
- return {
- selection: {
- selectedElements: [
- { trackId: targetTrackId, elementId: this.elementId },
- ],
- selectedKeyframes: [],
- keyframeSelectionAnchor: null,
- selectedMaskPoints: null,
- },
- };
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getElementId(): string {
- return this.elementId;
- }
-
- getTrackId(): string | null {
- return this.targetTrackId;
- }
-
- private buildElement({
- element,
- }: {
- element: CreateTimelineElement;
- }): TimelineElement {
- return {
- ...element,
- id: this.elementId,
- startTime: element.startTime,
- trimStart: element.trimStart ?? 0,
- trimEnd: element.trimEnd ?? 0,
- duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
- } as TimelineElement;
- }
-
- private validateElementBasics({
- element,
- }: {
- element: CreateTimelineElement;
- }): boolean {
- if (requiresMediaId({ element }) && !("mediaId" in element)) {
- console.error("Element requires mediaId");
- return false;
- }
-
- if (
- element.type === "audio" &&
- element.sourceType === "library" &&
- !element.sourceUrl
- ) {
- console.error("Library audio element must have sourceUrl");
- return false;
- }
-
- if (element.type === "sticker" && !element.stickerId) {
- console.error("Sticker element must have stickerId");
- return false;
- }
-
- if (element.type === "graphic") {
- registerDefaultGraphics();
- if (
- !element.definitionId ||
- !graphicsRegistry.has(element.definitionId)
- ) {
- console.error("Graphic element must have a valid definitionId");
- return false;
- }
- }
-
- if (element.type === "text" && !element.content) {
- console.error("Text element must have content");
- return false;
- }
-
- if (element.type === "effect" && !element.effectType) {
- console.error("Effect element must have effectType");
- return false;
- }
-
- return true;
- }
-
- private applyPlacementResult({
- tracks,
- element,
- }: {
- tracks: SceneTracks;
- element: TimelineElement;
- }): { updatedTracks: SceneTracks; targetTrackId: string } | null {
- const placement = this.placement;
-
- if (
- placement.mode === "auto" &&
- placement.trackType &&
- !canElementGoOnTrack({
- elementType: element.type,
- trackType: placement.trackType,
- })
- ) {
- console.error(
- `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
- );
- return null;
- }
-
- const placementResult = resolveTrackPlacement({
- tracks,
- ...(placement.mode === "auto" && placement.trackType
- ? { trackType: placement.trackType }
- : { elementType: element.type }),
- timeSpans: [
- {
- startTime: element.startTime,
- duration: element.duration,
- },
- ],
- strategy:
- placement.mode === "explicit"
- ? { type: "explicit", trackId: placement.trackId }
- : { type: "firstAvailable" },
- });
- if (!placementResult) {
- if (placement.mode === "explicit") {
- const targetTrack =
- tracks.main.id === placement.trackId
- ? tracks.main
- : (tracks.overlay.find((track) => track.id === placement.trackId) ??
- tracks.audio.find((track) => track.id === placement.trackId));
- if (!targetTrack) {
- console.error("Track not found:", placement.trackId);
- return null;
- }
-
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
- console.error(validation.errorMessage);
- }
-
- return null;
- }
-
- const elementToPlace =
- placementResult.kind === "existingTrack"
- ? {
- ...element,
- startTime: placementResult.adjustedStartTime ?? element.startTime,
- }
- : element;
-
- const appliedPlacement = applyPlacement({
- tracks,
- placementResult,
- elements: [elementToPlace],
- newTrackInsertIndexOverride:
- placement.mode === "auto" && typeof placement.insertIndex === "number"
- ? placement.insertIndex
- : undefined,
- });
- if (!appliedPlacement) {
- return null;
- }
-
- return {
- updatedTracks: appliedPlacement.updatedTracks,
- targetTrackId: appliedPlacement.targetTrackId,
- };
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
deleted file mode 100644
index 319a827b..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export * from "./remove-effect-param-keyframe";
-export * from "./remove-keyframe";
-export * from "./retime-keyframe";
-export * from "./update-scalar-keyframe-curve";
-export * from "./upsert-effect-param-keyframe";
-export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
deleted file mode 100644
index b1ef4aef..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class RemoveEffectParamKeyframeCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectId: string;
- private readonly paramKey: string;
- private readonly keyframeId: string;
-
- constructor({
- trackId,
- elementId,
- effectId,
- paramKey,
- keyframeId,
- }: {
- trackId: string;
- elementId: string;
- effectId: string;
- paramKey: string;
- keyframeId: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectId = effectId;
- this.paramKey = paramKey;
- this.keyframeId = keyframeId;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- const animations = removeEffectParamKeyframe({
- animations: element.animations,
- effectId: this.effectId,
- paramKey: this.paramKey,
- keyframeId: this.keyframeId,
- });
- return { ...element, animations };
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
deleted file mode 100644
index 3c3d1c6b..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { EditorCore } from "@/core";
-import {
- hasKeyframesForPath,
- removeElementKeyframe,
- resolveAnimationTarget,
-} from "@/lib/animation";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-
-function removeKeyframeAndPersist({
- element,
- propertyPath,
- keyframeId,
- valueAtPlayhead,
-}: {
- element: TimelineElement;
- propertyPath: AnimationPath;
- keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
-}): TimelineElement {
- const target = resolveAnimationTarget({ element, path: propertyPath });
- if (!target) {
- return element;
- }
-
- const nextAnimations = removeElementKeyframe({
- animations: element.animations,
- propertyPath,
- keyframeId,
- });
-
- const isChannelNowEmpty = !hasKeyframesForPath({
- animations: nextAnimations,
- propertyPath,
- });
-
- // When clearing all keyframes, preserve the value at the playhead (what the user was seeing)
- const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null;
-
- const baseElement = shouldPersistToBase
- ? target.setBaseValue({ value: valueAtPlayhead })
- : element;
-
- return { ...baseElement, animations: nextAnimations };
-}
-
-export class RemoveKeyframeCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly propertyPath: AnimationPath;
- private readonly keyframeId: string;
- private readonly valueAtPlayhead: AnimationValue | null;
-
- constructor({
- trackId,
- elementId,
- propertyPath,
- keyframeId,
- valueAtPlayhead,
- }: {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.propertyPath = propertyPath;
- this.keyframeId = keyframeId;
- this.valueAtPlayhead = valueAtPlayhead;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) =>
- removeKeyframeAndPersist({
- element,
- propertyPath: this.propertyPath,
- keyframeId: this.keyframeId,
- valueAtPlayhead: this.valueAtPlayhead,
- }),
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
deleted file mode 100644
index ad7f5651..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { EditorCore } from "@/core";
-import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import type { AnimationPath } from "@/lib/animation/types";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class RetimeKeyframeCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly propertyPath: AnimationPath;
- private readonly keyframeId: string;
- private readonly nextTime: number;
-
- constructor({
- trackId,
- elementId,
- propertyPath,
- keyframeId,
- nextTime,
- }: {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- keyframeId: string;
- nextTime: number;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.propertyPath = propertyPath;
- this.keyframeId = keyframeId;
- this.nextTime = nextTime;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) => {
- if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
- return element;
- }
-
- const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
- return {
- ...element,
- animations: retimeElementKeyframe({
- animations: element.animations,
- propertyPath: this.propertyPath,
- keyframeId: this.keyframeId,
- time: boundedTime,
- }),
- };
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
deleted file mode 100644
index 9a4c2304..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { EditorCore } from "@/core";
-import {
- resolveAnimationTarget,
- updateScalarKeyframeCurve,
-} from "@/lib/animation";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import type {
- AnimationPath,
- ScalarCurveKeyframePatch,
-} from "@/lib/animation/types";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class UpdateScalarKeyframeCurveCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly propertyPath: AnimationPath;
- private readonly componentKey: string;
- private readonly keyframeId: string;
- private readonly patch: ScalarCurveKeyframePatch;
-
- constructor({
- trackId,
- elementId,
- propertyPath,
- componentKey,
- keyframeId,
- patch,
- }: {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- componentKey: string;
- keyframeId: string;
- patch: ScalarCurveKeyframePatch;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.propertyPath = propertyPath;
- this.componentKey = componentKey;
- this.keyframeId = keyframeId;
- this.patch = patch;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) => {
- if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
- return element;
- }
-
- return {
- ...element,
- animations: updateScalarKeyframeCurve({
- animations: element.animations,
- propertyPath: this.propertyPath,
- componentKey: this.componentKey,
- keyframeId: this.keyframeId,
- patch: this.patch,
- }),
- };
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
deleted file mode 100644
index f436721a..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import {
- buildEffectParamPath,
- resolveAnimationTarget,
- upsertPathKeyframe,
-} from "@/lib/animation";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { AnimationInterpolation } from "@/lib/animation/types";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class UpsertEffectParamKeyframeCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly effectId: string;
- private readonly paramKey: string;
- private readonly time: number;
- private readonly value: number | string | boolean;
- private readonly interpolation: AnimationInterpolation | undefined;
- private readonly keyframeId: string | undefined;
-
- constructor({
- trackId,
- elementId,
- effectId,
- paramKey,
- time,
- value,
- interpolation,
- keyframeId,
- }: {
- trackId: string;
- elementId: string;
- effectId: string;
- paramKey: string;
- time: number;
- value: number | string | boolean;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.effectId = effectId;
- this.paramKey = paramKey;
- this.time = time;
- this.value = value;
- this.interpolation = interpolation;
- this.keyframeId = keyframeId;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isVisualElement,
- update: (element) => {
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
- const propertyPath = buildEffectParamPath({
- effectId: this.effectId,
- paramKey: this.paramKey,
- });
- const target = resolveAnimationTarget({
- element,
- path: propertyPath,
- });
- if (!target) {
- return element;
- }
-
- const animations = upsertPathKeyframe({
- animations: element.animations,
- propertyPath,
- time: boundedTime,
- value: this.value,
- interpolation: this.interpolation,
- keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
- coerceValue: target.coerceValue,
- });
- return { ...element, animations };
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
deleted file mode 100644
index f77e69b8..00000000
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
-import { updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks } from "@/lib/timeline";
-import type {
- AnimationPath,
- AnimationInterpolation,
- AnimationValue,
-} from "@/lib/animation/types";
-
-export class UpsertKeyframeCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly propertyPath: AnimationPath;
- private readonly time: number;
- private readonly value: AnimationValue;
- private readonly interpolation: AnimationInterpolation | undefined;
- private readonly keyframeId: string | undefined;
-
- constructor({
- trackId,
- elementId,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
- }: {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.propertyPath = propertyPath;
- this.time = time;
- this.value = value;
- this.interpolation = interpolation;
- this.keyframeId = keyframeId;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) => {
- const target = resolveAnimationTarget({
- element,
- path: this.propertyPath,
- });
- if (!target) {
- return element;
- }
-
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
- return {
- ...element,
- animations: upsertPathKeyframe({
- animations: element.animations,
- propertyPath: this.propertyPath,
- time: boundedTime,
- value: this.value,
- interpolation: this.interpolation,
- keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
- coerceValue: target.coerceValue,
- }),
- };
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts
deleted file mode 100644
index 346eb8d9..00000000
--- a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import {
- getCustomMaskClosedStateAfterPointRemoval,
- removeCustomMaskPoints,
-} from "@/lib/masks/custom-path";
-import type { CustomMask } from "@/lib/masks/types";
-import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { MaskableElement, SceneTracks } from "@/lib/timeline";
-
-function deletePointsFromCustomMask({
- mask,
- pointIds,
-}: {
- mask: CustomMask;
- pointIds: string[];
-}): CustomMask {
- const points = mask.params.path;
- const nextPoints = removeCustomMaskPoints({ points, pointIds });
- if (nextPoints.length === points.length) {
- return mask;
- }
-
- return {
- ...mask,
- params: {
- ...mask.params,
- path: nextPoints,
- closed: getCustomMaskClosedStateAfterPointRemoval({
- wasClosed: mask.params.closed,
- remainingPointCount: nextPoints.length,
- }),
- },
- };
-}
-
-function deletePointsFromElementMask({
- element,
- maskId,
- pointIds,
-}: {
- element: MaskableElement;
- maskId: string;
- pointIds: string[];
-}): { element: MaskableElement; didDeletePoints: boolean } {
- const currentMasks = element.masks ?? [];
- let didDeletePoints = false;
- const nextMasks = currentMasks.map((mask) => {
- if (mask.id !== maskId || mask.type !== "custom") {
- return mask;
- }
-
- const nextMask = deletePointsFromCustomMask({
- mask,
- pointIds,
- });
- didDeletePoints ||= nextMask !== mask;
- return nextMask;
- });
-
- return {
- element: didDeletePoints ? { ...element, masks: nextMasks } : element,
- didDeletePoints,
- };
-}
-
-export class DeleteCustomMaskPointsCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly maskId: string;
- private readonly pointIds: string[];
-
- constructor({
- trackId,
- elementId,
- maskId,
- pointIds,
- }: {
- trackId: string;
- elementId: string;
- maskId: string;
- pointIds: string[];
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.maskId = maskId;
- this.pointIds = pointIds;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- let didDeletePoints = false;
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isMaskableElement,
- update: (element) => {
- const result = deletePointsFromElementMask({
- element: element as MaskableElement,
- maskId: this.maskId,
- pointIds: this.pointIds,
- });
- didDeletePoints ||= result.didDeletePoints;
- return result.element;
- },
- });
-
- if (didDeletePoints) {
- editor.timeline.updateTracks(updatedTracks);
- return {
- selection: {
- selectedMaskPoints: null,
- },
- };
- }
-
- return undefined;
- }
-
- 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/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts
deleted file mode 100644
index dd6373b1..00000000
--- a/apps/web/src/lib/commands/timeline/element/masks/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
-export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
-export { RemoveMaskCommand } from "./remove-mask";
-export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts
deleted file mode 100644
index 9f837836..00000000
--- a/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { insertPointOnCustomMaskSegment } from "@/lib/masks/definitions/custom";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { CustomMask } from "@/lib/masks/types";
-import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { MaskableElement, SceneTracks } from "@/lib/timeline";
-
-function insertPointIntoCustomMask({
- mask,
- segmentIndex,
- canvasPoint,
- bounds,
-}: {
- mask: CustomMask;
- segmentIndex: number;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
-}): { mask: CustomMask; insertedPointId: string | null } {
- const result = insertPointOnCustomMaskSegment({
- params: mask.params,
- segmentIndex,
- canvasPoint,
- bounds,
- });
- if (!result) {
- return {
- mask,
- insertedPointId: null,
- };
- }
-
- return {
- mask: {
- ...mask,
- params: result.params,
- },
- insertedPointId: result.pointId,
- };
-}
-
-function insertPointIntoElementMask({
- element,
- maskId,
- segmentIndex,
- canvasPoint,
- bounds,
-}: {
- element: MaskableElement;
- maskId: string;
- segmentIndex: number;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
-}): {
- element: MaskableElement;
- didInsertPoint: boolean;
- insertedPointId: string | null;
-} {
- const currentMasks = element.masks ?? [];
- let insertedPointId: string | null = null;
- let didInsertPoint = false;
-
- const nextMasks = currentMasks.map((mask) => {
- if (mask.id !== maskId || mask.type !== "custom") {
- return mask;
- }
-
- const result = insertPointIntoCustomMask({
- mask,
- segmentIndex,
- canvasPoint,
- bounds,
- });
- if (result.insertedPointId) {
- insertedPointId = result.insertedPointId;
- didInsertPoint = true;
- }
- return result.mask;
- });
-
- return {
- element: didInsertPoint ? { ...element, masks: nextMasks } : element,
- didInsertPoint,
- insertedPointId,
- };
-}
-
-export class InsertCustomMaskPointCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly trackId: string;
- private readonly elementId: string;
- private readonly maskId: string;
- private readonly segmentIndex: number;
- private readonly canvasPoint: { x: number; y: number };
- private readonly bounds: ElementBounds;
-
- constructor({
- trackId,
- elementId,
- maskId,
- segmentIndex,
- canvasPoint,
- bounds,
- }: {
- trackId: string;
- elementId: string;
- maskId: string;
- segmentIndex: number;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.maskId = maskId;
- this.segmentIndex = segmentIndex;
- this.canvasPoint = canvasPoint;
- this.bounds = bounds;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- let didInsertPoint = false;
- let insertedPointId: string | null = null;
- const updatedTracks = updateElementInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- elementPredicate: isMaskableElement,
- update: (element) => {
- const result = insertPointIntoElementMask({
- element: element as MaskableElement,
- maskId: this.maskId,
- segmentIndex: this.segmentIndex,
- canvasPoint: this.canvasPoint,
- bounds: this.bounds,
- });
- didInsertPoint ||= result.didInsertPoint;
- insertedPointId ??= result.insertedPointId;
- return result.element;
- },
- });
-
- if (!didInsertPoint || !insertedPointId) {
- return undefined;
- }
-
- editor.timeline.updateTracks(updatedTracks);
- return {
- selection: {
- selectedMaskPoints: {
- trackId: this.trackId,
- elementId: this.elementId,
- maskId: this.maskId,
- pointIds: [insertedPointId],
- },
- },
- };
- }
-
- 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/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
deleted file mode 100644
index 9f6c5f77..00000000
--- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { SceneTracks, 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: SceneTracks | 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(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- 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);
- return undefined;
- }
-
- 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
deleted file mode 100644
index 2b5c1694..00000000
--- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
-import type { Mask } from "@/lib/masks/types";
-import type { SceneTracks, MaskableElement } from "@/lib/timeline";
-
-export function toggleMaskInvertedOnElement({
- element,
- maskId,
-}: {
- element: MaskableElement;
- maskId: string;
-}): MaskableElement {
- const currentMasks = element.masks ?? [];
- const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({
- ...mask,
- params: {
- ...mask.params,
- inverted: !mask.params.inverted,
- },
- });
- const updatedMasks = currentMasks.map((mask) =>
- mask.id !== maskId ? mask : toggleMask(mask),
- );
-
- return { ...element, masks: updatedMasks };
-}
-
-export class ToggleMaskInvertedCommand extends Command {
- private savedState: SceneTracks | 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(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const updatedTracks = updateElementInSceneTracks({
- 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);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
deleted file mode 100644
index 7300f540..00000000
--- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import {
- buildSeparatedAudioElement,
- canExtractSourceAudio,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import {
- applyPlacement,
- resolveTrackPlacement,
-} from "@/lib/timeline/placement";
-import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update";
-import type {
- SceneTracks,
- TimelineElement,
- VideoElement,
-} from "@/lib/timeline/types";
-import { generateUUID } from "@/utils/id";
-
-export class ToggleSourceAudioSeparationCommand extends Command {
- private savedState: SceneTracks | null = null;
-
- constructor(
- private readonly params: {
- trackId: string;
- elementId: string;
- },
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const sourceTrack = [
- ...this.savedState.overlay,
- this.savedState.main,
- ...this.savedState.audio,
- ].find((track) => track.id === this.params.trackId);
- if (!sourceTrack) {
- return;
- }
- const sourceElement = sourceTrack.elements.find(
- (element) => element.id === this.params.elementId,
- ) as TimelineElement | undefined;
- if (!sourceElement || sourceElement.type !== "video") {
- return;
- }
- const videoElement: VideoElement = sourceElement;
-
- if (isSourceAudioSeparated({ element: videoElement })) {
- editor.timeline.updateTracks(
- updateSourceAudioEnabled({
- tracks: this.savedState,
- trackId: this.params.trackId,
- elementId: this.params.elementId,
- isSourceAudioEnabled: true,
- }),
- );
- return;
- }
-
- const mediaAsset = editor.media
- .getAssets()
- .find((asset) => asset.id === videoElement.mediaId);
- if (!canExtractSourceAudio(videoElement, mediaAsset)) {
- return;
- }
- if (videoElement.duration <= 0) {
- return;
- }
-
- const separatedAudioElement = {
- ...buildSeparatedAudioElement({
- sourceElement: videoElement,
- }),
- id: generateUUID(),
- };
- const placementResult = resolveTrackPlacement({
- tracks: this.savedState,
- trackType: "audio",
- timeSpans: [
- {
- startTime: separatedAudioElement.startTime,
- duration: separatedAudioElement.duration,
- },
- ],
- strategy: { type: "firstAvailable" },
- });
- if (!placementResult) {
- return;
- }
- const appliedPlacement = applyPlacement({
- tracks: this.savedState,
- placementResult,
- elements: [separatedAudioElement],
- });
- if (!appliedPlacement) {
- return;
- }
-
- editor.timeline.updateTracks(
- updateSourceAudioEnabled({
- tracks: appliedPlacement.updatedTracks,
- trackId: this.params.trackId,
- elementId: this.params.elementId,
- isSourceAudioEnabled: false,
- }),
- );
- }
-
- undo(): void {
- if (!this.savedState) {
- return;
- }
-
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
-}
-
-function updateSourceAudioEnabled({
- tracks,
- trackId,
- elementId,
- isSourceAudioEnabled,
-}: {
- tracks: SceneTracks;
- trackId: string;
- elementId: string;
- isSourceAudioEnabled: boolean;
-}): SceneTracks {
- return updateElementInSceneTracks({
- tracks,
- trackId,
- elementId,
- elementPredicate: (element): element is VideoElement =>
- element.type === "video",
- update: (element) => ({
- ...element,
- isSourceAudioEnabled,
- }),
- });
-}
diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts
deleted file mode 100644
index 54d1ca88..00000000
--- a/apps/web/src/lib/commands/timeline/element/update-elements.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { EditorCore } from "@/core";
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-import {
- findTrackInSceneTracks,
- updateElementInSceneTracks,
-} from "@/lib/timeline";
-import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
-
-export class UpdateElementsCommand extends Command {
- private savedState: SceneTracks | null = null;
- private readonly updates: Array<{
- trackId: string;
- elementId: string;
- patch: Partial<TimelineElement>;
- }>;
-
- constructor({
- updates,
- }: {
- updates: Array<{
- trackId: string;
- elementId: string;
- patch: Partial<TimelineElement>;
- }>;
- }) {
- super();
- this.updates = updates;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
- let updatedTracks = this.savedState;
-
- for (const updateEntry of this.updates) {
- const currentTrack = findTrackInSceneTracks({
- tracks: updatedTracks,
- trackId: updateEntry.trackId,
- });
- const currentElement = currentTrack?.elements.find(
- (element) => element.id === updateEntry.elementId,
- );
- if (!currentTrack || !currentElement) {
- continue;
- }
-
- const nextElement = applyElementUpdate({
- element: currentElement,
- patch: updateEntry.patch,
- context: {
- tracks: updatedTracks,
- trackId: updateEntry.trackId,
- },
- });
-
- updatedTracks = updateElementInSceneTracks({
- tracks: updatedTracks,
- trackId: updateEntry.trackId,
- elementId: updateEntry.elementId,
- update: () => nextElement,
- });
- }
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts
deleted file mode 100644
index cb285d94..00000000
--- a/apps/web/src/lib/commands/timeline/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from "./track";
-export * from "./element";
-export * from "./clipboard";
-
-export { TracksSnapshotCommand } from "./tracks-snapshot";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
deleted file mode 100644
index a577c693..00000000
--- a/apps/web/src/lib/commands/timeline/track/add-track.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks, TrackType } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { EditorCore } from "@/core";
-import {
- buildEmptyTrack,
- getDefaultInsertIndexForTrack,
-} from "@/lib/timeline/placement";
-
-export class AddTrackCommand extends Command {
- private trackId: string;
- private savedState: SceneTracks | null = null;
-
- constructor(
- private type: TrackType,
- private index?: number,
- ) {
- super();
- this.trackId = generateUUID();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const insertIndex =
- this.index ??
- getDefaultInsertIndexForTrack({
- tracks: this.savedState,
- trackType: this.type,
- });
-
- const updatedTracks =
- this.type === "audio"
- ? buildAudioTrackState({
- tracks: this.savedState,
- insertIndex,
- trackId: this.trackId,
- })
- : buildOverlayTrackState({
- tracks: this.savedState,
- insertIndex,
- trackId: this.trackId,
- trackType: this.type,
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getTrackId(): string {
- return this.trackId;
- }
-}
-
-function buildAudioTrackState({
- tracks,
- insertIndex,
- trackId,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- trackId: string;
-}): SceneTracks {
- const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1);
- const newTrack = buildEmptyTrack({
- id: trackId,
- type: "audio",
- });
- return {
- ...tracks,
- audio: [
- ...tracks.audio.slice(0, audioInsertIndex),
- newTrack,
- ...tracks.audio.slice(audioInsertIndex),
- ],
- };
-}
-
-function buildOverlayTrackState({
- tracks,
- insertIndex,
- trackId,
- trackType,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- trackId: string;
- trackType: Exclude<TrackType, "audio">;
-}): SceneTracks {
- const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length);
- const newTrack =
- trackType === "video"
- ? buildEmptyTrack({ id: trackId, type: "video" })
- : trackType === "text"
- ? buildEmptyTrack({ id: trackId, type: "text" })
- : trackType === "graphic"
- ? buildEmptyTrack({ id: trackId, type: "graphic" })
- : buildEmptyTrack({ id: trackId, type: "effect" });
- return {
- ...tracks,
- overlay: [
- ...tracks.overlay.slice(0, overlayInsertIndex),
- newTrack,
- ...tracks.overlay.slice(overlayInsertIndex),
- ],
- };
-}
diff --git a/apps/web/src/lib/commands/timeline/track/index.ts b/apps/web/src/lib/commands/timeline/track/index.ts
deleted file mode 100644
index 9d4bd9c3..00000000
--- a/apps/web/src/lib/commands/timeline/track/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { AddTrackCommand } from "./add-track";
-export { RemoveTrackCommand } from "./remove-track";
-export { ToggleTrackMuteCommand } from "./toggle-track-mute";
-export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility";
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts
deleted file mode 100644
index 5fcaa898..00000000
--- a/apps/web/src/lib/commands/timeline/track/remove-track.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type { SceneTracks } from "@/lib/timeline";
-
-export class RemoveTrackCommand extends Command {
- private savedState: SceneTracks | null = null;
-
- constructor(private trackId: string) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
- const updatedTracks: SceneTracks = {
- ...this.savedState,
- overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId),
- audio: this.savedState.audio.filter((track) => track.id !== this.trackId),
- };
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
deleted file mode 100644
index 7ffe2fd7..00000000
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
-
-export class ToggleTrackMuteCommand extends Command {
- private savedState: SceneTracks | null = null;
-
- constructor(private trackId: string) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const targetTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- });
- if (!targetTrack) {
- return;
- }
-
- const updatedTracks = updateTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- update: (track) =>
- canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track,
- });
-
- editor.timeline.updateTracks(updatedTracks);
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
deleted file mode 100644
index 2292c83a..00000000
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
-
-export class ToggleTrackVisibilityCommand extends Command {
- private savedState: SceneTracks | null = null;
-
- constructor(private trackId: string) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.scenes.getActiveScene().tracks;
-
- const targetTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- });
- if (!targetTrack) {
- return;
- }
-
- const updatedTracks = updateTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- update: (track) => {
- if (canTrackBeHidden(track)) {
- return { ...track, hidden: !track.hidden };
- }
- return track;
- },
- });
-
- editor.timeline.updateTracks(updatedTracks);
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
deleted file mode 100644
index daed6a9e..00000000
--- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { SceneTracks } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-
-export class TracksSnapshotCommand extends Command {
- constructor(
- private before: SceneTracks,
- private after: SceneTracks,
- ) {
- super();
- }
-
- execute(): CommandResult | undefined {
- EditorCore.getInstance().timeline.updateTracks(this.after);
- return undefined;
- }
-
- undo(): void {
- EditorCore.getInstance().timeline.updateTracks(this.before);
- }
-}
diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts
deleted file mode 100644
index b1ffd119..00000000
--- a/apps/web/src/lib/db/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { drizzle } from "drizzle-orm/postgres-js";
-import postgres from "postgres";
-import * as schema from "./schema";
-import { webEnv } from "@/lib/env/web";
-
-let _db: ReturnType<typeof drizzle> | null = null;
-
-function getDb() {
- if (!_db) {
- const client = postgres(webEnv.DATABASE_URL);
- _db = drizzle(client, { schema });
- }
-
- return _db;
-}
-
-export const db = getDb();
-
-export * from "./schema";
diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts
deleted file mode 100644
index 8f8be248..00000000
--- a/apps/web/src/lib/db/schema.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
-
-export const users = pgTable("users", {
- id: text("id").primaryKey(),
-
- // todo: implement fully anonymous sign-in for privacy
- // we don't have any auth flows currently so this is fine for now
- name: text("name").notNull(),
- email: text("email").notNull().unique(),
- emailVerified: boolean("email_verified").default(false).notNull(),
- image: text("image"),
- createdAt: timestamp("created_at")
- .$defaultFn(() => /* @__PURE__ */ new Date())
- .notNull(),
- updatedAt: timestamp("updated_at")
- .$defaultFn(() => /* @__PURE__ */ new Date())
- .notNull(),
-}).enableRLS();
-
-export const sessions = pgTable("sessions", {
- id: text("id").primaryKey(),
- expiresAt: timestamp("expires_at").notNull(),
- token: text("token").notNull().unique(),
- createdAt: timestamp("created_at").notNull(),
- updatedAt: timestamp("updated_at").notNull(),
- ipAddress: text("ip_address"),
- userAgent: text("user_agent"),
- userId: text("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
-}).enableRLS();
-
-export const accounts = pgTable("accounts", {
- id: text("id").primaryKey(),
- accountId: text("account_id").notNull(),
- providerId: text("provider_id").notNull(),
- userId: text("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- accessToken: text("access_token"),
- refreshToken: text("refresh_token"),
- idToken: text("id_token"),
- accessTokenExpiresAt: timestamp("access_token_expires_at"),
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
- scope: text("scope"),
- password: text("password"),
- createdAt: timestamp("created_at").notNull(),
- updatedAt: timestamp("updated_at").notNull(),
-}).enableRLS();
-
-export const feedback = pgTable("feedback", {
- id: text("id").primaryKey(),
- message: text("message").notNull(),
- createdAt: timestamp("created_at")
- .$defaultFn(() => new Date())
- .notNull(),
-});
-
-export const verifications = pgTable("verifications", {
- id: text("id").primaryKey(),
- identifier: text("identifier").notNull(),
- value: text("value").notNull(),
- expiresAt: timestamp("expires_at").notNull(),
- createdAt: timestamp("created_at").$defaultFn(
- () => /* @__PURE__ */ new Date(),
- ),
- updatedAt: timestamp("updated_at").$defaultFn(
- () => /* @__PURE__ */ new Date(),
- ),
-}).enableRLS();
diff --git a/apps/web/src/lib/diagnostics/types.ts b/apps/web/src/lib/diagnostics/types.ts
deleted file mode 100644
index 486f362c..00000000
--- a/apps/web/src/lib/diagnostics/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export type DiagnosticSeverity = "caution" | "error";
-
-export interface DiagnosticDefinition {
- id: string;
- scope: string;
- severity: DiagnosticSeverity;
- message: string;
-}
diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts
deleted file mode 100644
index 5cbcfbe0..00000000
--- a/apps/web/src/lib/drag-data.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { TimelineDragData } from "@/lib/timeline/drag";
-
-const MIME_TYPE = "application/x-timeline-drag";
-let lastDragData: TimelineDragData | null = null;
-
-export function setDragData({
- dataTransfer,
- dragData,
-}: {
- dataTransfer: DataTransfer;
- dragData: TimelineDragData;
-}): void {
- dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
- dataTransfer.setData("text/plain", JSON.stringify(dragData));
- lastDragData = dragData;
-}
-
-export function getDragData({
- dataTransfer,
-}: {
- dataTransfer: DataTransfer;
-}): TimelineDragData | null {
- const data = dataTransfer.getData(MIME_TYPE);
- if (data) return JSON.parse(data) as TimelineDragData;
-
- const textData = dataTransfer.getData("text/plain");
- if (textData) {
- try {
- return JSON.parse(textData) as TimelineDragData;
- } catch {
- return lastDragData;
- }
- }
-
- return lastDragData;
-}
-
-export function hasDragData({
- dataTransfer,
-}: {
- dataTransfer: DataTransfer;
-}): boolean {
- return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
-}
-
-export function clearDragData(): void {
- lastDragData = null;
-}
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
deleted file mode 100644
index b464db3a..00000000
--- a/apps/web/src/lib/effects/definitions/blur.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import type { EffectDefinition, EffectPass } from "@/lib/effects/types";
-
-export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
-
-const MAX_SINGLE_PASS_SIGMA = 10;
-const MAX_STEP = 4;
-const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
-const MAX_ITERATIONS = 8;
-
-export function buildGaussianBlurPasses({
- sigmaX,
- sigmaY,
-}: {
- sigmaX: number;
- sigmaY: number;
-}): EffectPass[] {
- 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: EffectPass[] = [];
- for (let i = 0; i < iterations; i++) {
- passes.push({
- shader: GAUSSIAN_BLUR_SHADER,
- uniforms: {
- u_sigma: perPassSigmaX,
- u_step: stepX,
- u_direction: [1, 0],
- },
- });
- passes.push({
- shader: GAUSSIAN_BLUR_SHADER,
- uniforms: {
- u_sigma: perPassSigmaY,
- u_step: stepY,
- u_direction: [0, 1],
- },
- });
- }
- return passes;
-}
-
-export const INTENSITY_TO_SIGMA_DIVISOR = 5;
-
-export function intensityToSigma({ intensity, resolution, reference }: { intensity: number; resolution: number; reference: number }): number {
- return (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference);
-}
-
-function parseIntensity(effectParams: Record<string, unknown>): number {
- const raw = effectParams.intensity;
- return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
-}
-
-export const blurEffectDefinition: EffectDefinition = {
- type: "blur",
- name: "Blur",
- keywords: ["blur", "soft", "defocus"],
- params: [
- {
- key: "intensity",
- label: "Intensity",
- type: "number",
- default: 15,
- min: 0,
- max: 100,
- step: 1,
- },
- ],
- renderer: {
- passes: [
- {
- shader: GAUSSIAN_BLUR_SHADER,
- uniforms: ({ effectParams, width }) => ({
- u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: width, reference: 1920 }), 0.001),
- u_step: 1,
- u_direction: [1, 0],
- }),
- },
- {
- shader: GAUSSIAN_BLUR_SHADER,
- uniforms: ({ effectParams, height }) => ({
- u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: height, reference: 1080 }), 0.001),
- u_step: 1,
- u_direction: [0, 1],
- }),
- },
- ],
- buildPasses: ({ effectParams, width, height }) => {
- const intensity = parseIntensity(effectParams);
- return buildGaussianBlurPasses({
- sigmaX: intensityToSigma({ intensity, resolution: width, reference: 1920 }),
- sigmaY: intensityToSigma({ intensity, resolution: height, reference: 1080 }),
- });
- },
- },
-};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
deleted file mode 100644
index edc8c5e2..00000000
--- a/apps/web/src/lib/effects/definitions/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { effectsRegistry } from "../registry";
-import { blurEffectDefinition } from "./blur";
-
-const defaultEffects = [blurEffectDefinition];
-
-export function registerDefaultEffects(): void {
- for (const definition of defaultEffects) {
- if (effectsRegistry.has(definition.type)) {
- continue;
- }
- effectsRegistry.register(definition.type, definition);
- }
-}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
deleted file mode 100644
index 92484ee2..00000000
--- a/apps/web/src/lib/effects/index.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { generateUUID } from "@/utils/id";
-import { buildDefaultParamValues } from "@/lib/registry";
-import { effectsRegistry } from "./registry";
-import type { ParamValues } from "@/lib/params";
-import type { Effect, EffectDefinition, EffectPass } from "@/lib/effects/types";
-import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
-
-export { effectsRegistry } from "./registry";
-export { registerDefaultEffects } from "./definitions";
-
-export function resolveEffectPasses({
- definition,
- effectParams,
- width,
- height,
-}: {
- definition: EffectDefinition;
- effectParams: ParamValues;
- width: number;
- height: number;
-}): EffectPass[] {
- if (definition.renderer.buildPasses) {
- return definition.renderer.buildPasses({ effectParams, width, height });
- }
- return definition.renderer.passes.map((pass) => ({
- shader: pass.shader,
- uniforms: pass.uniforms({ effectParams, width, height }),
- }));
-}
-
-export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES;
-
-export function buildDefaultEffectInstance({
- effectType,
-}: {
- effectType: string;
-}): Effect {
- const definition = effectsRegistry.get(effectType);
- const params: ParamValues = buildDefaultParamValues(definition.params);
-
- return {
- id: generateUUID(),
- type: effectType,
- params,
- enabled: true,
- };
-}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
deleted file mode 100644
index 29cc3202..00000000
--- a/apps/web/src/lib/effects/registry.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { DefinitionRegistry } from "@/lib/registry";
-import type { EffectDefinition } from "@/lib/effects/types";
-
-export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> {
- constructor() {
- super("effect");
- }
-}
-
-export const effectsRegistry = new EffectsRegistry();
diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts
deleted file mode 100644
index 1862b440..00000000
--- a/apps/web/src/lib/effects/types.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { ParamDefinition, ParamValues } from "@/lib/params";
-
-export interface Effect {
- id: string;
- type: string;
- params: ParamValues;
- enabled: boolean;
-}
-
-export type EffectUniformValue = number | number[];
-
-export interface EffectPass {
- shader: string;
- uniforms: Record<string, EffectUniformValue>;
-}
-
-export interface EffectPassTemplate {
- shader: string;
- uniforms(params: {
- effectParams: ParamValues;
- width: number;
- height: number;
- }): Record<string, EffectUniformValue>;
-}
-
-export interface EffectRendererConfig {
- passes: EffectPassTemplate[];
- buildPasses?: (params: {
- effectParams: ParamValues;
- width: number;
- height: number;
- }) => EffectPass[];
-}
-
-export interface EffectDefinition {
- type: string;
- name: string;
- keywords: string[];
- params: ParamDefinition[];
- renderer: EffectRendererConfig;
-}
diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts
deleted file mode 100644
index aa051615..00000000
--- a/apps/web/src/lib/env/web.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { z } from "zod";
-
-const webEnvSchema = z.object({
- // Node
- NODE_ENV: z.enum(["development", "production", "test"]),
- ANALYZE: z.string().optional(),
- NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
-
- // Public
- NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
- NEXT_PUBLIC_MARBLE_API_URL: z.url(),
-
- // Server
- DATABASE_URL: z.string().refine(
- (url) =>
- url.startsWith("postgres://") || url.startsWith("postgresql://"),
- "DATABASE_URL must be a postgres:// or postgresql:// URL",
- ),
-
- BETTER_AUTH_SECRET: z.string(),
- UPSTASH_REDIS_REST_URL: z.url(),
- UPSTASH_REDIS_REST_TOKEN: z.string(),
- MARBLE_WORKSPACE_KEY: z.string(),
- FREESOUND_CLIENT_ID: z.string(),
- FREESOUND_API_KEY: z.string(),
-});
-
-export type WebEnv = z.infer<typeof webEnvSchema>;
-
-export const webEnv = webEnvSchema.parse(process.env);
diff --git a/apps/web/src/lib/export/defaults.ts b/apps/web/src/lib/export/defaults.ts
deleted file mode 100644
index 0247f8a8..00000000
--- a/apps/web/src/lib/export/defaults.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { ExportOptions } from "./index";
-
-export const DEFAULT_EXPORT_OPTIONS = {
- format: "mp4",
- quality: "high",
- includeAudio: true,
-} satisfies ExportOptions;
diff --git a/apps/web/src/lib/export/index.ts b/apps/web/src/lib/export/index.ts
deleted file mode 100644
index c5c522b3..00000000
--- a/apps/web/src/lib/export/index.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-import { EXPORT_MIME_TYPES } from "./mime-types";
-
-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?: FrameRate;
- 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,
-}: {
- format: ExportFormat;
-}): string {
- return EXPORT_MIME_TYPES[format];
-}
-
-export function getExportFileExtension({
- format,
-}: {
- format: ExportFormat;
-}): string {
- return `.${format}`;
-}
-
-export function downloadBuffer({
- buffer,
- filename,
- mimeType,
-}: {
- buffer: ArrayBuffer;
- filename: string;
- mimeType: string;
-}): void {
- const blob = new Blob([buffer], { type: mimeType });
- const url = URL.createObjectURL(blob);
- const downloadLink = document.createElement("a");
- downloadLink.href = url;
- downloadLink.download = filename;
- document.body.appendChild(downloadLink);
- downloadLink.click();
- document.body.removeChild(downloadLink);
- URL.revokeObjectURL(url);
-}
diff --git a/apps/web/src/lib/export/mime-types.ts b/apps/web/src/lib/export/mime-types.ts
deleted file mode 100644
index b98900bf..00000000
--- a/apps/web/src/lib/export/mime-types.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const EXPORT_MIME_TYPES = {
- webm: "video/webm",
- mp4: "video/mp4",
-} as const;
diff --git a/apps/web/src/lib/feedback/components/feedback-popover.tsx b/apps/web/src/lib/feedback/components/feedback-popover.tsx
deleted file mode 100644
index f94b7253..00000000
--- a/apps/web/src/lib/feedback/components/feedback-popover.tsx
+++ /dev/null
@@ -1,242 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { ClockIcon } from "lucide-react";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Form,
- FormField,
- FormItem,
- FormControl,
- clearFormDraft,
-} from "@/components/ui/form";
-import type { FeedbackEntry } from "../types";
-
-const PERSIST_KEY = "feedback-draft";
-const HISTORY_KEY = "feedback-history";
-const MAX_HISTORY = 20;
-
-interface FeedbackFormValues {
- message: string;
-}
-
-function readHistory(): FeedbackEntry[] {
- try {
- const stored = localStorage.getItem(HISTORY_KEY);
- return stored ? (JSON.parse(stored) as FeedbackEntry[]) : [];
- } catch {
- return [];
- }
-}
-
-function writeHistory({ entries }: { entries: FeedbackEntry[] }): void {
- try {
- localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
- } catch {
- // localStorage may be full or unavailable
- }
-}
-
-function useFeedback() {
- const [entries, setEntries] = useState<FeedbackEntry[]>(readHistory);
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- async function submit({
- values,
- onSuccess,
- }: {
- values: FeedbackFormValues;
- onSuccess: () => void;
- }) {
- if (isSubmitting) return;
- setIsSubmitting(true);
-
- try {
- const res = await fetch("/api/feedback", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(values),
- });
-
- if (!res.ok) {
- const data = await res.json().catch(() => null);
- throw new Error(data?.error ?? "Failed to submit");
- }
-
- const { entry } = await res.json();
- const next = [entry, ...entries].slice(0, MAX_HISTORY);
- setEntries(next);
- writeHistory({ entries: next });
- onSuccess();
- toast.success("Feedback sent");
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : "Failed to send feedback",
- );
- } finally {
- setIsSubmitting(false);
- }
- }
-
- return { entries, isSubmitting, submit };
-}
-
-export function FeedbackPopover() {
- const [open, setOpen] = useState(false);
-
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button variant="outline" className="h-8">
- Send feedback
- </Button>
- </PopoverTrigger>
- <PopoverContent align="end" className="w-80 p-0">
- <FeedbackPopoverContent onClose={() => setOpen(false)} />
- </PopoverContent>
- </Popover>
- );
-}
-
-type View = "compose" | "history";
-
-function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
- const { entries, isSubmitting, submit } = useFeedback();
- const [view, setView] = useState<View>("compose");
-
- const form = useForm<FeedbackFormValues>({
- defaultValues: { message: "" },
- });
-
- async function handleSubmit(values: FeedbackFormValues) {
- await submit({
- values,
- onSuccess: () => {
- form.reset({ message: "" });
- clearFormDraft({ key: PERSIST_KEY });
- onClose();
- },
- });
- }
-
- if (view === "history") {
- return (
- <div className="flex flex-col">
- <div
- className="max-h-72 overflow-y-auto divide-y"
- style={{
- maskImage:
- "linear-gradient(to bottom, black 80%, transparent 100%)",
- }}
- >
- {entries.map((entry) => (
- <FeedbackEntryItem key={entry.id} entry={entry} />
- ))}
- </div>
- <div className="border-t px-3 py-2">
- <button
- type="button"
- onClick={() => setView("compose")}
- className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
- >
- ← Back
- </button>
- </div>
- </div>
- );
- }
-
- return (
- <div className="flex flex-col">
- <Form persistKey={PERSIST_KEY} {...form}>
- <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col">
- <FormField
- control={form.control}
- name="message"
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Textarea
- placeholder="Thoughts, bugs, ideas..."
- className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none"
- {...field}
- />
- </FormControl>
- </FormItem>
- )}
- />
- <div className="flex items-center justify-between border-t px-3 py-2">
- {entries.length > 0 ? (
- <button
- type="button"
- onClick={() => setView("history")}
- className="flex items-center gap-1.5 text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
- >
- <ClockIcon className="size-3" />
- {entries.length}
- </button>
- ) : (
- <span />
- )}
- <div className="flex gap-2">
- {!form.watch("message").trim() && (
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={onClose}
- >
- Cancel
- </Button>
- )}
- <Button
- type="submit"
- size="sm"
- disabled={isSubmitting || !form.watch("message").trim()}
- >
- {isSubmitting ? <Spinner /> : "Send"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- );
-}
-
-function relativeDate(iso: string): string {
- const diff = Date.now() - new Date(iso).getTime();
- const mins = Math.floor(diff / 60_000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hrs = Math.floor(mins / 60);
- if (hrs < 24) return `${hrs}h ago`;
- const days = Math.floor(hrs / 24);
- if (days < 7) return `${days}d ago`;
- return new Date(iso).toLocaleDateString(undefined, {
- month: "short",
- day: "numeric",
- });
-}
-
-function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
- return (
- <div className="px-3 py-2.5">
- <p className="text-sm text-muted-foreground leading-snug whitespace-pre-wrap break-words">
- {entry.message}
- </p>
- <span className="mt-1 block text-[11px] text-muted-foreground/50">
- {relativeDate(entry.createdAt)}
- </span>
- </div>
- );
-}
diff --git a/apps/web/src/lib/feedback/index.ts b/apps/web/src/lib/feedback/index.ts
deleted file mode 100644
index dfd0743c..00000000
--- a/apps/web/src/lib/feedback/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { submitFeedback } from "./queries";
-export { MAX_MESSAGE_LENGTH } from "./types";
-export type { FeedbackEntry, SubmitFeedbackInput } from "./types";
diff --git a/apps/web/src/lib/feedback/queries.ts b/apps/web/src/lib/feedback/queries.ts
deleted file mode 100644
index 6962e71f..00000000
--- a/apps/web/src/lib/feedback/queries.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { db, feedback } from "@/lib/db";
-import { generateUUID } from "@/utils/id";
-import type { FeedbackEntry, SubmitFeedbackInput } from "./types";
-
-export async function submitFeedback({
- message,
-}: SubmitFeedbackInput): Promise<FeedbackEntry> {
- const id = generateUUID();
- const now = new Date();
-
- await db.insert(feedback).values({ id, message, createdAt: now });
-
- return { id, message, createdAt: now.toISOString() };
-}
diff --git a/apps/web/src/lib/feedback/types.ts b/apps/web/src/lib/feedback/types.ts
deleted file mode 100644
index 358b1354..00000000
--- a/apps/web/src/lib/feedback/types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const MAX_MESSAGE_LENGTH = 5000;
-
-export interface FeedbackEntry {
- id: string;
- message: string;
- createdAt: string;
-}
-
-export interface SubmitFeedbackInput {
- message: string;
-}
diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts
deleted file mode 100644
index 8ac55a08..00000000
--- a/apps/web/src/lib/fonts/google-fonts.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { FontAtlas } from "@/lib/fonts/types";
-import { SYSTEM_FONTS } from "@/lib/fonts/system-fonts";
-
-const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
-const FONT_ATLAS_PATH = "/fonts/font-atlas.json";
-const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-";
-
-const fullLoaded = new Set<string>();
-
-let cachedAtlas: FontAtlas | null = null;
-let atlasFetchPromise: Promise<FontAtlas | null> | null = null;
-
-function encodeGoogleFontsFamily(family: string): string {
- return family.replace(/ /g, "+");
-}
-
-export function getCachedFontAtlas(): FontAtlas | null {
- return cachedAtlas;
-}
-
-export function clearFontAtlasCache(): void {
- cachedAtlas = null;
- atlasFetchPromise = null;
- fullLoaded.clear();
-}
-
-export function loadFontAtlas(): Promise<FontAtlas | null> {
- if (cachedAtlas) return Promise.resolve(cachedAtlas);
- if (atlasFetchPromise) return atlasFetchPromise;
-
- 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);
-
- return atlasFetchPromise;
-}
-
-function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
- const maxChunk = Math.max(
- ...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 = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`;
- }
-}
-
-export async function loadFullFont({
- family,
- weights = [400, 700],
-}: {
- family: string;
- weights?: number[];
-}): Promise<void> {
- if (fullLoaded.has(family)) return;
-
- const url = `${GOOGLE_FONTS_CSS}?family=${encodeGoogleFontsFamily(family)}:wght@${weights.join(";")}&display=swap`;
- const link = document.createElement("link");
- link.rel = "stylesheet";
- link.href = url;
- document.head.appendChild(link);
- await new Promise<void>((resolve) => {
- link.addEventListener("load", () => resolve(), { once: true });
- link.addEventListener("error", () => resolve(), { once: true });
- });
- await Promise.all(
- weights.map((weight) =>
- document.fonts.load(`${weight} 16px "${family.replace(/"/g, '\\"')}"`),
- ),
- );
- fullLoaded.add(family);
-}
-
-export async function loadFonts({
- families,
-}: {
- families: string[];
-}): Promise<void> {
- const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family));
- await Promise.all(googleFonts.map((family) => loadFullFont({ family })));
-}
diff --git a/apps/web/src/lib/fonts/system-fonts.ts b/apps/web/src/lib/fonts/system-fonts.ts
deleted file mode 100644
index 9cc363a3..00000000
--- a/apps/web/src/lib/fonts/system-fonts.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const SYSTEM_FONTS = new Set([
- "Arial",
- "Helvetica",
- "Times New Roman",
- "Courier New",
- "Verdana",
- "Georgia",
- "monospace",
- "sans-serif",
- "serif",
-]);
diff --git a/apps/web/src/lib/fonts/types.ts b/apps/web/src/lib/fonts/types.ts
deleted file mode 100644
index 89ebce08..00000000
--- a/apps/web/src/lib/fonts/types.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export interface FontOption {
- value: string;
- label: string;
- category: "system" | "google" | "custom";
- weights?: number[];
- hasClassName?: boolean;
-}
-
-export interface GoogleFontMeta {
- family: string;
- category: string;
-}
-
-export interface FontAtlasEntry {
- x: number;
- y: number;
- w: number;
- ch: number;
- s: string[];
-}
-
-export interface FontAtlas {
- fonts: Record<string, FontAtlasEntry>;
-}
diff --git a/apps/web/src/lib/fps/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts
deleted file mode 100644
index 10eed8d9..00000000
--- a/apps/web/src/lib/fps/__tests__/fps.test.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- getHighestImportedVideoFps,
- getRaisedProjectFpsForImportedMedia,
-} from "@/lib/fps/utils";
-
-describe("getHighestImportedVideoFps", () => {
- test("returns the highest valid video fps", () => {
- expect(
- getHighestImportedVideoFps({
- mediaAssets: [
- { type: "audio" },
- { type: "video", fps: 30 },
- { type: "image", fps: 120 },
- { type: "video", fps: 60 },
- ],
- }),
- ).toBe(60);
- });
-
- test("ignores missing and invalid fps values", () => {
- expect(
- getHighestImportedVideoFps({
- mediaAssets: [
- { type: "video" },
- { type: "video", fps: 0 },
- { type: "video", fps: -10 },
- { type: "audio", fps: 120 },
- ],
- }),
- ).toBeNull();
- });
-});
-
-describe("getRaisedProjectFpsForImportedMedia", () => {
- test("raises the project fps to match a higher-fps import", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: { numerator: 30, denominator: 1 },
- importedAssets: [{ type: "video", fps: 60 }],
- }),
- ).toEqual({ numerator: 60, denominator: 1 });
- });
-
- test("does not lower the project fps for lower-fps imports", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: { numerator: 60, denominator: 1 },
- importedAssets: [{ type: "video", fps: 10 }],
- }),
- ).toBeNull();
- });
-
- test("ignores non-video imports", () => {
- expect(
- getRaisedProjectFpsForImportedMedia({
- currentFps: { numerator: 30, denominator: 1 },
- importedAssets: [
- { type: "image", fps: 60 },
- { type: "audio", fps: 120 },
- ],
- }),
- ).toBeNull();
- });
-});
diff --git a/apps/web/src/lib/fps/defaults.ts b/apps/web/src/lib/fps/defaults.ts
deleted file mode 100644
index 66bf0b1c..00000000
--- a/apps/web/src/lib/fps/defaults.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-
-export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 };
diff --git a/apps/web/src/lib/fps/presets.ts b/apps/web/src/lib/fps/presets.ts
deleted file mode 100644
index b09920d4..00000000
--- a/apps/web/src/lib/fps/presets.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export const FPS_PRESETS = [
- { value: "24", label: "24 fps" },
- { value: "25", label: "25 fps" },
- { value: "30", label: "30 fps" },
- { value: "60", label: "60 fps" },
- { value: "120", label: "120 fps" },
-] as const;
diff --git a/apps/web/src/lib/fps/utils.ts b/apps/web/src/lib/fps/utils.ts
deleted file mode 100644
index 932788c4..00000000
--- a/apps/web/src/lib/fps/utils.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-import type { MediaAsset } from "@/lib/media/types";
-
-type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
-
-const STANDARD_FRAME_RATES: Array<{ value: number; rate: FrameRate }> = [
- { value: 24_000 / 1_001, rate: { numerator: 24_000, denominator: 1_001 } },
- { value: 24, rate: { numerator: 24, denominator: 1 } },
- { value: 25, rate: { numerator: 25, denominator: 1 } },
- { value: 30_000 / 1_001, rate: { numerator: 30_000, denominator: 1_001 } },
- { value: 30, rate: { numerator: 30, denominator: 1 } },
- { value: 48, rate: { numerator: 48, denominator: 1 } },
- { value: 50, rate: { numerator: 50, denominator: 1 } },
- { value: 60_000 / 1_001, rate: { numerator: 60_000, denominator: 1_001 } },
- { value: 60, rate: { numerator: 60, denominator: 1 } },
- { value: 120, rate: { numerator: 120, denominator: 1 } },
-];
-
-const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
-
-export function frameRateToFloat(rate: FrameRate): number {
- return rate.numerator / rate.denominator;
-}
-
-export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean {
- return a.numerator === b.numerator && a.denominator === b.denominator;
-}
-
-export function floatToFrameRate(fps: number): FrameRate {
- const standard = STANDARD_FRAME_RATES.find(
- (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
- );
- if (standard) return standard.rate;
-
- if (Number.isInteger(fps)) {
- return { numerator: fps, denominator: 1 };
- }
-
- const ARBITRARY_DENOMINATOR = 1_000_000;
- const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
- const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR);
- return {
- numerator: scaledNumerator / divisor,
- denominator: ARBITRARY_DENOMINATOR / divisor,
- };
-}
-
-function gcd(left: number, right: number): number {
- let a = Math.abs(left);
- let b = Math.abs(right);
- while (b !== 0) {
- const remainder = a % b;
- a = b;
- b = remainder;
- }
- return a || 1;
-}
-
-export function getHighestImportedVideoFps({
- mediaAssets,
-}: {
- mediaAssets: MediaAssetFpsInput[];
-}): number | null {
- let highestFps: number | null = null;
-
- for (const asset of mediaAssets) {
- const fps = asset.fps ?? Number.NaN;
- if (asset.type !== "video") continue;
- if (!Number.isFinite(fps) || fps <= 0) continue;
-
- highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
- }
-
- return highestFps;
-}
-
-export function getRaisedProjectFpsForImportedMedia({
- currentFps,
- importedAssets,
-}: {
- currentFps: FrameRate;
- importedAssets: MediaAssetFpsInput[];
-}): FrameRate | null {
- const highestImportedVideoFps = getHighestImportedVideoFps({
- mediaAssets: importedAssets,
- });
-
- const currentFpsFloat = frameRateToFloat(currentFps);
-
- if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) {
- return null;
- }
-
- return floatToFrameRate(highestImportedVideoFps);
-}
diff --git a/apps/web/src/lib/gradients/canvas.ts b/apps/web/src/lib/gradients/canvas.ts
deleted file mode 100644
index e2287333..00000000
--- a/apps/web/src/lib/gradients/canvas.ts
+++ /dev/null
@@ -1,923 +0,0 @@
-import type {
- GradientAst,
- Color,
- ColorStop,
- GradientOrientation,
-} from "./parser";
-import { parseGradient } from "./parser";
-
-type BackgroundLayer =
- | { type: "color"; value: string }
- | { type: "gradient"; value: GradientAst };
-
-type LinearPoints = {
- x0: number;
- y0: number;
- x1: number;
- y1: number;
- length: number;
-};
-
-type RadialDimensions = {
- cx: number;
- cy: number;
- rx: number;
- ry: number;
-};
-
-type PositionKeyword = "left" | "center" | "right" | "top" | "bottom";
-
-type Distance =
- | { type: "%"; value: string }
- | { type: "position-keyword"; value: string }
- | { type: "calc"; value: string }
- | { type: "px"; value: string }
- | { type: "em"; value: string };
-
-type Position = { type: "position"; value: { x?: Distance; y?: Distance } };
-
-type Shape = {
- type: "shape";
- value: "circle" | "ellipse";
- style?: Distance | { type: "extent-keyword"; value: string } | Position;
- at?: Position;
-};
-
-type DefaultRadial = { type: "default-radial"; at: Position };
-
-type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position };
-
-type RadialOrientation = Shape | ExtentKeyword | DefaultRadial;
-
-const gradientLayerPattern =
- /^(?:-(webkit|o|ms|moz)-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
-
-export function drawCssBackground({
- ctx,
- width,
- height,
- css,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- css: string;
-}): void {
- const layers = parseBackgroundLayers({ css });
- const layersInPaintOrder = layers.slice().reverse();
-
- for (const layer of layersInPaintOrder) {
- if (layer.type === "color") {
- ctx.fillStyle = layer.value;
- ctx.fillRect(0, 0, width, height);
- continue;
- }
-
- drawGradientLayer({ ctx, width, height, gradient: layer.value });
- }
-}
-
-const parseBackgroundLayers = ({
- css,
-}: {
- css: string;
-}): Array<BackgroundLayer> => {
- const segments = splitCssLayers({ css });
- const layers: Array<BackgroundLayer> = [];
-
- for (const segment of segments) {
- if (!segment) {
- continue;
- }
-
- if (gradientLayerPattern.test(segment)) {
- try {
- const parsed = parseGradient({ code: segment.trim() });
- for (const gradient of parsed) {
- layers.push({ type: "gradient", value: gradient });
- }
- continue;
- } catch {
- layers.push({ type: "color", value: segment });
- continue;
- }
- }
-
- layers.push({ type: "color", value: segment });
- }
-
- return layers;
-};
-
-const splitCssLayers = ({ css }: { css: string }): Array<string> => {
- const layers: Array<string> = [];
- let current = "";
- let depth = 0;
-
- for (const char of css) {
- if (char === "(") {
- depth += 1;
- }
- if (char === ")") {
- depth = Math.max(0, depth - 1);
- }
-
- if (char === "," && depth === 0) {
- layers.push(current.trim());
- current = "";
- continue;
- }
-
- current += char;
- }
-
- if (current.trim()) {
- layers.push(current.trim());
- }
-
- return layers;
-};
-
-const drawGradientLayer = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- if (gradient.type.includes("linear")) {
- drawLinearGradient({ ctx, width, height, gradient });
- return;
- }
-
- drawRadialGradient({ ctx, width, height, gradient });
-};
-
-const drawLinearGradient = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- const { x0, y0, x1, y1, length } = resolveLinearPoints({
- width,
- height,
- orientation: gradient.orientation,
- });
- const canvasGradient = ctx.createLinearGradient(x0, y0, x1, y1);
- const colorStops = normalizeColorStops({
- colorStops: gradient.colorStops,
- gradientLength: length,
- });
-
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
-
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(0, 0, width, height);
-};
-
-const drawRadialGradient = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- const { cx, cy, rx, ry } = resolveRadialDimensions({
- width,
- height,
- orientation: gradient.orientation,
- });
- const gradientLength = Math.max(rx, ry);
- const colorStops = normalizeColorStops({
- colorStops: gradient.colorStops,
- gradientLength,
- });
-
- if (rx === ry || ry === 0) {
- const canvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, rx);
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(0, 0, width, height);
- return;
- }
-
- const scaleY = ry / rx;
- ctx.save();
- ctx.translate(cx, cy);
- ctx.scale(1, scaleY);
- const canvasGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(-cx, -cy / scaleY, width, height / scaleY);
- ctx.restore();
-};
-
-const resolveLinearPoints = ({
- width,
- height,
- orientation,
-}: {
- width: number;
- height: number;
- orientation: GradientOrientation | undefined;
-}): LinearPoints => {
- const angle = resolveLinearAngle({ orientation });
- const radians = (angle * Math.PI) / 180;
- const dx = Math.sin(radians);
- const dy = -Math.cos(radians);
- const centerX = width / 2;
- const centerY = height / 2;
- const halfLength = (Math.abs(width * dx) + Math.abs(height * dy)) / 2;
- const x0 = centerX - dx * halfLength;
- const y0 = centerY - dy * halfLength;
- const x1 = centerX + dx * halfLength;
- const y1 = centerY + dy * halfLength;
- const length = Math.hypot(x1 - x0, y1 - y0);
-
- return { x0, y0, x1, y1, length };
-};
-
-const resolveLinearAngle = ({
- orientation,
-}: {
- orientation: GradientOrientation | undefined;
-}): number => {
- if (!orientation) {
- return 180;
- }
-
- if (!Array.isArray(orientation)) {
- if (orientation.type === "angular") {
- return Number.parseFloat(orientation.value);
- }
-
- if (orientation.type === "directional") {
- return angleFromDirectional({ value: orientation.value });
- }
- }
-
- return 180;
-};
-
-const angleFromDirectional = ({ value }: { value: string }): number => {
- const normalized = value.toLowerCase().replace("to", "").trim();
- const parts = normalized.split(/\s+/).filter(Boolean);
- let dx = 0;
- let dy = 0;
-
- for (const part of parts) {
- if (part === "left") {
- dx = -1;
- }
- if (part === "right") {
- dx = 1;
- }
- if (part === "top") {
- dy = -1;
- }
- if (part === "bottom") {
- dy = 1;
- }
- }
-
- if (dx === 0 && dy === 0) {
- return 180;
- }
-
- const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
- return (angle + 360) % 360;
-};
-
-const resolveRadialDimensions = ({
- width,
- height,
- orientation,
-}: {
- width: number;
- height: number;
- orientation: GradientOrientation | undefined;
-}): RadialDimensions => {
- const centerFallback = { cx: width / 2, cy: height / 2 };
- const radial = Array.isArray(orientation)
- ? (orientation[0] as RadialOrientation | undefined)
- : undefined;
-
- if (!radial) {
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx: centerFallback.cx,
- cy: centerFallback.cy,
- shape: "ellipse",
- extent: "farthest-corner",
- });
- return { ...centerFallback, rx, ry };
- }
-
- if (radial.type === "shape") {
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const shape = radial.value;
-
- if (radial.style && radial.style.type === "position") {
- const xDist = radial.style.value.x;
- const yDist = radial.style.value.y;
- const rx = xDist
- ? resolveEllipseDimension({ distance: xDist, axisSize: width })
- : width / 2;
- const ry = yDist
- ? resolveEllipseDimension({ distance: yDist, axisSize: height })
- : height / 2;
- return { cx, cy, rx, ry };
- }
-
- if (radial.style && radial.style.type !== "extent-keyword") {
- const resolvedRadius = resolveDistanceInPixels({
- distance: radial.style,
- axisSize: Math.max(width, height),
- });
- if (shape === "circle") {
- return {
- cx,
- cy,
- rx: resolvedRadius ?? 0,
- ry: resolvedRadius ?? 0,
- };
- }
-
- const radius = resolvedRadius ?? 0;
- return { cx, cy, rx: radius, ry: radius };
- }
-
- const extent =
- radial.style && radial.style.type === "extent-keyword"
- ? radial.style.value
- : "farthest-corner";
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape,
- extent,
- });
- return { cx, cy, rx, ry };
- }
-
- if (radial.type === "extent-keyword") {
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape: "ellipse",
- extent: radial.value,
- });
- return { cx, cy, rx, ry };
- }
-
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape: "ellipse",
- extent: "farthest-corner",
- });
- return { cx, cy, rx, ry };
-};
-
-const resolveRadialCenter = ({
- width,
- height,
- position,
-}: {
- width: number;
- height: number;
- position?: Position;
-}): { cx: number; cy: number } => {
- if (!position) {
- return { cx: width / 2, cy: height / 2 };
- }
-
- const normalized = normalizePositionKeywords({ position });
- const cx = resolvePositionValue({
- distance: normalized.value.x,
- axisSize: width,
- axis: "x",
- });
- const cy = resolvePositionValue({
- distance: normalized.value.y,
- axisSize: height,
- axis: "y",
- });
-
- return { cx, cy };
-};
-
-const normalizePositionKeywords = ({
- position,
-}: {
- position: Position;
-}): Position => {
- const xValue = position.value.x;
- const yValue = position.value.y;
-
- if (
- xValue?.type === "position-keyword" &&
- yValue?.type === "position-keyword"
- ) {
- const xKeyword = xValue.value.toLowerCase() as PositionKeyword;
- const yKeyword = yValue.value.toLowerCase() as PositionKeyword;
- const xIsVertical = xKeyword === "top" || xKeyword === "bottom";
- const yIsHorizontal = yKeyword === "left" || yKeyword === "right";
-
- if (xIsVertical && yIsHorizontal) {
- return {
- type: "position",
- value: {
- x: { type: "position-keyword", value: yKeyword },
- y: { type: "position-keyword", value: xKeyword },
- },
- };
- }
- }
-
- return position;
-};
-
-const resolveRadialExtents = ({
- width,
- height,
- cx,
- cy,
- shape,
- extent,
-}: {
- width: number;
- height: number;
- cx: number;
- cy: number;
- shape: "circle" | "ellipse";
- extent: string;
-}): { rx: number; ry: number } => {
- const left = cx;
- const right = width - cx;
- const top = cy;
- const bottom = height - cy;
-
- if (shape === "circle") {
- const distances = [
- Math.hypot(left, top),
- Math.hypot(right, top),
- Math.hypot(left, bottom),
- Math.hypot(right, bottom),
- ];
- if (extent === "closest-side") {
- return { rx: Math.min(left, right, top, bottom), ry: 0 };
- }
- if (extent === "farthest-side") {
- return { rx: Math.max(left, right, top, bottom), ry: 0 };
- }
- if (extent === "closest-corner") {
- return { rx: Math.min(...distances), ry: 0 };
- }
- return { rx: Math.max(...distances), ry: 0 };
- }
-
- if (extent === "closest-side") {
- return { rx: Math.min(left, right), ry: Math.min(top, bottom) };
- }
- if (extent === "farthest-side") {
- return { rx: Math.max(left, right), ry: Math.max(top, bottom) };
- }
-
- const corners = [
- { dx: left, dy: top },
- { dx: right, dy: top },
- { dx: left, dy: bottom },
- { dx: right, dy: bottom },
- ];
- const sorted = corners
- .slice()
- .sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy));
- const chosen =
- extent === "closest-corner" ? sorted[0] : sorted[sorted.length - 1];
- return { rx: Math.abs(chosen.dx), ry: Math.abs(chosen.dy) };
-};
-
-const resolvePositionValue = ({
- distance,
- axisSize,
- axis,
-}: {
- distance?: Distance;
- axisSize: number;
- axis: "x" | "y";
-}): number => {
- if (!distance) {
- return axisSize / 2;
- }
-
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "position-keyword") {
- return keywordToPosition({
- value: distance.value,
- axisSize,
- axis,
- });
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return axisSize / 2;
-};
-
-const resolveDistanceInPixels = ({
- distance,
- axisSize,
-}: {
- distance: Distance;
- axisSize: number;
-}): number | null => {
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return null;
-};
-
-const resolveEllipseDimension = ({
- distance,
- axisSize,
-}: {
- distance: Distance;
- axisSize: number;
-}): number => {
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return axisSize / 2;
-};
-
-const keywordToPosition = ({
- value,
- axisSize,
- axis,
-}: {
- value: string;
- axisSize: number;
- axis: "x" | "y";
-}): number => {
- const keyword = value.toLowerCase() as PositionKeyword;
-
- if (keyword === "center") {
- return axisSize / 2;
- }
-
- if (axis === "x") {
- if (keyword === "left") {
- return 0;
- }
- if (keyword === "right") {
- return axisSize;
- }
- }
-
- if (axis === "y") {
- if (keyword === "top") {
- return 0;
- }
- if (keyword === "bottom") {
- return axisSize;
- }
- }
-
- return axisSize / 2;
-};
-
-const isTransparent = ({ color }: { color: string }): boolean => {
- const lower = color.toLowerCase().trim();
- if (lower === "transparent") {
- return true;
- }
- const rgbaMatch = lower.match(
- /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbaMatch && Number.parseFloat(rgbaMatch[1]) === 0) {
- return true;
- }
- return false;
-};
-
-const parseColorToRgb = ({
- color,
-}: {
- color: string;
-}): { r: number; g: number; b: number; a: number } | null => {
- const lower = color.toLowerCase().trim();
-
- const hexMatch = lower.match(/^#([0-9a-f]{3,8})$/);
- if (hexMatch) {
- const hex = hexMatch[1];
- if (hex.length === 3) {
- return {
- r: Number.parseInt(hex[0] + hex[0], 16),
- g: Number.parseInt(hex[1] + hex[1], 16),
- b: Number.parseInt(hex[2] + hex[2], 16),
- a: 1,
- };
- }
- if (hex.length === 6) {
- return {
- r: Number.parseInt(hex.slice(0, 2), 16),
- g: Number.parseInt(hex.slice(2, 4), 16),
- b: Number.parseInt(hex.slice(4, 6), 16),
- a: 1,
- };
- }
- if (hex.length === 8) {
- return {
- r: Number.parseInt(hex.slice(0, 2), 16),
- g: Number.parseInt(hex.slice(2, 4), 16),
- b: Number.parseInt(hex.slice(4, 6), 16),
- a: Number.parseInt(hex.slice(6, 8), 16) / 255,
- };
- }
- }
-
- const rgbMatch = lower.match(
- /^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbMatch) {
- return {
- r: Number.parseFloat(rgbMatch[1]),
- g: Number.parseFloat(rgbMatch[2]),
- b: Number.parseFloat(rgbMatch[3]),
- a: 1,
- };
- }
-
- const rgbaMatch = lower.match(
- /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbaMatch) {
- return {
- r: Number.parseFloat(rgbaMatch[1]),
- g: Number.parseFloat(rgbaMatch[2]),
- b: Number.parseFloat(rgbaMatch[3]),
- a: Number.parseFloat(rgbaMatch[4]),
- };
- }
-
- return null;
-};
-
-const fixTransparentStops = ({
- stops,
-}: {
- stops: Array<{ color: string; offset: number | null }>;
-}): Array<{ color: string; offset: number | null }> => {
- if (stops.length === 0) {
- return stops;
- }
-
- const result = stops.map((stop) => ({ ...stop }));
-
- for (let i = 0; i < result.length; i++) {
- if (!isTransparent({ color: result[i].color })) {
- continue;
- }
-
- let donorColor: { r: number; g: number; b: number } | null = null;
-
- for (let j = i - 1; j >= 0; j--) {
- if (!isTransparent({ color: result[j].color })) {
- const parsed = parseColorToRgb({ color: result[j].color });
- if (parsed) {
- donorColor = parsed;
- break;
- }
- }
- }
-
- if (!donorColor) {
- for (let j = i + 1; j < result.length; j++) {
- if (!isTransparent({ color: result[j].color })) {
- const parsed = parseColorToRgb({ color: result[j].color });
- if (parsed) {
- donorColor = parsed;
- break;
- }
- }
- }
- }
-
- if (donorColor) {
- result[i].color =
- `rgba(${donorColor.r},${donorColor.g},${donorColor.b},0)`;
- }
- }
-
- return result;
-};
-
-const normalizeColorStops = ({
- colorStops,
- gradientLength,
-}: {
- colorStops: Array<ColorStop>;
- gradientLength: number;
-}): Array<{ color: string; offset: number }> => {
- const mappedStops = colorStops.map((stop) => ({
- color: colorToString({ color: stop }),
- offset: resolveStopOffset({ stop, gradientLength }),
- }));
-
- const fixedStops = fixTransparentStops({ stops: mappedStops });
- const resolvedStops = fixedStops.map((stop) => ({ ...stop }));
- const knownIndices = resolvedStops
- .map((stop, index) => (stop.offset === null ? null : index))
- .filter((index): index is number => index !== null);
-
- if (knownIndices.length === 0) {
- const step = resolvedStops.length > 1 ? 1 / (resolvedStops.length - 1) : 1;
- for (let index = 0; index < resolvedStops.length; index += 1) {
- resolvedStops[index].offset = step * index;
- }
- return clampStops({ stops: resolvedStops });
- }
-
- const firstKnown = knownIndices[0];
- if (resolvedStops[firstKnown].offset === null) {
- resolvedStops[firstKnown].offset = 0;
- }
-
- for (let index = 0; index < firstKnown; index += 1) {
- const nextOffset = resolvedStops[firstKnown].offset ?? 0;
- resolvedStops[index].offset = (nextOffset * index) / firstKnown;
- }
-
- for (let i = 0; i < knownIndices.length - 1; i += 1) {
- const startIndex = knownIndices[i];
- const endIndex = knownIndices[i + 1];
- const startOffset = resolvedStops[startIndex].offset ?? 0;
- const endOffset = resolvedStops[endIndex].offset ?? startOffset;
- const gap = endIndex - startIndex;
-
- if (gap <= 1) {
- continue;
- }
-
- const step = (endOffset - startOffset) / gap;
- for (let index = 1; index < gap; index += 1) {
- resolvedStops[startIndex + index].offset = startOffset + step * index;
- }
- }
-
- const lastKnown = knownIndices[knownIndices.length - 1];
- const lastOffset = resolvedStops[lastKnown].offset ?? 1;
- if (lastKnown < resolvedStops.length - 1) {
- const gap = resolvedStops.length - 1 - lastKnown;
- const step = (1 - lastOffset) / gap;
- for (let index = 1; index <= gap; index += 1) {
- resolvedStops[lastKnown + index].offset = lastOffset + step * index;
- }
- }
-
- return clampStops({ stops: resolvedStops });
-};
-
-const clampStops = ({
- stops,
-}: {
- stops: Array<{ color: string; offset: number | null }>;
-}): Array<{ color: string; offset: number }> => {
- return stops.map((stop) => ({
- color: stop.color,
- offset: clamp01({ value: stop.offset ?? 0 }),
- }));
-};
-
-const resolveStopOffset = ({
- stop,
- gradientLength,
-}: {
- stop: ColorStop;
- gradientLength: number;
-}): number | null => {
- if (!stop.length) {
- return null;
- }
-
- if (stop.length.type === "%") {
- return Number.parseFloat(stop.length.value) / 100;
- }
-
- if (stop.length.type === "px") {
- return Number.parseFloat(stop.length.value) / gradientLength;
- }
-
- if (stop.length.type === "em") {
- return (Number.parseFloat(stop.length.value) * 16) / gradientLength;
- }
-
- return null;
-};
-
-const clamp01 = ({ value }: { value: number }): number => {
- if (value < 0) {
- return 0;
- }
- if (value > 1) {
- return 1;
- }
- return value;
-};
-
-const colorToString = ({ color }: { color: Color }): string => {
- if (color.type === "hex") {
- return `#${color.value}`;
- }
-
- if (color.type === "literal") {
- return color.value;
- }
-
- if (color.type === "rgb") {
- return `rgb(${color.value.join(",")})`;
- }
-
- if (color.type === "rgba") {
- return `rgba(${color.value.join(",")})`;
- }
-
- if (color.type === "hsl") {
- return `hsl(${color.value.join(",")})`;
- }
-
- if (color.type === "hsla") {
- return `hsla(${color.value.join(",")})`;
- }
-
- return `var(${color.value})`;
-};
diff --git a/apps/web/src/lib/gradients/index.ts b/apps/web/src/lib/gradients/index.ts
deleted file mode 100644
index 490a8fbf..00000000
--- a/apps/web/src/lib/gradients/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./parser";
-export * from "./canvas";
diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts
deleted file mode 100644
index 342780b4..00000000
--- a/apps/web/src/lib/gradients/parser.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-/*
- * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js
- */
-
-type GradientType =
- | "linear-gradient"
- | "repeating-linear-gradient"
- | "radial-gradient"
- | "repeating-radial-gradient";
-
-type DirectionalOrientation = { type: "directional"; value: string };
-type AngularOrientation = { type: "angular"; value: string };
-type LinearOrientation = DirectionalOrientation | AngularOrientation;
-
-type Distance =
- | { type: "%"; value: string }
- | { type: "position-keyword"; value: string }
- | { type: "calc"; value: string }
- | { type: "px"; value: string }
- | { type: "em"; value: string };
-
-type PositionValue = { x?: Distance; y?: Distance };
-type Position = { type: "position"; value: PositionValue };
-
-type ExtentKeyword = { type: "extent-keyword"; value: string };
-
-type ShapeValue = "circle" | "ellipse";
-type Shape = {
- type: "shape";
- value: ShapeValue;
- style?: Distance | ExtentKeyword | Position;
- at?: Position;
-};
-
-type DefaultRadial = { type: "default-radial"; at: Position };
-
-type RadialOrientation =
- | Shape
- | (ExtentKeyword & { at?: Position })
- | DefaultRadial;
-
-export type GradientOrientation = LinearOrientation | Array<RadialOrientation>;
-
-export type Color =
- | { type: "hex"; value: string }
- | { type: "literal"; value: string }
- | { type: "rgb"; value: Array<string> }
- | { type: "rgba"; value: Array<string> }
- | { type: "hsl"; value: [string, string, string] }
- | { type: "hsla"; value: [string, string, string, string] }
- | { type: "var"; value: string };
-
-export type ColorStop = Color & { length?: Distance };
-
-export type GradientAst = {
- type: GradientType;
- orientation: GradientOrientation | undefined;
- colorStops: Array<ColorStop>;
-};
-
-type Tokens = {
- linearGradient: RegExp;
- repeatingLinearGradient: RegExp;
- radialGradient: RegExp;
- repeatingRadialGradient: RegExp;
- sideOrCorner: RegExp;
- extentKeywords: RegExp;
- positionKeywords: RegExp;
- pixelValue: RegExp;
- percentageValue: RegExp;
- emValue: RegExp;
- angleValue: RegExp;
- radianValue: RegExp;
- startCall: RegExp;
- endCall: RegExp;
- comma: RegExp;
- hexColor: RegExp;
- literalColor: RegExp;
- rgbColor: RegExp;
- rgbaColor: RegExp;
- varColor: RegExp;
- calcValue: RegExp;
- variableName: RegExp;
- number: RegExp;
- hslColor: RegExp;
- hslaColor: RegExp;
-};
-
-const tokens: Tokens = {
- linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i,
- repeatingLinearGradient:
- /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i,
- radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i,
- repeatingRadialGradient:
- /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i,
- sideOrCorner:
- /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
- extentKeywords:
- /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/,
- positionKeywords: /^(left|center|right|top|bottom)/i,
- pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
- percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/,
- emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
- angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
- radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
- startCall: /^\(/,
- endCall: /^\)/,
- comma: /^,/,
- hexColor: /^#([0-9a-fA-F]+)/,
- literalColor: /^([a-zA-Z]+)/,
- rgbColor: /^rgb/i,
- rgbaColor: /^rgba/i,
- varColor: /^var/i,
- calcValue: /^calc/i,
- variableName: /^(--[a-zA-Z0-9-,\s#]+)/,
- number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
- hslColor: /^hsl/i,
- hslaColor: /^hsla/i,
-};
-
-let input = "";
-
-const error = ({ message }: { message: string }): never => {
- const err = new Error(`${input}: ${message}`);
- (err as Error & { source?: string }).source = input;
- throw err;
-};
-
-const getAst = (): Array<GradientAst> => {
- const ast = matchListDefinitions();
-
- if (input.length > 0) {
- error({ message: "Invalid input not EOF" });
- }
-
- return ast;
-};
-
-const matchListDefinitions = (): Array<GradientAst> =>
- matchListing({ matcher: matchDefinition });
-
-const matchDefinition = (): GradientAst | undefined =>
- matchGradient({
- gradientType: "linear-gradient",
- pattern: tokens.linearGradient,
- orientationMatcher: matchLinearOrientation,
- }) ||
- matchGradient({
- gradientType: "repeating-linear-gradient",
- pattern: tokens.repeatingLinearGradient,
- orientationMatcher: matchLinearOrientation,
- }) ||
- matchGradient({
- gradientType: "radial-gradient",
- pattern: tokens.radialGradient,
- orientationMatcher: matchListRadialOrientations,
- }) ||
- matchGradient({
- gradientType: "repeating-radial-gradient",
- pattern: tokens.repeatingRadialGradient,
- orientationMatcher: matchListRadialOrientations,
- });
-
-const matchGradient = ({
- gradientType,
- pattern,
- orientationMatcher,
-}: {
- gradientType: GradientType;
- pattern: RegExp;
- orientationMatcher: () => GradientOrientation | undefined;
-}): GradientAst | undefined =>
- matchCall({
- pattern,
- callback: () => {
- const orientation = orientationMatcher();
- if (orientation && !scan({ regexp: tokens.comma })) {
- error({ message: "Missing comma before color stops" });
- }
-
- return {
- type: gradientType,
- orientation,
- colorStops: matchListing({ matcher: matchColorStop }),
- };
- },
- });
-
-const matchCall = <T>({
- pattern,
- callback,
-}: {
- pattern: RegExp;
- callback: (captures: RegExpExecArray) => T;
-}): T | undefined => {
- const captures = scan({ regexp: pattern });
-
- if (!captures) {
- return undefined;
- }
-
- if (!scan({ regexp: tokens.startCall })) {
- error({ message: "Missing (" });
- }
-
- const result = callback(captures);
-
- if (!scan({ regexp: tokens.endCall })) {
- error({ message: "Missing )" });
- }
-
- return result;
-};
-
-const matchLinearOrientation = (): LinearOrientation | undefined => {
- const sideOrCorner = matchSideOrCorner();
- if (sideOrCorner) {
- return sideOrCorner;
- }
-
- const legacyDirection = match({
- type: "position-keyword",
- pattern: tokens.positionKeywords,
- captureIndex: 1,
- });
- if (legacyDirection) {
- return {
- type: "directional",
- value: legacyDirection.value,
- };
- }
-
- return matchAngle();
-};
-
-const matchSideOrCorner = (): DirectionalOrientation | undefined =>
- match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 });
-
-const matchAngle = (): AngularOrientation | undefined =>
- match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) ||
- match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 });
-
-const matchListRadialOrientations = ():
- | Array<RadialOrientation>
- | undefined => {
- const radialOrientation = matchRadialOrientation();
- if (!radialOrientation) {
- return undefined;
- }
-
- const radialOrientations: Array<RadialOrientation> = [radialOrientation];
- const lookaheadCache = input;
-
- if (!scan({ regexp: tokens.comma })) {
- return radialOrientations;
- }
-
- const nextRadial = matchRadialOrientation();
- if (!nextRadial) {
- input = lookaheadCache;
- return radialOrientations;
- }
-
- radialOrientations.push(nextRadial);
- return radialOrientations;
-};
-
-const matchRadialOrientation = (): RadialOrientation | undefined => {
- const radialType = matchCircle() || matchEllipse();
- if (radialType) {
- radialType.at = matchAtPosition();
- return radialType;
- }
-
- const extent = matchExtentKeyword();
- if (extent) {
- const positionAt = matchAtPosition();
- if (positionAt) {
- return { ...extent, at: positionAt };
- }
- return extent;
- }
-
- const implicitEllipse = matchImplicitEllipse();
- if (implicitEllipse) {
- return implicitEllipse;
- }
-
- const atPosition = matchAtPosition();
- if (atPosition) {
- return { type: "default-radial", at: atPosition };
- }
-
- const defaultPosition = matchPositioning();
- if (defaultPosition) {
- return { type: "default-radial", at: defaultPosition };
- }
-
- return undefined;
-};
-
-const matchImplicitEllipse = (): Shape | undefined => {
- const lookaheadCache = input;
-
- const width = matchDistance();
- if (!width) {
- return undefined;
- }
-
- const height = matchDistance();
- if (!height) {
- input = lookaheadCache;
- return undefined;
- }
-
- const atPos = matchAtPosition();
- if (!atPos) {
- input = lookaheadCache;
- return undefined;
- }
-
- return {
- type: "shape",
- value: "ellipse",
- style: { type: "position", value: { x: width, y: height } },
- at: atPos,
- };
-};
-
-const matchCircle = (): Shape | undefined => {
- const circle = match({
- type: "shape",
- pattern: /^(circle)/i,
- captureIndex: 0,
- }) as Shape | undefined;
-
- if (!circle) {
- return undefined;
- }
-
- circle.style = matchLength() || matchExtentKeyword();
- circle.value = "circle";
- return circle;
-};
-
-const matchEllipse = (): Shape | undefined => {
- const ellipse = match({
- type: "shape",
- pattern: /^(ellipse)/i,
- captureIndex: 0,
- }) as Shape | undefined;
-
- if (!ellipse) {
- return undefined;
- }
-
- ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword();
- ellipse.value = "ellipse";
- return ellipse;
-};
-
-const matchExtentKeyword = (): ExtentKeyword | undefined =>
- match({
- type: "extent-keyword",
- pattern: tokens.extentKeywords,
- captureIndex: 1,
- });
-
-const matchAtPosition = (): Position | undefined => {
- if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) {
- return undefined;
- }
-
- const positioning = matchPositioning();
- if (!positioning) {
- error({ message: "Missing positioning value" });
- }
-
- return positioning;
-};
-
-const matchPositioning = (): Position | undefined => {
- const location = matchCoordinates();
-
- if (!location.x && !location.y) {
- return undefined;
- }
-
- return {
- type: "position",
- value: location,
- };
-};
-
-const matchCoordinates = (): PositionValue => ({
- x: matchDistance(),
- y: matchDistance(),
-});
-
-const matchListing = <T>({
- matcher,
-}: {
- matcher: () => T | undefined;
-}): Array<T> => {
- const captures = matcher();
- const result: Array<T> = [];
-
- if (!captures) {
- return result;
- }
-
- result.push(captures);
- while (scan({ regexp: tokens.comma })) {
- const nextCapture = matcher() ?? error({ message: "One extra comma" });
- result.push(nextCapture);
- }
-
- return result;
-};
-
-const matchColorStop = (): ColorStop => {
- const color = matchColor() ?? error({ message: "Expected color definition" });
- const length = matchDistance();
- return { ...color, length };
-};
-
-const matchColor = (): Color | undefined =>
- matchHexColor() ||
- matchHSLAColor() ||
- matchHSLColor() ||
- matchRGBAColor() ||
- matchRGBColor() ||
- matchVarColor() ||
- matchLiteralColor();
-
-const matchLiteralColor = (): Color | undefined =>
- match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 });
-
-const matchHexColor = (): Color | undefined =>
- match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 });
-
-const matchRGBColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.rgbColor,
- callback: () => ({
- type: "rgb",
- value: matchListing({ matcher: matchNumber }),
- }),
- });
-
-const matchRGBAColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.rgbaColor,
- callback: () => ({
- type: "rgba",
- value: matchListing({ matcher: matchNumber }),
- }),
- });
-
-const matchVarColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.varColor,
- callback: () => ({
- type: "var",
- value: matchVariableName(),
- }),
- });
-
-const matchHSLColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.hslColor,
- callback: () => {
- const lookahead = scan({ regexp: tokens.percentageValue });
- if (lookahead) {
- error({
- message:
- "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
- });
- }
-
- const hue = matchNumber();
- scan({ regexp: tokens.comma });
- let captures = scan({ regexp: tokens.percentageValue });
- const sat = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- captures = scan({ regexp: tokens.percentageValue });
- const light = captures ? captures[1] : null;
- const ensuredSat =
- sat ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSL",
- });
- const ensuredLight =
- light ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSL",
- });
- return {
- type: "hsl",
- value: [hue, ensuredSat, ensuredLight],
- };
- },
- });
-
-const matchHSLAColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.hslaColor,
- callback: () => {
- const hue = matchNumber();
- scan({ regexp: tokens.comma });
- let captures = scan({ regexp: tokens.percentageValue });
- const sat = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- captures = scan({ regexp: tokens.percentageValue });
- const light = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- const alpha = matchNumber();
- const ensuredSat =
- sat ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSLA",
- });
- const ensuredLight =
- light ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSLA",
- });
- return {
- type: "hsla",
- value: [hue, ensuredSat, ensuredLight, alpha],
- };
- },
- });
-
-const matchVariableName = (): string => {
- const captures =
- scan({ regexp: tokens.variableName }) ??
- error({ message: "Expected CSS variable name" });
- return captures[1];
-};
-
-const matchNumber = (): string => {
- const captures =
- scan({ regexp: tokens.number }) ?? error({ message: "Expected number" });
- return captures[1];
-};
-
-const matchDistance = (): Distance | undefined =>
- match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) ||
- matchPositionKeyword() ||
- matchCalc() ||
- matchLength();
-
-const matchPositionKeyword = (): Distance | undefined =>
- match({
- type: "position-keyword",
- pattern: tokens.positionKeywords,
- captureIndex: 1,
- });
-
-const matchCalc = (): Distance | undefined =>
- matchCall({
- pattern: tokens.calcValue,
- callback: () => {
- let openParenCount = 1;
- let index = 0;
-
- while (openParenCount > 0 && index < input.length) {
- const char = input.charAt(index);
- if (char === "(") {
- openParenCount++;
- } else if (char === ")") {
- openParenCount--;
- }
- index++;
- }
-
- if (openParenCount > 0) {
- error({ message: "Missing closing parenthesis in calc() expression" });
- }
-
- const calcContent = input.slice(0, index - 1);
- consume({ size: index - 1 });
-
- return {
- type: "calc",
- value: calcContent,
- };
- },
- });
-
-const matchLength = (): Distance | undefined =>
- match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) ||
- match({ type: "em", pattern: tokens.emValue, captureIndex: 1 });
-
-const match = <TType extends string>({
- type,
- pattern,
- captureIndex,
-}: {
- type: TType;
- pattern: RegExp;
- captureIndex: number;
-}): { type: TType; value: string } | undefined => {
- const captures = scan({ regexp: pattern });
- if (!captures) {
- return undefined;
- }
-
- return {
- type,
- value: captures[captureIndex],
- };
-};
-
-const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => {
- const blankCaptures = /^[\n\r\t\s]+/.exec(input);
- if (blankCaptures) {
- consume({ size: blankCaptures[0].length });
- }
-
- const captures = regexp.exec(input);
- if (captures) {
- consume({ size: captures[0].length });
- }
-
- return captures;
-};
-
-const consume = ({ size }: { size: number }): void => {
- input = input.slice(size);
-};
-
-export const parseGradient = ({
- code,
-}: {
- code: string;
-}): Array<GradientAst> => {
- input = code.toString().trim();
- if (input.endsWith(";")) {
- input = input.slice(0, -1);
- }
- return getAst();
-};
-
-export const GradientParser = {
- parse: parseGradient,
-};
diff --git a/apps/web/src/lib/graphics/definitions/ellipse.ts b/apps/web/src/lib/graphics/definitions/ellipse.ts
deleted file mode 100644
index 04b93c27..00000000
--- a/apps/web/src/lib/graphics/definitions/ellipse.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import { applyAlignedStroke } from "../stroke";
-import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
-import type { GraphicDefinition } from "../types";
-
-interface EllipseParams {
- fill: string;
- stroke: string;
- strokeWidth: number;
- strokeAlign: GraphicStrokeAlign;
-}
-
-const ELLIPSE_PARAMS: ParamDefinition<keyof EllipseParams & string>[] = [
- {
- key: "fill",
- label: "Fill",
- type: "color",
- default: "#ffffff",
- },
- {
- key: "stroke",
- label: "Color",
- type: "color",
- default: "#000000",
- group: "stroke",
- },
- {
- key: "strokeWidth",
- label: "Width",
- type: "number",
- default: 0,
- min: 0,
- max: 64,
- step: 1,
- shortLabel: "W",
- group: "stroke",
- },
- STROKE_ALIGN_PARAM,
-];
-
-export const ellipseGraphicDefinition: GraphicDefinition = {
- id: "ellipse",
- name: "Ellipse",
- keywords: ["ellipse", "circle", "oval"],
- params: ELLIPSE_PARAMS,
- render({ ctx, params, width, height }) {
- const fill = String(params.fill ?? "#ffffff");
- const stroke = String(params.stroke ?? "#000000");
- const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
- const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
- const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
- const centerX = width / 2;
- const centerY = height / 2;
- const radiusX = Math.max(1, width / 2 - inset);
- const radiusY = Math.max(1, height / 2 - inset);
-
- ctx.clearRect(0, 0, width, height);
- const path = new Path2D();
- path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
- ctx.fillStyle = fill;
- ctx.fill(path);
-
- if (strokeWidth > 0) {
- applyAlignedStroke({
- ctx,
- path,
- strokeWidth,
- strokeAlign,
- strokeColor: stroke,
- });
- }
- },
-};
diff --git a/apps/web/src/lib/graphics/definitions/index.ts b/apps/web/src/lib/graphics/definitions/index.ts
deleted file mode 100644
index 6fd9e446..00000000
--- a/apps/web/src/lib/graphics/definitions/index.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index deecb6ff..00000000
--- a/apps/web/src/lib/graphics/definitions/polygon.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import { applyAlignedStroke } from "../stroke";
-import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
-import type { GraphicDefinition } from "../types";
-
-interface Point {
- x: number;
- y: number;
-}
-
-interface PolygonParams {
- fill: string;
- stroke: string;
- strokeWidth: number;
- strokeAlign: GraphicStrokeAlign;
- sides: number;
- cornerRadius: number;
-}
-
-const POLYGON_PARAMS: ParamDefinition<keyof PolygonParams & string>[] = [
- {
- key: "fill",
- label: "Fill",
- type: "color",
- default: "#ffffff",
- },
- {
- key: "stroke",
- label: "Color",
- type: "color",
- default: "#000000",
- group: "stroke",
- },
- {
- key: "strokeWidth",
- label: "Width",
- type: "number",
- default: 0,
- min: 0,
- max: 64,
- step: 1,
- shortLabel: "W",
- group: "stroke",
- },
- STROKE_ALIGN_PARAM,
- {
- key: "sides",
- label: "Sides",
- type: "number",
- default: 5,
- min: 3,
- max: 12,
- step: 1,
- shortLabel: "S",
- },
- {
- key: "cornerRadius",
- label: "Corner radius",
- type: "number",
- default: 0,
- min: 0,
- max: 50,
- step: 1,
- shortLabel: "R",
- },
-];
-
-function buildPolygonVertices({
- centerX,
- centerY,
- radius,
- sides,
-}: {
- centerX: number;
- centerY: number;
- radius: number;
- sides: number;
-}): Point[] {
- return Array.from({ length: sides }, (_, index) => {
- const angle = -Math.PI / 2 + (index * Math.PI * 2) / sides;
- return {
- x: centerX + Math.cos(angle) * radius,
- y: centerY + Math.sin(angle) * radius,
- };
- });
-}
-
-function distance({ a, b }: { a: Point; b: Point }): number {
- return Math.hypot(a.x - b.x, a.y - b.y);
-}
-
-function normalize(point: Point): Point {
- const length = Math.hypot(point.x, point.y) || 1;
- return {
- x: point.x / length,
- y: point.y / length,
- };
-}
-
-function traceRoundedPolygonPath({
- path,
- vertices,
- radius,
-}: {
- path: Path2D;
- vertices: Point[];
- radius: number;
-}): void {
- if (vertices.length < 3) {
- return;
- }
-
- if (radius <= 0) {
- path.moveTo(vertices[0].x, vertices[0].y);
- for (let index = 1; index < vertices.length; index++) {
- path.lineTo(vertices[index].x, vertices[index].y);
- }
- path.closePath();
- return;
- }
-
- for (let index = 0; index < vertices.length; index++) {
- const previous = vertices[(index - 1 + vertices.length) % vertices.length];
- const current = vertices[index];
- const next = vertices[(index + 1) % vertices.length];
- const toPrevious = normalize({
- x: previous.x - current.x,
- y: previous.y - current.y,
- });
- const toNext = normalize({
- x: next.x - current.x,
- y: next.y - current.y,
- });
- const angle = Math.acos(
- Math.max(-1, Math.min(1, toPrevious.x * toNext.x + toPrevious.y * toNext.y)),
- );
- const maxOffset =
- Math.min(distance({ a: previous, b: current }), distance({ a: current, b: next })) / 2;
- const tangentOffset = Math.min(radius / Math.tan(angle / 2), maxOffset);
- const start = {
- x: current.x + toPrevious.x * tangentOffset,
- y: current.y + toPrevious.y * tangentOffset,
- };
- const end = {
- x: current.x + toNext.x * tangentOffset,
- y: current.y + toNext.y * tangentOffset,
- };
-
- if (index === 0) {
- path.moveTo(start.x, start.y);
- } else {
- path.lineTo(start.x, start.y);
- }
-
- path.arcTo(
- current.x,
- current.y,
- end.x,
- end.y,
- Math.min(radius, maxOffset),
- );
- }
-
- path.closePath();
-}
-
-export const polygonGraphicDefinition: GraphicDefinition = {
- id: "polygon",
- name: "Polygon",
- keywords: ["polygon", "triangle", "pentagon", "hexagon", "diamond"],
- params: POLYGON_PARAMS,
- render({ ctx, params, width, height }) {
- const fill = String(params.fill ?? "#ffffff");
- const stroke = String(params.stroke ?? "#000000");
- const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
- const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
- const sides = Math.max(3, Math.min(12, Math.round(Number(params.sides ?? 5))));
- const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
- const radius = Math.max(1, Math.min(width, height) / 2 - inset);
- const maxCornerRadius = radius * Math.sin(Math.PI / sides);
- const cornerRadiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
- const cornerRadius =
- maxCornerRadius * Math.min(cornerRadiusPercent, 50) / 50;
- const vertices = buildPolygonVertices({
- centerX: width / 2,
- centerY: height / 2,
- radius,
- sides,
- });
-
- ctx.clearRect(0, 0, width, height);
- const path = new Path2D();
- traceRoundedPolygonPath({
- path,
- vertices,
- radius: cornerRadius,
- });
- ctx.fillStyle = fill;
- ctx.fill(path);
-
- if (strokeWidth > 0) {
- applyAlignedStroke({
- ctx,
- path,
- strokeWidth,
- strokeAlign,
- strokeColor: stroke,
- });
- }
- },
-};
diff --git a/apps/web/src/lib/graphics/definitions/rectangle.ts b/apps/web/src/lib/graphics/definitions/rectangle.ts
deleted file mode 100644
index fb52c03a..00000000
--- a/apps/web/src/lib/graphics/definitions/rectangle.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import { applyAlignedStroke } from "../stroke";
-import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
-import type { GraphicDefinition } from "../types";
-
-interface RectangleParams {
- fill: string;
- stroke: string;
- strokeWidth: number;
- strokeAlign: GraphicStrokeAlign;
- cornerRadius: number;
-}
-
-const RECTANGLE_PARAMS: ParamDefinition<keyof RectangleParams & string>[] = [
- {
- key: "fill",
- label: "Fill",
- type: "color",
- default: "#ffffff",
- },
- {
- key: "stroke",
- label: "Color",
- type: "color",
- default: "#000000",
- group: "stroke",
- },
- {
- key: "strokeWidth",
- label: "Width",
- type: "number",
- default: 0,
- min: 0,
- max: 64,
- step: 1,
- shortLabel: "W",
- group: "stroke",
- },
- STROKE_ALIGN_PARAM,
- {
- key: "cornerRadius",
- label: "Corner radius",
- type: "number",
- default: 0,
- min: 0,
- max: 50,
- step: 1,
- shortLabel: "R",
- },
-];
-
-export const rectangleGraphicDefinition: GraphicDefinition = {
- id: "rectangle",
- name: "Rectangle",
- keywords: ["rectangle", "square", "box"],
- params: RECTANGLE_PARAMS,
- render({ ctx, params, width, height }) {
- const fill = String(params.fill ?? "#ffffff");
- const stroke = String(params.stroke ?? "#000000");
- const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
- const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
- const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
- const drawWidth = Math.max(1, width - inset * 2);
- const drawHeight = Math.max(1, height - inset * 2);
- const radiusPercent = Math.max(0, Number(params.cornerRadius ?? 0));
- const radius =
- (Math.min(drawWidth, drawHeight) / 2) * Math.min(radiusPercent, 50) / 50;
-
- ctx.clearRect(0, 0, width, height);
- const path = new Path2D();
- path.roundRect(inset, inset, drawWidth, drawHeight, radius);
- ctx.fillStyle = fill;
- ctx.fill(path);
-
- if (strokeWidth > 0) {
- applyAlignedStroke({
- ctx,
- path,
- strokeWidth,
- strokeAlign,
- strokeColor: stroke,
- });
- }
- },
-};
diff --git a/apps/web/src/lib/graphics/definitions/shared.ts b/apps/web/src/lib/graphics/definitions/shared.ts
deleted file mode 100644
index 4f56c77c..00000000
--- a/apps/web/src/lib/graphics/definitions/shared.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index 2160c125..00000000
--- a/apps/web/src/lib/graphics/definitions/star.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import { applyAlignedStroke } from "../stroke";
-import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
-import type { GraphicDefinition } from "../types";
-
-interface StarParams {
- fill: string;
- stroke: string;
- strokeWidth: number;
- strokeAlign: GraphicStrokeAlign;
- points: number;
- depth: number;
-}
-
-const STAR_PARAMS: ParamDefinition<keyof StarParams & string>[] = [
- {
- key: "fill",
- label: "Fill",
- type: "color",
- default: "#ffffff",
- },
- {
- key: "stroke",
- label: "Color",
- type: "color",
- default: "#000000",
- group: "stroke",
- },
- {
- key: "strokeWidth",
- label: "Width",
- type: "number",
- default: 0,
- min: 0,
- max: 64,
- step: 1,
- shortLabel: "W",
- group: "stroke",
- },
- STROKE_ALIGN_PARAM,
- {
- key: "points",
- label: "Points",
- type: "number",
- default: 5,
- min: 3,
- max: 12,
- step: 1,
- shortLabel: "P",
- },
- {
- key: "depth",
- label: "Depth",
- type: "number",
- default: 45,
- min: 1,
- max: 99,
- step: 1,
- shortLabel: "D",
- },
-];
-
-export const starGraphicDefinition: GraphicDefinition = {
- id: "star",
- name: "Star",
- keywords: ["star", "sparkle", "burst"],
- params: STAR_PARAMS,
- render({ ctx, params, width, height }) {
- const fill = String(params.fill ?? "#ffffff");
- const stroke = String(params.stroke ?? "#000000");
- const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0));
- const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign;
- const points = Math.max(3, Math.min(12, Math.round(Number(params.points ?? 5))));
- const depth = Math.max(1, Math.min(99, Number(params.depth ?? 45))) / 100;
- const inset = strokeAlign === "center" ? strokeWidth / 2 : 0;
- const outerRadius = Math.max(1, Math.min(width, height) / 2 - inset);
- const innerRadius = outerRadius * depth;
- const centerX = width / 2;
- const centerY = height / 2;
-
- ctx.clearRect(0, 0, width, height);
- const path = new Path2D();
- for (let index = 0; index < points * 2; index++) {
- const radius = index % 2 === 0 ? outerRadius : innerRadius;
- const angle = -Math.PI / 2 + (index * Math.PI) / points;
- const x = centerX + Math.cos(angle) * radius;
- const y = centerY + Math.sin(angle) * radius;
-
- if (index === 0) {
- path.moveTo(x, y);
- } else {
- path.lineTo(x, y);
- }
- }
- path.closePath();
- ctx.fillStyle = fill;
- ctx.fill(path);
-
- if (strokeWidth > 0) {
- applyAlignedStroke({
- ctx,
- path,
- strokeWidth,
- strokeAlign,
- strokeColor: stroke,
- });
- }
- },
-};
diff --git a/apps/web/src/lib/graphics/index.ts b/apps/web/src/lib/graphics/index.ts
deleted file mode 100644
index 9002a151..00000000
--- a/apps/web/src/lib/graphics/index.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { buildDefaultParamValues } from "@/lib/registry";
-import type { ParamValues } from "@/lib/params";
-import { graphicsRegistry } from "./registry";
-import {
- registerDefaultGraphics,
- ellipseGraphicDefinition,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- starGraphicDefinition,
-} from "./definitions";
-import {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- type GraphicInstance,
- type GraphicDefinition,
-} from "./types";
-
-const graphicPreviewUrlCache = new Map<string, string>();
-
-const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
-const FALLBACK_FILL_OPACITY = 0.08;
-const FALLBACK_MIN_FONT_SIZE = 12;
-const FALLBACK_FONT_SIZE_RATIO = 0.15;
-
-function buildFallbackPreviewUrl({
- name,
- size,
-}: {
- name: string;
- size: number;
-}): string {
- const svg = `
- <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
- <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
- <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
- </svg>
- `;
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
-}
-
-export function getGraphicDefinition({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicDefinition {
- registerDefaultGraphics();
- return graphicsRegistry.get(definitionId);
-}
-
-export function buildDefaultGraphicInstance({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicInstance {
- const definition = getGraphicDefinition({ definitionId });
- return {
- definitionId,
- params: buildDefaultParamValues(definition.params),
- };
-}
-
-export function resolveGraphicParams(
- definition: GraphicDefinition,
- params?: ParamValues,
-): ParamValues {
- return {
- ...buildDefaultParamValues(definition.params),
- ...(params ?? {}),
- };
-}
-
-export function buildGraphicPreviewUrl({
- definitionId,
- params,
- size = DEFAULT_GRAPHIC_SOURCE_SIZE,
-}: {
- definitionId: string;
- params?: ParamValues;
- size?: number;
-}): string {
- const definition = getGraphicDefinition({ definitionId });
- const resolvedParams = resolveGraphicParams(definition, params);
- const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
- const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
- if (cachedUrl) {
- return cachedUrl;
- }
-
- if (typeof document === "undefined") {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- definition.render({
- ctx,
- params: resolvedParams,
- width: size,
- height: size,
- });
-
- const previewUrl = canvas.toDataURL("image/png");
- graphicPreviewUrlCache.set(cacheKey, previewUrl);
- return previewUrl;
-}
-
-export {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- ellipseGraphicDefinition,
- graphicsRegistry,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- registerDefaultGraphics,
- starGraphicDefinition,
-};
-export type {
- GraphicDefinition,
- GraphicInstance,
- GraphicRenderContext,
-} from "./types";
diff --git a/apps/web/src/lib/graphics/registry.ts b/apps/web/src/lib/graphics/registry.ts
deleted file mode 100644
index 2f6a5850..00000000
--- a/apps/web/src/lib/graphics/registry.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { DefinitionRegistry } from "@/lib/registry";
-import type { GraphicDefinition } from "./types";
-
-export class GraphicsRegistry extends DefinitionRegistry<string, GraphicDefinition> {
- constructor() {
- super("graphic");
- }
-}
-
-export const graphicsRegistry = new GraphicsRegistry();
diff --git a/apps/web/src/lib/graphics/stroke.ts b/apps/web/src/lib/graphics/stroke.ts
deleted file mode 100644
index 230d9904..00000000
--- a/apps/web/src/lib/graphics/stroke.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index 22bfc5ac..00000000
--- a/apps/web/src/lib/graphics/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index fe7944cb..00000000
--- a/apps/web/src/lib/guides/definitions/custom.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { PlusSignIcon, RulerIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Button } from "@/components/ui/button";
-import type { GuideDefinition } from "@/lib/guides/types";
-
-function CustomGuideOptions() {
- return (
- <div className="flex gap-2">
- <Button variant="outline" size="sm" className="flex-1">
- <HugeiconsIcon icon={PlusSignIcon} />
- Add guide line
- </Button>
- </div>
- );
-}
-
-export const customGuide = {
- id: "custom",
- label: "Custom",
- renderPreview: () => <HugeiconsIcon size={16} icon={RulerIcon} />,
- renderTriggerIcon: () => <HugeiconsIcon icon={RulerIcon} />,
- renderOverlay: () => null,
- renderOptions: () => <CustomGuideOptions />,
-} as const satisfies GuideDefinition;
diff --git a/apps/web/src/lib/guides/definitions/grid.tsx b/apps/web/src/lib/guides/definitions/grid.tsx
deleted file mode 100644
index 7677a1d9..00000000
--- a/apps/web/src/lib/guides/definitions/grid.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-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 "@/lib/guides/grid";
-import { usePreviewStore } from "@/stores/preview-store";
-import { clampRound } from "@/utils/math";
-import { cn } from "@/utils/ui";
-import type { GuideDefinition } from "@/lib/guides/types";
-
-function GridLines({
- rows,
- cols,
- color,
-}: {
- rows: number;
- cols: number;
- color: string;
-}) {
- const verticals = Array.from(
- { length: cols - 1 },
- (_, i) => ((i + 1) / cols) * 100,
- );
- const horizontals = Array.from(
- { length: rows - 1 },
- (_, i) => ((i + 1) / rows) * 100,
- );
-
- return (
- <>
- {verticals.map((pct) => (
- <div
- key={`v-${pct}`}
- className={cn("absolute top-0 bottom-0 w-px", color)}
- style={{ left: `${pct}%` }}
- />
- ))}
- {horizontals.map((pct) => (
- <div
- key={`h-${pct}`}
- className={cn("absolute left-0 right-0 h-px", color)}
- style={{ top: `${pct}%` }}
- />
- ))}
- </>
- );
-}
-
-function GridGuidePreview() {
- return (
- <div className="relative aspect-video w-full">
- <GridLines rows={3} cols={4} color="bg-foreground/15" />
- </div>
- );
-}
-
-function GridGuideOverlay() {
- const { rows, cols } = usePreviewStore((s) => s.gridConfig);
-
- return (
- <div className="absolute inset-0">
- <GridLines rows={rows} cols={cols} color="bg-white/35" />
- </div>
- );
-}
-
-function GridGuideOptions() {
- const rows = usePreviewStore((s) => s.gridConfig.rows);
- const cols = usePreviewStore((s) => s.gridConfig.cols);
- const setGridConfig = usePreviewStore((s) => s.setGridConfig);
-
- const clampGridValue = (value: number) =>
- clampRound({ value, min: GRID_MIN, max: GRID_MAX });
-
- return (
- <div className="flex gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={LayoutThreeRowIcon} />}
- value={rows}
- min={GRID_MIN}
- max={GRID_MAX}
- isDefault={rows === DEFAULT_GRID_CONFIG.rows}
- onReset={() => setGridConfig({ rows: DEFAULT_GRID_CONFIG.rows })}
- onScrub={(value) => setGridConfig({ rows: clampGridValue(value) })}
- onChange={(event) => {
- const parsed = Number.parseInt(event.target.value, 10);
- if (!Number.isNaN(parsed))
- setGridConfig({ rows: clampGridValue(parsed) });
- }}
- className="flex-1"
- />
- <NumberField
- icon={<HugeiconsIcon icon={LayoutThreeColumnIcon} />}
- value={cols}
- min={GRID_MIN}
- max={GRID_MAX}
- isDefault={cols === DEFAULT_GRID_CONFIG.cols}
- onReset={() => setGridConfig({ cols: DEFAULT_GRID_CONFIG.cols })}
- onScrub={(value) => setGridConfig({ cols: clampGridValue(value) })}
- onChange={(event) => {
- const parsed = Number.parseInt(event.target.value, 10);
- if (!Number.isNaN(parsed))
- setGridConfig({ cols: clampGridValue(parsed) });
- }}
- className="flex-1"
- />
- </div>
- );
-}
-
-export const gridGuide = {
- id: "grid",
- label: "Grid",
- renderPreview: () => <GridGuidePreview />,
- renderTriggerIcon: () => <HugeiconsIcon icon={GridTableIcon} />,
- renderOverlay: () => <GridGuideOverlay />,
- renderOptions: () => <GridGuideOptions />,
-} as const satisfies GuideDefinition;
diff --git a/apps/web/src/lib/guides/definitions/platforms.tsx b/apps/web/src/lib/guides/definitions/platforms.tsx
deleted file mode 100644
index 8c403eb1..00000000
--- a/apps/web/src/lib/guides/definitions/platforms.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import Image from "next/image";
-import type { GuideDefinition } from "@/lib/guides/types";
-import { TikTokLayout } from "./tiktok-layout";
-
-function PlatformLogo({
- domain,
- className = "size-4",
-}: {
- domain: string;
- className?: string;
-}) {
- return (
- <Image
- src={`https://cdn.brandfetch.io/${domain}/w/64/h/64`}
- alt=""
- width={18}
- height={18}
- className={className}
- draggable={false}
- unoptimized
- />
- );
-}
-
-function PlatformGuidePreview({ domain }: { domain: string }) {
- return <PlatformLogo domain={domain} />;
-}
-
-function platformGuide({
- id,
- label,
- domain,
-}: {
- id: string;
- label: string;
- domain: string;
-}): GuideDefinition {
- return {
- id,
- label,
- renderPreview: () => <PlatformGuidePreview domain={domain} />,
- renderTriggerIcon: () => <PlatformLogo domain={domain} />,
- renderOverlay: () => null,
- };
-}
-
-export const tiktokGuide: GuideDefinition = {
- ...platformGuide({ id: "tiktok", label: "TikTok", domain: "tiktok.com" }),
- renderOverlay: () => <TikTokLayout />,
-};
-export const igReelsGuide = platformGuide({
- id: "ig-reels",
- label: "Reels",
- domain: "instagram.com",
-});
-export const ytShortsGuide = platformGuide({
- id: "yt-shorts",
- label: "Shorts",
- domain: "youtube.com",
-});
-export const spotlightGuide = platformGuide({
- id: "spotlight",
- label: "Spotlight",
- domain: "snapchat.com",
-});
diff --git a/apps/web/src/lib/guides/definitions/tiktok-layout.tsx b/apps/web/src/lib/guides/definitions/tiktok-layout.tsx
deleted file mode 100644
index b7513bac..00000000
--- a/apps/web/src/lib/guides/definitions/tiktok-layout.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-export function TikTokLayout() {
- return (
- <svg
- width="100%"
- height="100%"
- viewBox="0 0 1080 1920"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <title>TikTok Layout</title>
- <path
- d="M56.2488 1751.28H51.6355C51.458 1750.3 51.1278 1749.43 50.6447 1748.68C50.1617 1747.93 49.5702 1747.3 48.8704 1746.77C48.1704 1746.25 47.3868 1745.85 46.5194 1745.59C45.6617 1745.32 44.7498 1745.19 43.7838 1745.19C42.0391 1745.19 40.4766 1745.63 39.0965 1746.5C37.7264 1747.38 36.642 1748.67 35.8435 1750.36C35.055 1752.06 34.6605 1754.13 34.6605 1756.57C34.6605 1759.04 35.055 1761.12 35.8435 1762.81C36.642 1764.51 37.7313 1765.79 39.1114 1766.65C40.4914 1767.52 42.044 1767.95 43.7689 1767.95C44.7253 1767.95 45.6322 1767.83 46.4897 1767.57C47.3571 1767.3 48.141 1766.91 48.8408 1766.4C49.5408 1765.89 50.1321 1765.26 50.6151 1764.53C51.108 1763.78 51.4481 1762.92 51.6355 1761.95L56.2488 1761.97C56.0023 1763.46 55.5243 1764.83 54.8146 1766.08C54.1145 1767.32 53.2125 1768.39 52.1086 1769.3C51.0143 1770.2 49.7625 1770.89 48.3528 1771.38C46.9431 1771.88 45.4053 1772.12 43.7395 1772.12C41.1173 1772.12 38.7811 1771.5 36.7307 1770.26C34.6803 1769.01 33.0636 1767.22 31.8808 1764.9C30.7077 1762.57 30.1211 1759.79 30.1211 1756.57C30.1211 1753.34 30.7126 1750.57 31.8954 1748.25C33.0785 1745.92 34.6951 1744.14 36.7455 1742.9C38.796 1741.65 41.1272 1741.02 43.7395 1741.02C45.3462 1741.02 46.8447 1741.25 48.2347 1741.72C49.6342 1742.17 50.8912 1742.84 52.005 1743.73C53.1191 1744.6 54.0406 1745.68 54.7701 1746.95C55.4995 1748.21 55.9924 1749.66 56.2488 1751.28ZM61.3612 1771.71V1749H65.6344V1752.61H65.8709C66.285 1751.39 67.0144 1750.43 68.0596 1749.73C69.1143 1749.02 70.307 1748.66 71.6379 1748.66C71.9137 1748.66 72.239 1748.67 72.6138 1748.69C72.9982 1748.71 73.2987 1748.74 73.5155 1748.77V1753C73.3383 1752.95 73.0229 1752.89 72.5693 1752.83C72.1159 1752.76 71.6623 1752.73 71.209 1752.73C70.1641 1752.73 69.2325 1752.95 68.4143 1753.39C67.606 1753.83 66.9653 1754.43 66.4921 1755.21C66.019 1755.98 65.7825 1756.86 65.7825 1757.84V1771.71H61.3612ZM86.317 1772.17C84.0792 1772.17 82.1521 1771.69 80.5355 1770.73C78.9288 1769.77 77.6867 1768.41 76.8094 1766.67C75.9417 1764.91 75.5082 1762.86 75.5082 1760.5C75.5082 1758.18 75.9417 1756.13 76.8094 1754.36C77.6867 1752.58 78.909 1751.2 80.4764 1750.2C82.0535 1749.2 83.8971 1748.71 86.0066 1748.71C87.288 1748.71 88.5301 1748.92 89.7327 1749.34C90.9353 1749.77 92.0148 1750.43 92.9709 1751.34C93.927 1752.25 94.6812 1753.42 95.2332 1754.87C95.7851 1756.31 96.0612 1758.06 96.0612 1760.12V1761.69H78.007V1758.38H91.7288C91.7288 1757.21 91.4922 1756.18 91.0191 1755.29C90.546 1754.38 89.8805 1753.66 89.023 1753.14C88.1752 1752.62 87.1795 1752.36 86.036 1752.36C84.7941 1752.36 83.7097 1752.67 82.783 1753.28C81.8664 1753.88 81.1565 1754.67 80.6537 1755.64C80.1608 1756.61 79.9146 1757.66 79.9146 1758.79V1761.38C79.9146 1762.89 80.1805 1764.19 80.713 1765.25C81.2551 1766.31 82.0093 1767.13 82.9753 1767.69C83.9413 1768.24 85.07 1768.52 86.3613 1768.52C87.1993 1768.52 87.9633 1768.4 88.6532 1768.16C89.3434 1767.92 89.9396 1767.55 90.4424 1767.07C90.9452 1766.58 91.3295 1765.99 91.5958 1765.28L95.7802 1766.03C95.4453 1767.27 94.8438 1768.34 93.9765 1769.27C93.1187 1770.19 92.0392 1770.9 90.738 1771.41C89.4467 1771.92 87.973 1772.17 86.317 1772.17ZM107.576 1772.21C106.137 1772.21 104.836 1771.95 103.673 1771.41C102.509 1770.87 101.588 1770.09 100.907 1769.06C100.237 1768.04 99.9021 1766.78 99.9021 1765.29C99.9021 1764.01 100.148 1762.96 100.641 1762.13C101.134 1761.3 101.8 1760.65 102.638 1760.16C103.476 1759.68 104.412 1759.32 105.447 1759.07C106.482 1758.82 107.537 1758.64 108.611 1758.51C109.972 1758.35 111.076 1758.22 111.923 1758.12C112.771 1758.02 113.387 1757.84 113.772 1757.61C114.156 1757.37 114.348 1756.99 114.348 1756.45V1756.35C114.348 1755.06 113.984 1754.06 113.254 1753.35C112.535 1752.64 111.46 1752.29 110.031 1752.29C108.542 1752.29 107.369 1752.62 106.511 1753.28C105.664 1753.93 105.077 1754.65 104.752 1755.45L100.597 1754.5C101.09 1753.12 101.809 1752.01 102.756 1751.16C103.712 1750.31 104.811 1749.68 106.053 1749.3C107.295 1748.91 108.601 1748.71 109.972 1748.71C110.879 1748.71 111.84 1748.82 112.855 1749.03C113.88 1749.24 114.836 1749.62 115.724 1750.19C116.621 1750.75 117.355 1751.55 117.927 1752.59C118.498 1753.63 118.784 1754.98 118.784 1756.63V1771.71H114.467V1768.61H114.289C114.003 1769.18 113.574 1769.74 113.003 1770.29C112.431 1770.84 111.697 1771.3 110.8 1771.66C109.903 1772.03 108.828 1772.21 107.576 1772.21ZM108.537 1768.67C109.76 1768.67 110.804 1768.42 111.672 1767.94C112.549 1767.46 113.215 1766.83 113.668 1766.05C114.132 1765.26 114.363 1764.42 114.363 1763.52V1760.59C114.206 1760.75 113.9 1760.9 113.446 1761.04C113.003 1761.16 112.495 1761.28 111.923 1761.38C111.352 1761.46 110.795 1761.55 110.252 1761.63C109.71 1761.7 109.257 1761.76 108.892 1761.8C108.035 1761.91 107.251 1762.1 106.541 1762.35C105.841 1762.61 105.279 1762.98 104.856 1763.46C104.442 1763.93 104.234 1764.57 104.234 1765.35C104.234 1766.45 104.639 1767.27 105.447 1767.84C106.255 1768.39 107.285 1768.67 108.537 1768.67ZM135.275 1749V1752.55H122.869V1749H135.275ZM126.196 1743.56H130.617V1765.04C130.617 1765.9 130.745 1766.54 131.001 1766.98C131.258 1767.4 131.588 1767.69 131.992 1767.85C132.406 1768 132.855 1768.07 133.338 1768.07C133.693 1768.07 134.003 1768.05 134.269 1768C134.536 1767.95 134.742 1767.91 134.891 1767.88L135.689 1771.53C135.433 1771.63 135.068 1771.73 134.595 1771.83C134.121 1771.94 133.53 1772 132.82 1772.01C131.657 1772.02 130.573 1771.82 129.567 1771.38C128.562 1770.95 127.748 1770.28 127.128 1769.37C126.507 1768.47 126.196 1767.33 126.196 1765.96V1743.56ZM149.488 1772.17C147.359 1772.17 145.501 1771.68 143.914 1770.7C142.327 1769.73 141.094 1768.36 140.217 1766.61C139.34 1764.86 138.901 1762.81 138.901 1760.46C138.901 1758.11 139.34 1756.04 140.217 1754.28C141.094 1752.52 142.327 1751.15 143.914 1750.17C145.501 1749.2 147.359 1748.71 149.488 1748.71C151.618 1748.71 153.476 1749.2 155.063 1750.17C156.65 1751.15 157.882 1752.52 158.759 1754.28C159.637 1756.04 160.075 1758.11 160.075 1760.46C160.075 1762.81 159.637 1764.86 158.759 1766.61C157.882 1768.36 156.65 1769.73 155.063 1770.7C153.476 1771.68 151.618 1772.17 149.488 1772.17ZM149.503 1768.46C150.883 1768.46 152.027 1768.09 152.934 1767.36C153.841 1766.63 154.511 1765.66 154.944 1764.45C155.388 1763.24 155.61 1761.9 155.61 1760.44C155.61 1759 155.388 1757.67 154.944 1756.45C154.511 1755.23 153.841 1754.25 152.934 1753.51C152.027 1752.77 150.883 1752.41 149.503 1752.41C148.113 1752.41 146.96 1752.77 146.043 1753.51C145.136 1754.25 144.461 1755.23 144.017 1756.45C143.584 1757.67 143.367 1759 143.367 1760.44C143.367 1761.9 143.584 1763.24 144.017 1764.45C144.461 1765.66 145.136 1766.63 146.043 1767.36C146.96 1768.09 148.113 1768.46 149.503 1768.46ZM165.01 1771.71V1749H169.284V1752.61H169.52C169.934 1751.39 170.664 1750.43 171.709 1749.73C172.763 1749.02 173.956 1748.66 175.287 1748.66C175.563 1748.66 175.888 1748.67 176.263 1748.69C176.647 1748.71 176.948 1748.74 177.165 1748.77V1753C176.987 1752.95 176.672 1752.89 176.218 1752.83C175.765 1752.76 175.311 1752.73 174.858 1752.73C173.813 1752.73 172.882 1752.95 172.063 1753.39C171.255 1753.83 170.614 1754.43 170.141 1755.21C169.668 1755.98 169.432 1756.86 169.432 1757.84V1771.71H165.01Z"
- fill="white"
- />
- <path
- d="M43.4438 1811.44V1814.4H31.6738V1811.44H43.4438ZM35.1043 1806H38.5939V1827.64C38.5939 1828.63 38.7368 1829.37 39.0228 1829.86C39.3184 1830.34 39.6932 1830.67 40.1465 1830.84C40.6097 1830.99 41.0977 1831.07 41.6104 1831.07C41.9947 1831.07 42.3101 1831.05 42.5566 1831.01C42.803 1830.97 43.0003 1830.92 43.1481 1830.9L43.8578 1834.03C43.6212 1834.12 43.291 1834.21 42.8673 1834.3C42.4434 1834.39 41.906 1834.44 41.2554 1834.44C40.2699 1834.44 39.3036 1834.23 38.3573 1833.81C37.421 1833.38 36.6421 1832.74 36.0212 1831.87C35.4099 1831 35.1043 1829.91 35.1043 1828.59V1806ZM55.1775 1834.62C53.1271 1834.62 51.328 1834.13 49.7804 1833.16C48.2426 1832.18 47.04 1830.82 46.1726 1829.06C45.3149 1827.31 44.8863 1825.26 44.8863 1822.91C44.8863 1820.55 45.3149 1818.48 46.1726 1816.72C47.04 1814.96 48.2426 1813.59 49.7804 1812.61C51.328 1811.64 53.1271 1811.15 55.1775 1811.15C57.2279 1811.15 59.022 1811.64 60.5598 1812.61C62.1075 1813.59 63.31 1814.96 64.1675 1816.72C65.0352 1818.48 65.4687 1820.55 65.4687 1822.91C65.4687 1825.26 65.0352 1827.31 64.1675 1829.06C63.31 1830.82 62.1075 1832.18 60.5598 1833.16C59.022 1834.13 57.2279 1834.62 55.1775 1834.62ZM55.1775 1831.49C56.735 1831.49 58.0165 1831.09 59.022 1830.29C60.0274 1829.49 60.7716 1828.44 61.2547 1827.14C61.7377 1825.84 61.9792 1824.43 61.9792 1822.91C61.9792 1821.39 61.7377 1819.98 61.2547 1818.67C60.7716 1817.36 60.0274 1816.3 59.022 1815.49C58.0165 1814.68 56.735 1814.28 55.1775 1814.28C53.62 1814.28 52.3385 1814.68 51.333 1815.49C50.3277 1816.3 49.5834 1817.36 49.1004 1818.67C48.6173 1819.98 48.3758 1821.39 48.3758 1822.91C48.3758 1824.43 48.6173 1825.84 49.1004 1827.14C49.5834 1828.44 50.3277 1829.49 51.333 1830.29C52.3385 1831.09 53.62 1831.49 55.1775 1831.49ZM71.7869 1803.87V1834.15H68.2973V1803.87H71.7869ZM84.2561 1834.62C82.3636 1834.62 80.6926 1834.14 79.2436 1833.19C77.7945 1832.22 76.6609 1830.86 75.8427 1829.11C75.0245 1827.34 74.6154 1825.26 74.6154 1822.85C74.6154 1820.47 75.0245 1818.4 75.8427 1816.65C76.6609 1814.89 77.7995 1813.54 79.2584 1812.58C80.7173 1811.62 82.4029 1811.15 84.3154 1811.15C85.7941 1811.15 86.9621 1811.39 87.8198 1811.89C88.6872 1812.37 89.3477 1812.92 89.801 1813.54C90.2645 1814.15 90.6242 1814.66 90.8805 1815.05H91.1761V1803.87H94.6657V1834.15H91.2945V1830.66H90.8805C90.6242 1831.07 90.2596 1831.59 89.7862 1832.22C89.3131 1832.85 88.638 1833.4 87.7605 1833.89C86.8832 1834.38 85.7152 1834.62 84.2561 1834.62ZM84.7294 1831.49C86.129 1831.49 87.3121 1831.12 88.2781 1830.39C89.2441 1829.65 89.9785 1828.63 90.4813 1827.33C90.9841 1826.02 91.2355 1824.51 91.2355 1822.79C91.2355 1821.1 90.989 1819.62 90.4961 1818.34C90.0032 1817.06 89.2738 1816.07 88.3078 1815.36C87.3418 1814.64 86.1488 1814.28 84.7294 1814.28C83.2508 1814.28 82.0185 1814.66 81.0327 1815.42C80.0568 1816.17 79.3224 1817.19 78.8295 1818.48C78.3465 1819.76 78.105 1821.2 78.105 1822.79C78.105 1824.41 78.3515 1825.88 78.8444 1827.2C79.3471 1828.51 80.0865 1829.56 81.0624 1830.33C82.0482 1831.1 83.2705 1831.49 84.7294 1831.49ZM108.256 1834.15V1811.44H111.627V1814.99H111.923C112.396 1813.78 113.16 1812.84 114.215 1812.17C115.27 1811.49 116.536 1811.15 118.015 1811.15C119.513 1811.15 120.76 1811.49 121.756 1812.17C122.761 1812.84 123.545 1813.78 124.107 1814.99H124.343C124.925 1813.82 125.797 1812.89 126.961 1812.19C128.124 1811.5 129.519 1811.15 131.145 1811.15C133.176 1811.15 134.837 1811.78 136.128 1813.05C137.419 1814.32 138.065 1816.28 138.065 1818.95V1834.15H134.576V1818.95C134.576 1817.28 134.117 1816.08 133.2 1815.36C132.284 1814.64 131.204 1814.28 129.962 1814.28C128.365 1814.28 127.128 1814.76 126.251 1815.73C125.373 1816.68 124.935 1817.9 124.935 1819.36V1834.15H121.386V1818.6C121.386 1817.31 120.967 1816.27 120.129 1815.48C119.291 1814.68 118.212 1814.28 116.891 1814.28C115.984 1814.28 115.136 1814.52 114.348 1815C113.569 1815.49 112.938 1816.16 112.455 1817.02C111.982 1817.86 111.745 1818.84 111.745 1819.96V1834.15H108.256ZM143.382 1842.66C142.79 1842.66 142.263 1842.61 141.799 1842.51C141.336 1842.43 141.016 1842.34 140.838 1842.25L141.725 1839.17C142.573 1839.39 143.322 1839.47 143.973 1839.41C144.624 1839.35 145.2 1839.06 145.703 1838.54C146.216 1838.03 146.684 1837.19 147.108 1836.04L147.758 1834.26L139.36 1811.44H143.145L149.414 1829.53H149.651L155.92 1811.44H159.706L150.065 1837.46C149.631 1838.63 149.094 1839.6 148.453 1840.37C147.812 1841.15 147.068 1841.73 146.221 1842.1C145.383 1842.47 144.436 1842.66 143.382 1842.66ZM177.901 1816.53L174.766 1817.41C174.569 1816.89 174.278 1816.38 173.894 1815.89C173.519 1815.39 173.007 1814.98 172.356 1814.65C171.705 1814.33 170.873 1814.16 169.857 1814.16C168.467 1814.16 167.309 1814.48 166.382 1815.12C165.466 1815.75 165.007 1816.56 165.007 1817.53C165.007 1818.4 165.323 1819.08 165.954 1819.59C166.584 1820.09 167.57 1820.51 168.911 1820.84L172.282 1821.67C174.313 1822.16 175.826 1822.92 176.821 1823.93C177.817 1824.94 178.315 1826.23 178.315 1827.82C178.315 1829.12 177.941 1830.28 177.191 1831.31C176.452 1832.33 175.417 1833.14 174.086 1833.73C172.755 1834.32 171.208 1834.62 169.443 1834.62C167.127 1834.62 165.209 1834.12 163.691 1833.11C162.173 1832.11 161.212 1830.64 160.808 1828.71L164.12 1827.88C164.436 1829.1 165.032 1830.02 165.909 1830.63C166.796 1831.24 167.955 1831.55 169.384 1831.55C171.011 1831.55 172.302 1831.2 173.258 1830.51C174.224 1829.81 174.707 1828.97 174.707 1828C174.707 1827.21 174.431 1826.55 173.879 1826.02C173.327 1825.48 172.479 1825.07 171.336 1824.8L167.55 1823.92C165.471 1823.43 163.943 1822.66 162.967 1821.63C162.001 1820.58 161.518 1819.28 161.518 1817.71C161.518 1816.43 161.877 1815.3 162.597 1814.31C163.326 1813.32 164.317 1812.55 165.569 1811.99C166.831 1811.43 168.26 1811.15 169.857 1811.15C172.105 1811.15 173.869 1811.64 175.151 1812.62C176.442 1813.61 177.359 1814.91 177.901 1816.53ZM190.592 1834.62C188.404 1834.62 186.516 1834.14 184.929 1833.17C183.352 1832.2 182.134 1830.84 181.277 1829.09C180.429 1827.34 180.005 1825.3 180.005 1822.97C180.005 1820.65 180.429 1818.6 181.277 1816.82C182.134 1815.04 183.327 1813.65 184.855 1812.66C186.393 1811.65 188.187 1811.15 190.237 1811.15C191.42 1811.15 192.588 1811.34 193.742 1811.74C194.895 1812.13 195.945 1812.77 196.891 1813.66C197.838 1814.54 198.591 1815.7 199.153 1817.15C199.715 1818.6 199.996 1820.38 199.996 1822.5V1823.98H182.489V1820.96H196.448C196.448 1819.68 196.191 1818.54 195.679 1817.53C195.176 1816.53 194.456 1815.73 193.52 1815.15C192.593 1814.57 191.499 1814.28 190.237 1814.28C188.847 1814.28 187.645 1814.62 186.629 1815.31C185.624 1816 184.85 1816.88 184.308 1817.98C183.766 1819.07 183.495 1820.24 183.495 1821.49V1823.5C183.495 1825.22 183.79 1826.67 184.382 1827.86C184.983 1829.05 185.816 1829.95 186.881 1830.57C187.945 1831.18 189.182 1831.49 190.592 1831.49C191.509 1831.49 192.337 1831.36 193.076 1831.1C193.825 1830.84 194.471 1830.44 195.013 1829.92C195.555 1829.39 195.974 1828.73 196.27 1827.94L199.641 1828.88C199.287 1830.03 198.69 1831.03 197.852 1831.9C197.014 1832.76 195.979 1833.43 194.747 1833.91C193.515 1834.38 192.13 1834.62 190.592 1834.62ZM206.296 1803.87V1834.15H202.806V1803.87H206.296ZM220.303 1811.44V1814.4H208.06V1811.44H220.303ZM211.727 1834.15V1808.31C211.727 1807.01 212.032 1805.92 212.643 1805.06C213.255 1804.19 214.048 1803.54 215.024 1803.1C216 1802.67 217.03 1802.45 218.114 1802.45C218.972 1802.45 219.672 1802.52 220.214 1802.66C220.756 1802.8 221.161 1802.93 221.427 1803.04L220.421 1806.06C220.244 1806 219.997 1805.93 219.682 1805.84C219.376 1805.75 218.972 1805.71 218.469 1805.71C217.316 1805.71 216.483 1806 215.97 1806.58C215.468 1807.16 215.216 1808.01 215.216 1809.14V1834.15H211.727ZM241.134 1834.62C239.084 1834.62 237.285 1834.13 235.737 1833.16C234.2 1832.18 232.997 1830.82 232.129 1829.06C231.272 1827.31 230.843 1825.26 230.843 1822.91C230.843 1820.55 231.272 1818.48 232.129 1816.72C232.997 1814.96 234.2 1813.59 235.737 1812.61C237.285 1811.64 239.084 1811.15 241.134 1811.15C243.185 1811.15 244.979 1811.64 246.517 1812.61C248.064 1813.59 249.267 1814.96 250.125 1816.72C250.992 1818.48 251.426 1820.55 251.426 1822.91C251.426 1825.26 250.992 1827.31 250.125 1829.06C249.267 1830.82 248.064 1832.18 246.517 1833.16C244.979 1834.13 243.185 1834.62 241.134 1834.62ZM241.134 1831.49C242.692 1831.49 243.973 1831.09 244.979 1830.29C245.984 1829.49 246.729 1828.44 247.212 1827.14C247.695 1825.84 247.936 1824.43 247.936 1822.91C247.936 1821.39 247.695 1819.98 247.212 1818.67C246.729 1817.36 245.984 1816.3 244.979 1815.49C243.973 1814.68 242.692 1814.28 241.134 1814.28C239.577 1814.28 238.295 1814.68 237.29 1815.49C236.285 1816.3 235.54 1817.36 235.057 1818.67C234.574 1819.98 234.333 1821.39 234.333 1822.91C234.333 1824.43 234.574 1825.84 235.057 1827.14C235.54 1828.44 236.285 1829.49 237.29 1830.29C238.295 1831.09 239.577 1831.49 241.134 1831.49ZM257.744 1820.49V1834.15H254.254V1811.44H257.626V1814.99H257.921C258.454 1813.84 259.262 1812.91 260.345 1812.21C261.43 1811.5 262.83 1811.15 264.545 1811.15C266.083 1811.15 267.429 1811.46 268.582 1812.09C269.735 1812.71 270.632 1813.66 271.273 1814.93C271.915 1816.19 272.236 1817.79 272.236 1819.72V1834.15H268.746V1819.96C268.746 1818.17 268.282 1816.78 267.356 1815.79C266.43 1814.78 265.157 1814.28 263.541 1814.28C262.427 1814.28 261.43 1814.52 260.553 1815C259.686 1815.49 259.001 1816.19 258.498 1817.12C257.995 1818.05 257.744 1819.17 257.744 1820.49ZM285.635 1834.62C283.447 1834.62 281.56 1834.14 279.972 1833.17C278.395 1832.2 277.177 1830.84 276.319 1829.09C275.473 1827.34 275.049 1825.3 275.049 1822.97C275.049 1820.65 275.473 1818.6 276.319 1816.82C277.177 1815.04 278.369 1813.65 279.897 1812.66C281.435 1811.65 283.231 1811.15 285.281 1811.15C286.463 1811.15 287.631 1811.34 288.784 1811.74C289.937 1812.13 290.988 1812.77 291.933 1813.66C292.88 1814.54 293.635 1815.7 294.197 1817.15C294.759 1818.6 295.04 1820.38 295.04 1822.5V1823.98H277.531V1820.96H291.491C291.491 1819.68 291.236 1818.54 290.723 1817.53C290.218 1816.53 289.5 1815.73 288.563 1815.15C287.637 1814.57 286.541 1814.28 285.281 1814.28C283.892 1814.28 282.687 1814.62 281.672 1815.31C280.667 1816 279.894 1816.88 279.351 1817.98C278.809 1819.07 278.539 1820.24 278.539 1821.49V1823.5C278.539 1825.22 278.833 1826.67 279.426 1827.86C280.027 1829.05 280.86 1829.95 281.924 1830.57C282.989 1831.18 284.225 1831.49 285.635 1831.49C286.551 1831.49 287.379 1831.36 288.121 1831.1C288.868 1830.84 289.516 1830.44 290.057 1829.92C290.598 1829.39 291.017 1828.73 291.314 1827.94L294.684 1828.88C294.33 1830.03 293.734 1831.03 292.896 1831.9C292.058 1832.76 291.022 1833.43 289.791 1833.91C288.558 1834.38 287.173 1834.62 285.635 1834.62ZM316.586 1834.62C314.397 1834.62 312.508 1834.14 310.92 1833.17C309.343 1832.2 308.128 1830.84 307.269 1829.09C306.421 1827.34 305.997 1825.3 305.997 1822.97C305.997 1820.65 306.421 1818.6 307.269 1816.82C308.128 1815.04 309.32 1813.65 310.847 1812.66C312.386 1811.65 314.179 1811.15 316.229 1811.15C317.413 1811.15 318.582 1811.34 319.735 1811.74C320.888 1812.13 321.936 1812.77 322.884 1813.66C323.831 1814.54 324.583 1815.7 325.145 1817.15C325.707 1818.6 325.988 1820.38 325.988 1822.5V1823.98H308.482V1820.96H322.441C322.441 1819.68 322.184 1818.54 321.671 1817.53C321.169 1816.53 320.448 1815.73 319.514 1815.15C318.584 1814.57 317.491 1814.28 316.229 1814.28C314.84 1814.28 313.637 1814.62 312.622 1815.31C311.615 1816 310.842 1816.88 310.301 1817.98C309.757 1819.07 309.486 1820.24 309.486 1821.49V1823.5C309.486 1825.22 309.783 1826.67 310.374 1827.86C310.975 1829.05 311.808 1829.95 312.872 1830.57C313.937 1831.18 315.175 1831.49 316.586 1831.49C317.502 1831.49 318.329 1831.36 319.069 1831.1C319.818 1830.84 320.463 1830.44 321.005 1829.92C321.549 1829.39 321.968 1828.73 322.262 1827.94L325.634 1828.88C325.278 1830.03 324.682 1831.03 323.844 1831.9C323.006 1832.76 321.973 1833.43 320.739 1833.91C319.508 1834.38 318.121 1834.62 316.586 1834.62ZM328.799 1842.66V1811.44H332.169V1815.05H332.583C332.84 1814.66 333.194 1814.15 333.65 1813.54C334.113 1812.92 334.771 1812.37 335.63 1811.89C336.497 1811.39 337.671 1811.15 339.149 1811.15C341.061 1811.15 342.748 1811.62 344.205 1812.58C345.665 1813.54 346.802 1814.89 347.622 1816.65C348.439 1818.4 348.848 1820.47 348.848 1822.85C348.848 1825.26 348.439 1827.34 347.622 1829.11C346.802 1830.86 345.67 1832.22 344.221 1833.19C342.771 1834.14 341.1 1834.62 339.209 1834.62C337.749 1834.62 336.58 1834.38 335.703 1833.89C334.826 1833.4 334.152 1832.85 333.678 1832.22C333.205 1831.59 332.84 1831.07 332.583 1830.66H332.289V1842.66H328.799ZM332.229 1822.79C332.229 1824.51 332.481 1826.02 332.984 1827.33C333.486 1828.63 334.22 1829.65 335.185 1830.39C336.153 1831.12 337.335 1831.49 338.735 1831.49C340.195 1831.49 341.413 1831.1 342.389 1830.33C343.372 1829.56 344.112 1828.51 344.606 1827.2C345.108 1825.88 345.361 1824.41 345.361 1822.79C345.361 1821.2 345.113 1819.76 344.619 1818.48C344.138 1817.19 343.404 1816.17 342.417 1815.42C341.441 1814.66 340.213 1814.28 338.735 1814.28C337.317 1814.28 336.122 1814.64 335.157 1815.36C334.191 1816.07 333.462 1817.06 332.968 1818.34C332.476 1819.62 332.229 1821.1 332.229 1822.79ZM351.674 1834.15V1811.44H355.164V1834.15H351.674ZM353.449 1807.66C352.767 1807.66 352.182 1807.43 351.69 1806.96C351.206 1806.5 350.964 1805.94 350.964 1805.29C350.964 1804.64 351.206 1804.09 351.69 1803.62C352.182 1803.16 352.767 1802.93 353.449 1802.93C354.128 1802.93 354.711 1803.16 355.193 1803.62C355.687 1804.09 355.932 1804.64 355.932 1805.29C355.932 1805.94 355.687 1806.5 355.193 1806.96C354.711 1807.43 354.128 1807.66 353.449 1807.66ZM375.145 1816.53L372.009 1817.41C371.812 1816.89 371.523 1816.38 371.138 1815.89C370.763 1815.39 370.25 1814.98 369.599 1814.65C368.949 1814.33 368.116 1814.16 367.101 1814.16C365.711 1814.16 364.553 1814.48 363.624 1815.12C362.708 1815.75 362.25 1816.56 362.25 1817.53C362.25 1818.4 362.565 1819.08 363.198 1819.59C363.827 1820.09 364.814 1820.51 366.154 1820.84L369.527 1821.67C371.557 1822.16 373.069 1822.92 374.065 1823.93C375.059 1824.94 375.559 1826.23 375.559 1827.82C375.559 1829.12 375.184 1830.28 374.435 1831.31C373.696 1832.33 372.66 1833.14 371.33 1833.73C369.998 1834.32 368.452 1834.62 366.687 1834.62C364.369 1834.62 362.453 1834.12 360.933 1833.11C359.416 1832.11 358.456 1830.64 358.05 1828.71L361.363 1827.88C361.678 1829.1 362.276 1830.02 363.153 1830.63C364.041 1831.24 365.199 1831.55 366.628 1831.55C368.254 1831.55 369.545 1831.2 370.503 1830.51C371.468 1829.81 371.949 1828.97 371.949 1828C371.949 1827.21 371.674 1826.55 371.122 1826.02C370.57 1825.48 369.722 1825.07 368.579 1824.8L364.793 1823.92C362.714 1823.43 361.186 1822.66 360.21 1821.63C359.245 1820.58 358.76 1819.28 358.76 1817.71C358.76 1816.43 359.12 1815.3 359.84 1814.31C360.569 1813.32 361.561 1812.55 362.812 1811.99C364.075 1811.43 365.503 1811.15 367.101 1811.15C369.347 1811.15 371.111 1811.64 372.394 1812.62C373.685 1813.61 374.601 1814.91 375.145 1816.53ZM387.541 1834.62C385.49 1834.62 383.689 1834.13 382.143 1833.16C380.605 1832.18 379.403 1830.82 378.534 1829.06C377.677 1827.31 377.248 1825.26 377.248 1822.91C377.248 1820.55 377.677 1818.48 378.534 1816.72C379.403 1814.96 380.605 1813.59 382.143 1812.61C383.689 1811.64 385.49 1811.15 387.541 1811.15C389.591 1811.15 391.384 1811.64 392.922 1812.61C394.471 1813.59 395.673 1814.96 396.529 1816.72C397.398 1818.48 397.83 1820.55 397.83 1822.91C397.83 1825.26 397.398 1827.31 396.529 1829.06C395.673 1830.82 394.471 1832.18 392.922 1833.16C391.384 1834.13 389.591 1834.62 387.541 1834.62ZM387.541 1831.49C389.097 1831.49 390.38 1831.09 391.384 1830.29C392.389 1829.49 393.133 1828.44 393.617 1827.14C394.099 1825.84 394.341 1824.43 394.341 1822.91C394.341 1821.39 394.099 1819.98 393.617 1818.67C393.133 1817.36 392.389 1816.3 391.384 1815.49C390.38 1814.68 389.097 1814.28 387.541 1814.28C385.982 1814.28 384.701 1814.68 383.694 1815.49C382.69 1816.3 381.945 1817.36 381.461 1818.67C380.98 1819.98 380.738 1821.39 380.738 1822.91C380.738 1824.43 380.98 1825.84 381.461 1827.14C381.945 1828.44 382.69 1829.49 383.694 1830.29C384.701 1831.09 385.982 1831.49 387.541 1831.49ZM409.234 1834.62C407.342 1834.62 405.672 1834.14 404.222 1833.19C402.775 1832.22 401.64 1830.86 400.823 1829.11C400.003 1827.34 399.595 1825.26 399.595 1822.85C399.595 1820.47 400.003 1818.4 400.823 1816.65C401.64 1814.89 402.778 1813.54 404.238 1812.58C405.698 1811.62 407.381 1811.15 409.294 1811.15C410.772 1811.15 411.941 1811.39 412.8 1811.89C413.666 1812.37 414.327 1812.92 414.78 1813.54C415.243 1814.15 415.602 1814.66 415.86 1815.05H416.157V1803.87H419.644V1834.15H416.274V1830.66H415.86C415.602 1831.07 415.238 1831.59 414.764 1832.22C414.293 1832.85 413.617 1833.4 412.74 1833.89C411.863 1834.38 410.694 1834.62 409.234 1834.62ZM409.708 1831.49C411.108 1831.49 412.292 1831.12 413.258 1830.39C414.223 1829.65 414.957 1828.63 415.462 1827.33C415.964 1826.02 416.214 1824.51 416.214 1822.79C416.214 1821.1 415.969 1819.62 415.475 1818.34C414.983 1817.06 414.254 1816.07 413.286 1815.36C412.321 1814.64 411.129 1814.28 409.708 1814.28C408.23 1814.28 406.999 1814.66 406.012 1815.42C405.037 1816.17 404.303 1817.19 403.808 1818.48C403.327 1819.76 403.085 1821.2 403.085 1822.79C403.085 1824.41 403.332 1825.88 403.824 1827.2C404.326 1828.51 405.065 1829.56 406.041 1830.33C407.027 1831.1 408.251 1831.49 409.708 1831.49ZM433.546 1834.62C431.357 1834.62 429.468 1834.14 427.883 1833.17C426.303 1832.2 425.088 1830.84 424.229 1829.09C423.381 1827.34 422.957 1825.3 422.957 1822.97C422.957 1820.65 423.381 1818.6 424.229 1816.82C425.088 1815.04 426.28 1813.65 427.808 1812.66C429.346 1811.65 431.139 1811.15 433.189 1811.15C434.374 1811.15 435.542 1811.34 436.695 1811.74C437.848 1812.13 438.897 1812.77 439.844 1813.66C440.791 1814.54 441.546 1815.7 442.105 1817.15C442.667 1818.6 442.948 1820.38 442.948 1822.5V1823.98H425.442V1820.96H439.401C439.401 1819.68 439.144 1818.54 438.631 1817.53C438.129 1816.53 437.408 1815.73 436.474 1815.15C435.547 1814.57 434.452 1814.28 433.189 1814.28C431.8 1814.28 430.597 1814.62 429.582 1815.31C428.578 1816 427.802 1816.88 427.261 1817.98C426.72 1819.07 426.447 1820.24 426.447 1821.49V1823.5C426.447 1825.22 426.743 1826.67 427.334 1827.86C427.935 1829.05 428.768 1829.95 429.835 1830.57C430.899 1831.18 432.135 1831.49 433.546 1831.49C434.462 1831.49 435.29 1831.36 436.029 1831.1C436.778 1830.84 437.424 1830.44 437.967 1829.92C438.509 1829.39 438.928 1828.73 439.222 1827.94L442.595 1828.88C442.241 1830.03 441.642 1831.03 440.804 1831.9C439.966 1832.76 438.933 1833.43 437.699 1833.91C436.468 1834.38 435.084 1834.62 433.546 1834.62Z"
- fill="#E6E6E6"
- />
- <path
- d="M42.9097 1883.58L47.2568 1857.09H50.362L46.0149 1883.58H42.9097ZM30.916 1876.49L31.4206 1873.39H51.5005L50.9959 1876.49H30.916ZM33.5942 1883.58L37.9415 1857.09H41.0467L36.6993 1883.58H33.5942ZM32.4556 1867.28L32.9602 1864.18H53.0401L52.5358 1867.28H32.4556ZM66.1656 1883.94C64.3112 1883.94 62.6724 1883.62 61.2492 1882.98C59.8348 1882.35 58.7264 1881.47 57.9241 1880.35C57.122 1879.23 56.7251 1877.95 56.7337 1876.52C56.7251 1875.4 56.9539 1874.37 57.4195 1873.43C57.8939 1872.48 58.5364 1871.69 59.3473 1871.06C60.1582 1870.42 61.0639 1870.02 62.0642 1869.84V1869.69C60.7446 1869.37 59.688 1868.66 58.8945 1867.57C58.101 1866.47 57.7086 1865.21 57.7172 1863.79C57.7086 1862.44 58.0664 1861.23 58.7909 1860.17C59.524 1859.1 60.5291 1858.26 61.8056 1857.65C63.082 1857.04 64.5355 1856.73 66.1656 1856.73C67.7786 1856.73 69.219 1857.04 70.4869 1857.66C71.7637 1858.27 72.7685 1859.11 73.5016 1860.18C74.2347 1861.24 74.6058 1862.44 74.6144 1863.79C74.6058 1865.21 74.2003 1866.47 73.398 1867.57C72.5959 1868.66 71.5524 1869.37 70.267 1869.69V1869.84C71.2591 1870.02 72.1517 1870.42 72.9452 1871.06C73.7475 1871.69 74.3856 1872.48 74.86 1873.43C75.343 1874.37 75.589 1875.4 75.5975 1876.52C75.589 1877.95 75.1835 1879.23 74.3814 1880.35C73.5791 1881.47 72.4666 1882.35 71.0433 1882.98C69.6289 1883.62 68.0029 1883.94 66.1656 1883.94ZM66.1656 1880.67C67.2613 1880.67 68.2098 1880.49 69.0122 1880.13C69.8142 1879.76 70.4354 1879.24 70.8752 1878.59C71.315 1877.92 71.5394 1877.15 71.5479 1876.26C71.5394 1875.34 71.2978 1874.52 70.8234 1873.81C70.3576 1873.11 69.7237 1872.55 68.9216 1872.15C68.1193 1871.74 67.2009 1871.54 66.1656 1871.54C65.1221 1871.54 64.1948 1871.74 63.3839 1872.15C62.5733 1872.55 61.9349 1873.11 61.4691 1873.81C61.0035 1874.52 60.7747 1875.34 60.7833 1876.26C60.7747 1877.15 60.9861 1877.92 61.4175 1878.59C61.8574 1879.24 62.4827 1879.76 63.2934 1880.13C64.1043 1880.49 65.0617 1880.67 66.1656 1880.67ZM66.1656 1868.34C67.0455 1868.34 67.826 1868.17 68.5075 1867.81C69.1889 1867.46 69.7237 1866.97 70.1119 1866.34C70.5085 1865.71 70.7113 1864.97 70.7199 1864.13C70.7113 1863.3 70.513 1862.57 70.1247 1861.96C69.7453 1861.33 69.2149 1860.86 68.5333 1860.52C67.852 1860.17 67.0627 1860 66.1656 1860C65.2514 1860 64.4493 1860.17 63.7592 1860.52C63.0779 1860.86 62.5472 1861.33 62.1678 1861.96C61.7884 1862.57 61.6028 1863.3 61.6114 1864.13C61.6028 1864.97 61.7926 1865.71 62.1808 1866.34C62.5689 1866.97 63.1036 1867.46 63.7852 1867.81C64.4751 1868.17 65.2686 1868.34 66.1656 1868.34ZM85.246 1871.78V1883.58H81.3775V1857.09H85.1942V1866.95H85.4401C85.9057 1865.88 86.6174 1865.03 87.5749 1864.4C88.5323 1863.77 89.783 1863.45 91.3268 1863.45C92.6896 1863.45 93.88 1863.74 94.8978 1864.29C95.9242 1864.86 96.7177 1865.69 97.2785 1866.81C97.8476 1867.91 98.1323 1869.29 98.1323 1870.94C98.1323 1872.6 98.1323 1883.58 98.1323 1883.58H94.2638V1871.41C94.2638 1869.95 93.8886 1868.82 93.1383 1868.02C92.3878 1867.21 91.3442 1866.81 90.0071 1866.81C89.0929 1866.81 88.2734 1867 87.5488 1867.39C86.8329 1867.78 86.2679 1868.34 85.8539 1869.09C85.4487 1869.84 85.246 1870.73 85.246 1871.78ZM112.759 1883.98C110.896 1883.98 109.27 1883.55 107.881 1882.7C106.492 1881.85 105.414 1880.65 104.646 1879.12C103.879 1877.58 103.495 1875.79 103.495 1873.74C103.495 1871.68 103.879 1869.87 104.646 1868.33C105.414 1866.79 106.492 1865.59 107.881 1864.73C109.27 1863.88 110.896 1863.45 112.759 1863.45C114.622 1863.45 116.247 1863.88 117.636 1864.73C119.025 1865.59 120.103 1866.79 120.871 1868.33C121.638 1869.87 122.022 1871.68 122.022 1873.74C122.022 1875.79 121.638 1877.58 120.871 1879.12C120.103 1880.65 119.025 1881.85 117.636 1882.7C116.247 1883.55 114.622 1883.98 112.759 1883.98ZM112.771 1880.73C113.979 1880.73 114.98 1880.42 115.773 1879.78C116.567 1879.14 117.153 1878.29 117.533 1877.23C117.921 1876.17 118.115 1875 118.115 1873.73C118.115 1872.46 117.921 1871.29 117.533 1870.23C117.153 1869.16 116.567 1868.31 115.773 1867.66C114.98 1867.01 113.979 1866.69 112.771 1866.69C111.555 1866.69 110.546 1867.01 109.744 1867.66C108.951 1868.31 108.36 1869.16 107.972 1870.23C107.592 1871.29 107.402 1872.46 107.402 1873.73C107.402 1875 107.592 1876.17 107.972 1877.23C108.36 1878.29 108.951 1879.14 109.744 1879.78C110.546 1880.42 111.555 1880.73 112.771 1880.73ZM140.022 1875.34V1863.71H143.903V1883.58H140.1V1880.14H139.893C139.436 1881.2 138.703 1882.08 137.693 1882.79C136.693 1883.49 135.446 1883.84 133.954 1883.84C132.677 1883.84 131.548 1883.56 130.564 1883C129.59 1882.43 128.822 1881.59 128.261 1880.48C127.709 1879.36 127.433 1877.99 127.433 1876.35V1863.71H131.302V1875.88C131.302 1877.24 131.677 1878.32 132.427 1879.12C133.178 1879.92 134.153 1880.32 135.351 1880.32C136.076 1880.32 136.796 1880.14 137.512 1879.78C138.237 1879.42 138.836 1878.87 139.31 1878.14C139.793 1877.4 140.031 1876.47 140.022 1875.34ZM150.195 1883.58V1863.71H153.934V1866.87H154.141C154.503 1865.8 155.141 1864.96 156.055 1864.35C156.978 1863.73 158.022 1863.42 159.186 1863.42C159.428 1863.42 159.713 1863.43 160.04 1863.44C160.377 1863.46 160.64 1863.48 160.83 1863.51V1867.21C160.674 1867.16 160.398 1867.11 160.001 1867.06C159.605 1867 159.208 1866.97 158.811 1866.97C157.897 1866.97 157.082 1867.17 156.366 1867.55C155.659 1867.93 155.098 1868.46 154.684 1869.15C154.27 1869.82 154.063 1870.59 154.063 1871.45V1883.58H150.195ZM180.162 1868.56L176.656 1869.19C176.509 1868.74 176.276 1868.31 175.957 1867.9C175.647 1867.5 175.224 1867.17 174.689 1866.91C174.154 1866.65 173.486 1866.52 172.684 1866.52C171.588 1866.52 170.674 1866.77 169.941 1867.26C169.208 1867.74 168.841 1868.37 168.841 1869.13C168.841 1869.8 169.087 1870.33 169.579 1870.74C170.07 1871.14 170.864 1871.47 171.959 1871.73L175.116 1872.46C176.945 1872.88 178.308 1873.53 179.205 1874.41C180.102 1875.29 180.55 1876.43 180.55 1877.84C180.55 1879.03 180.205 1880.09 179.515 1881.02C178.834 1881.94 177.881 1882.67 176.656 1883.19C175.44 1883.72 174.029 1883.98 172.425 1883.98C170.2 1883.98 168.384 1883.51 166.978 1882.56C165.572 1881.6 164.71 1880.24 164.39 1878.48L168.13 1877.91C168.362 1878.89 168.841 1879.63 169.566 1880.13C170.29 1880.62 171.235 1880.86 172.399 1880.86C173.667 1880.86 174.681 1880.6 175.44 1880.08C176.199 1879.54 176.578 1878.89 176.578 1878.12C176.578 1877.5 176.345 1876.98 175.88 1876.56C175.423 1876.13 174.719 1875.81 173.771 1875.6L170.407 1874.86C168.552 1874.44 167.181 1873.77 166.293 1872.85C165.413 1871.92 164.973 1870.75 164.973 1869.34C164.973 1868.17 165.3 1867.14 165.956 1866.26C166.612 1865.38 167.517 1864.7 168.673 1864.21C169.829 1863.7 171.153 1863.45 172.645 1863.45C174.793 1863.45 176.483 1863.92 177.717 1864.85C178.95 1865.77 179.765 1867.01 180.162 1868.56ZM189.765 1857.09V1883.58H185.897V1857.09H189.765ZM201.846 1884.02C200.587 1884.02 199.448 1883.79 198.43 1883.32C197.412 1882.85 196.606 1882.16 196.011 1881.26C195.424 1880.37 195.131 1879.27 195.131 1877.97C195.131 1876.84 195.347 1875.92 195.778 1875.2C196.209 1874.47 196.791 1873.9 197.525 1873.48C198.258 1873.06 199.077 1872.74 199.983 1872.52C200.888 1872.3 201.811 1872.14 202.751 1872.03C203.942 1871.89 204.908 1871.78 205.65 1871.69C206.391 1871.6 206.93 1871.45 207.267 1871.24C207.603 1871.04 207.772 1870.7 207.772 1870.23V1870.14C207.772 1869.01 207.452 1868.14 206.814 1867.52C206.184 1866.9 205.244 1866.58 203.994 1866.58C202.691 1866.58 201.665 1866.87 200.914 1867.45C200.172 1868.02 199.659 1868.65 199.375 1869.35L195.739 1868.52C196.17 1867.32 196.8 1866.34 197.628 1865.6C198.465 1864.85 199.426 1864.31 200.513 1863.97C201.6 1863.63 202.743 1863.45 203.942 1863.45C204.735 1863.45 205.576 1863.55 206.465 1863.74C207.362 1863.92 208.199 1864.26 208.975 1864.75C209.76 1865.24 210.402 1865.94 210.902 1866.86C211.403 1867.76 211.653 1868.94 211.653 1870.39V1883.58H207.875V1880.86H207.72C207.47 1881.36 207.094 1881.86 206.594 1882.34C206.094 1882.82 205.451 1883.22 204.666 1883.54C203.881 1883.86 202.941 1884.02 201.846 1884.02ZM202.687 1880.92C203.756 1880.92 204.671 1880.71 205.43 1880.28C206.197 1879.86 206.779 1879.31 207.176 1878.63C207.582 1877.94 207.785 1877.2 207.785 1876.42V1873.85C207.646 1873.99 207.379 1874.12 206.982 1874.24C206.594 1874.36 206.15 1874.45 205.65 1874.54C205.149 1874.62 204.662 1874.69 204.188 1874.76C203.713 1874.82 203.316 1874.87 202.997 1874.91C202.247 1875.01 201.561 1875.17 200.94 1875.39C200.328 1875.62 199.836 1875.94 199.465 1876.36C199.103 1876.78 198.922 1877.33 198.922 1878.02C198.922 1878.98 199.275 1879.7 199.983 1880.19C200.69 1880.67 201.591 1880.92 202.687 1880.92ZM226.464 1883.98C224.506 1883.98 222.819 1883.56 221.405 1882.73C219.999 1881.88 218.912 1880.7 218.144 1879.17C217.385 1877.64 217.006 1875.84 217.006 1873.78C217.006 1871.74 217.385 1869.95 218.144 1868.39C218.912 1866.84 219.982 1865.63 221.353 1864.76C222.733 1863.89 224.346 1863.45 226.192 1863.45C227.313 1863.45 228.4 1863.64 229.452 1864.01C230.505 1864.38 231.449 1864.96 232.286 1865.76C233.122 1866.55 233.782 1867.58 234.265 1868.85C234.748 1870.11 234.99 1871.64 234.99 1873.44V1874.81H219.192V1871.91H231.199C231.199 1870.9 230.992 1869.99 230.578 1869.21C230.164 1868.42 229.582 1867.79 228.831 1867.34C228.09 1866.88 227.218 1866.65 226.218 1866.65C225.131 1866.65 224.182 1866.92 223.371 1867.45C222.569 1867.98 221.948 1868.67 221.508 1869.52C221.077 1870.37 220.861 1871.29 220.861 1872.28V1874.54C220.861 1875.87 221.094 1877 221.56 1877.93C222.035 1878.86 222.694 1879.57 223.54 1880.06C224.385 1880.54 225.372 1880.79 226.502 1880.79C227.235 1880.79 227.904 1880.68 228.508 1880.48C229.112 1880.26 229.633 1879.94 230.073 1879.52C230.513 1879.1 230.85 1878.57 231.083 1877.95L234.744 1878.61C234.451 1879.69 233.924 1880.64 233.166 1881.45C232.415 1882.25 231.471 1882.87 230.332 1883.32C229.202 1883.76 227.913 1883.98 226.464 1883.98ZM240.375 1883.58V1863.71H244.114V1866.87H244.321C244.683 1865.8 245.322 1864.96 246.236 1864.35C247.159 1863.73 248.203 1863.42 249.367 1863.42C249.609 1863.42 249.893 1863.43 250.221 1863.44C250.557 1863.46 250.82 1863.48 251.01 1863.51V1867.21C250.855 1867.16 250.579 1867.11 250.182 1867.06C249.785 1867 249.388 1866.97 248.992 1866.97C248.078 1866.97 247.262 1867.17 246.546 1867.55C245.839 1867.93 245.278 1868.46 244.864 1869.15C244.451 1869.82 244.243 1870.59 244.243 1871.45V1883.58H240.375ZM276.123 1883.58L280.469 1857.09H283.574L279.228 1883.58H276.123ZM264.129 1876.49L264.634 1873.39H284.714L284.209 1876.49H264.129ZM266.807 1883.58L271.155 1857.09H274.26L269.911 1883.58H266.807ZM265.669 1867.28L266.174 1864.18H286.252L285.75 1867.28H265.669ZM294.345 1871.78V1883.58H290.478V1863.71H294.189V1866.95H294.436C294.894 1865.89 295.61 1865.05 296.583 1864.41C297.567 1863.77 298.806 1863.45 300.297 1863.45C301.65 1863.45 302.837 1863.74 303.855 1864.31C304.872 1864.87 305.663 1865.7 306.223 1866.82C306.782 1867.93 307.063 1869.31 307.063 1870.94V1883.58H303.196V1871.41C303.196 1869.97 302.819 1868.84 302.069 1868.03C301.32 1867.21 300.289 1866.81 298.978 1866.81C298.08 1866.81 297.283 1867 296.583 1867.39C295.894 1867.78 295.347 1868.34 294.941 1869.09C294.543 1869.84 294.345 1870.73 294.345 1871.78ZM321.91 1883.98C319.953 1883.98 318.267 1883.56 316.851 1882.73C315.446 1881.88 314.358 1880.7 313.59 1879.17C312.833 1877.64 312.453 1875.84 312.453 1873.78C312.453 1871.74 312.833 1869.95 313.59 1868.39C314.358 1866.84 315.427 1865.63 316.799 1864.76C318.178 1863.89 319.792 1863.45 321.639 1863.45C322.758 1863.45 323.846 1863.64 324.898 1864.01C325.952 1864.38 326.896 1864.96 327.732 1865.76C328.57 1866.55 329.228 1867.58 329.712 1868.85C330.194 1870.11 330.436 1871.64 330.436 1873.44V1874.81H314.639V1871.91H326.646C326.646 1870.9 326.438 1869.99 326.024 1869.21C325.611 1868.42 325.028 1867.79 324.278 1867.34C323.537 1866.88 322.665 1866.65 321.665 1866.65C320.578 1866.65 319.628 1866.92 318.818 1867.45C318.017 1867.98 317.395 1868.67 316.955 1869.52C316.523 1870.37 316.307 1871.29 316.307 1872.28V1874.54C316.307 1875.87 316.541 1877 317.007 1877.93C317.481 1878.86 318.142 1879.57 318.985 1880.06C319.831 1880.54 320.82 1880.79 321.949 1880.79C322.683 1880.79 323.349 1880.68 323.953 1880.48C324.557 1880.26 325.08 1879.94 325.52 1879.52C325.959 1879.1 326.295 1878.57 326.529 1877.95L330.191 1878.61C329.897 1879.69 329.371 1880.64 328.611 1881.45C327.862 1882.25 326.917 1882.87 325.777 1883.32C324.648 1883.76 323.36 1883.98 321.91 1883.98ZM345.097 1863.71V1866.82H334.243V1863.71H345.097ZM337.155 1858.95H341.022V1877.75C341.022 1878.5 341.134 1879.06 341.358 1879.44C341.584 1879.81 341.873 1880.07 342.227 1880.2C342.589 1880.33 342.982 1880.4 343.403 1880.4C343.713 1880.4 343.986 1880.38 344.218 1880.33C344.452 1880.29 344.632 1880.26 344.762 1880.23L345.459 1883.42C345.235 1883.51 344.918 1883.6 344.504 1883.68C344.088 1883.78 343.572 1883.83 342.951 1883.84C341.933 1883.86 340.983 1883.67 340.103 1883.3C339.224 1882.92 338.513 1882.33 337.969 1881.54C337.426 1880.74 337.155 1879.75 337.155 1878.55V1858.95ZM359.929 1863.71V1866.82H348.697V1863.71H359.929ZM351.778 1883.58V1861.41C351.778 1860.17 352.049 1859.14 352.592 1858.32C353.136 1857.49 353.855 1856.87 354.752 1856.47C355.65 1856.06 356.624 1855.85 357.678 1855.85C358.453 1855.85 359.117 1855.91 359.668 1856.04C360.22 1856.16 360.631 1856.28 360.899 1856.38L359.994 1859.51C359.812 1859.46 359.58 1859.4 359.294 1859.33C359.01 1859.25 358.664 1859.21 358.258 1859.21C357.318 1859.21 356.647 1859.44 356.241 1859.91C355.843 1860.38 355.645 1861.05 355.645 1861.93V1883.58H351.778ZM368.881 1857.09V1883.58H365.014V1857.09H368.881ZM375.179 1883.58V1863.71H379.046V1883.58H375.179ZM377.133 1860.65C376.459 1860.65 375.881 1860.42 375.397 1859.98C374.924 1859.52 374.687 1858.97 374.687 1858.34C374.687 1857.71 374.924 1857.16 375.397 1856.72C375.881 1856.26 376.459 1856.03 377.133 1856.03C377.805 1856.03 378.377 1856.26 378.853 1856.72C379.335 1857.16 379.577 1857.71 379.577 1858.34C379.577 1858.97 379.335 1859.52 378.853 1859.98C378.377 1860.42 377.805 1860.65 377.133 1860.65ZM388.396 1863.71L392.784 1871.45L397.208 1863.71H401.44L395.241 1873.65L401.492 1883.58H397.26L392.784 1876.16L388.321 1883.58H384.076L390.26 1873.65L384.154 1863.71H388.396ZM427.175 1883.58L431.523 1857.09H434.628L430.282 1883.58H427.175ZM415.183 1876.49L415.688 1873.39H435.768L435.263 1876.49H415.183ZM417.861 1883.58L422.207 1857.09H425.311L420.965 1883.58H417.861ZM416.721 1867.28L417.226 1864.18H437.306L436.801 1867.28H416.721ZM445.4 1857.09V1883.58H441.53V1857.09H445.4ZM451.697 1883.58V1863.71H455.564V1883.58H451.697ZM453.649 1860.65C452.978 1860.65 452.4 1860.42 451.916 1859.98C451.442 1859.52 451.205 1858.97 451.205 1858.34C451.205 1857.71 451.442 1857.16 451.916 1856.72C452.4 1856.26 452.978 1856.03 453.649 1856.03C454.323 1856.03 454.896 1856.26 455.372 1856.72C455.853 1857.16 456.095 1857.71 456.095 1858.34C456.095 1858.97 455.853 1859.52 455.372 1859.98C454.896 1860.42 454.323 1860.65 453.649 1860.65ZM470.427 1883.98C468.47 1883.98 466.784 1883.56 465.368 1882.73C463.962 1881.88 462.875 1880.7 462.107 1879.17C461.35 1877.64 460.97 1875.84 460.97 1873.78C460.97 1871.74 461.35 1869.95 462.107 1868.39C462.875 1866.84 463.944 1865.63 465.316 1864.76C466.698 1863.89 468.309 1863.45 470.156 1863.45C471.278 1863.45 472.363 1863.64 473.417 1864.01C474.468 1864.38 475.413 1864.96 476.248 1865.76C477.086 1866.55 477.745 1867.58 478.229 1868.85C478.713 1870.11 478.952 1871.64 478.952 1873.44V1874.81H463.156V1871.91H475.163C475.163 1870.9 474.955 1869.99 474.541 1869.21C474.127 1868.42 473.545 1867.79 472.795 1867.34C472.053 1866.88 471.182 1866.65 470.182 1866.65C469.094 1866.65 468.145 1866.92 467.335 1867.45C466.534 1867.98 465.912 1868.67 465.472 1869.52C465.04 1870.37 464.824 1871.29 464.824 1872.28V1874.54C464.824 1875.87 465.058 1877 465.524 1877.93C465.998 1878.86 466.659 1879.57 467.504 1880.06C468.348 1880.54 469.336 1880.79 470.466 1880.79C471.2 1880.79 471.869 1880.68 472.472 1880.48C473.076 1880.26 473.597 1879.94 474.036 1879.52C474.476 1879.1 474.812 1878.57 475.046 1877.95L478.708 1878.61C478.414 1879.69 477.888 1880.64 477.128 1881.45C476.379 1882.25 475.434 1882.87 474.297 1883.32C473.165 1883.76 471.876 1883.98 470.427 1883.98ZM499.23 1868.56L495.725 1869.19C495.577 1868.74 495.345 1868.31 495.025 1867.9C494.715 1867.5 494.294 1867.17 493.757 1866.91C493.224 1866.65 492.555 1866.52 491.751 1866.52C490.658 1866.52 489.742 1866.77 489.008 1867.26C488.277 1867.74 487.91 1868.37 487.91 1869.13C487.91 1869.8 488.154 1870.33 488.646 1870.74C489.138 1871.14 489.932 1871.47 491.028 1871.73L494.184 1872.46C496.014 1872.88 497.375 1873.53 498.273 1874.41C499.17 1875.29 499.618 1876.43 499.618 1877.84C499.618 1879.03 499.275 1880.09 498.582 1881.02C497.903 1881.94 496.948 1882.67 495.725 1883.19C494.507 1883.72 493.096 1883.98 491.493 1883.98C489.268 1883.98 487.452 1883.51 486.047 1882.56C484.641 1881.6 483.777 1880.24 483.46 1878.48L487.197 1877.91C487.431 1878.89 487.91 1879.63 488.633 1880.13C489.359 1880.62 490.304 1880.86 491.467 1880.86C492.735 1880.86 493.75 1880.6 494.507 1880.08C495.267 1879.54 495.647 1878.89 495.647 1878.12C495.647 1877.5 495.413 1876.98 494.947 1876.56C494.491 1876.13 493.789 1875.81 492.839 1875.6L489.474 1874.86C487.621 1874.44 486.249 1873.77 485.359 1872.85C484.48 1871.92 484.04 1870.75 484.04 1869.34C484.04 1868.17 484.368 1867.14 485.024 1866.26C485.68 1865.38 486.585 1864.7 487.741 1864.21C488.896 1863.7 490.221 1863.45 491.715 1863.45C493.862 1863.45 495.551 1863.92 496.784 1864.85C498.018 1865.77 498.835 1867.01 499.23 1868.56Z"
- fill="#BFBFBF"
- />
- <path
- d="M994.565 1876.06C1023.31 1876.06 1046.61 1852.77 1046.61 1824.03C1046.61 1795.3 1023.31 1772 994.565 1772C965.819 1772 942.517 1795.3 942.517 1824.03C942.517 1852.77 965.819 1876.06 994.565 1876.06Z"
- fill="#9D9D9D"
- />
- <path
- d="M993.253 1599C997.49 1599.08 997.139 1599.29 997.139 1599.29L1006.1 1608.39L1011.97 1614.36C1020.55 1623.53 1022.53 1625.45 1029.01 1633.37C1026.99 1640.66 1022.2 1644.73 1016.89 1649.79L1014.55 1651.94L1010.41 1655.85L1006.99 1659.15L1002.3 1663.47C1002.3 1663.47 998.357 1667.12 995.939 1669.04C995.939 1669.04 993.43 1669.56 992.056 1668.18C991.957 1665.4 991.762 1660.81 991.793 1658.04C991.806 1656.52 991.817 1655 991.83 1653.43C991.845 1652.26 991.861 1651.08 991.879 1649.87C976.944 1649.3 973.915 1650.07 961.179 1658.94C958.909 1658.79 958.084 1656.73 958.084 1656.73C956.677 1644.97 961.9 1637.63 968.499 1627.87C975.089 1620.72 982.393 1618.15 991.879 1616.87C991.861 1615.7 991.845 1614.52 991.83 1613.31C991.817 1611.79 991.806 1610.28 991.793 1608.71C991.778 1607.2 991.76 1605.68 991.744 1604.12C991.879 1600.37 991.879 1600.37 993.253 1599Z"
- fill="white"
- fillOpacity="0.9"
- />
- <g filter="url(#filter0_d_5_2)">
- <path
- d="M965.181 1685.31V1708.02H962.43V1688.19H962.298L956.752 1691.87V1689.08L962.43 1685.31H965.181ZM974.318 1704.91L974.141 1706.11C974.016 1706.95 973.824 1707.85 973.566 1708.82C973.314 1709.78 973.051 1710.68 972.778 1711.53C972.504 1712.38 972.278 1713.06 972.101 1713.56H970.105C970.201 1713.09 970.326 1712.46 970.482 1711.68C970.638 1710.91 970.792 1710.04 970.948 1709.08C971.109 1708.13 971.242 1707.15 971.346 1706.15L971.479 1704.91H974.318ZM985.764 1708.33C984.462 1708.33 983.291 1708.07 982.248 1707.55C981.207 1707.03 980.371 1706.32 979.742 1705.42C979.114 1704.52 978.771 1703.49 978.711 1702.34H981.373C981.475 1703.37 981.941 1704.22 982.768 1704.89C983.604 1705.56 984.603 1705.89 985.764 1705.89C986.695 1705.89 987.523 1705.67 988.249 1705.23C988.98 1704.8 989.553 1704.2 989.966 1703.44C990.388 1702.67 990.599 1701.8 990.599 1700.83C990.599 1699.84 990.38 1698.96 989.946 1698.18C989.516 1697.4 988.926 1696.78 988.171 1696.33C987.416 1695.88 986.555 1695.65 985.587 1695.64C984.892 1695.64 984.179 1695.74 983.445 1695.97C982.714 1696.18 982.112 1696.46 981.639 1696.8L979.065 1696.49L980.442 1685.31H992.241V1687.75H982.747L981.948 1694.45H982.081C982.547 1694.08 983.133 1693.77 983.835 1693.53C984.535 1693.28 985.269 1693.16 986.029 1693.16C987.419 1693.16 988.657 1693.49 989.745 1694.16C990.838 1694.82 991.697 1695.72 992.319 1696.86C992.946 1698.01 993.261 1699.32 993.261 1700.79C993.261 1702.24 992.936 1703.53 992.285 1704.67C991.642 1705.8 990.755 1706.69 989.623 1707.35C988.491 1708 987.205 1708.33 985.764 1708.33ZM1005.21 1708.33C1004.27 1708.31 1003.34 1708.13 1002.41 1707.8C1001.48 1707.45 1000.63 1706.88 999.861 1706.08C999.093 1705.26 998.476 1704.17 998.011 1702.78C997.545 1701.39 997.311 1699.65 997.311 1697.55C997.311 1695.54 997.5 1693.76 997.878 1692.21C998.253 1690.65 998.802 1689.34 999.517 1688.27C1000.24 1687.2 1001.1 1686.39 1002.11 1685.83C1003.13 1685.28 1004.28 1685 1005.56 1685C1006.83 1685 1007.96 1685.25 1008.96 1685.76C1009.95 1686.27 1010.77 1686.97 1011.39 1687.87C1012.02 1688.77 1012.43 1689.81 1012.62 1690.99H1009.91C1009.66 1689.97 1009.17 1689.12 1008.45 1688.45C1007.72 1687.78 1006.76 1687.44 1005.56 1687.44C1003.8 1687.44 1002.42 1688.21 1001.4 1689.73C1000.4 1691.26 999.892 1693.41 999.884 1696.18H1000.06C1000.48 1695.55 1000.97 1695.01 1001.54 1694.57C1002.11 1694.12 1002.75 1693.77 1003.44 1693.53C1004.14 1693.28 1004.87 1693.16 1005.65 1693.16C1006.95 1693.16 1008.14 1693.49 1009.22 1694.14C1010.3 1694.78 1011.17 1695.67 1011.82 1696.81C1012.47 1697.94 1012.79 1699.24 1012.79 1700.7C1012.79 1702.1 1012.48 1703.39 1011.85 1704.56C1011.22 1705.72 1010.34 1706.64 1009.2 1707.33C1008.07 1708.01 1006.74 1708.34 1005.21 1708.33ZM1005.21 1705.89C1006.14 1705.89 1006.97 1705.65 1007.71 1705.19C1008.46 1704.72 1009.05 1704.1 1009.48 1703.32C1009.91 1702.53 1010.13 1701.66 1010.13 1700.7C1010.13 1699.76 1009.92 1698.91 1009.5 1698.14C1009.08 1697.36 1008.51 1696.75 1007.78 1696.29C1007.06 1695.83 1006.23 1695.6 1005.29 1695.6C1004.59 1695.6 1003.94 1695.74 1003.33 1696.02C1002.73 1696.29 1002.2 1696.67 1001.73 1697.15C1001.28 1697.63 1000.93 1698.18 1000.67 1698.8C1000.41 1699.42 1000.28 1700.06 1000.28 1700.74C1000.28 1701.65 1000.49 1702.49 1000.91 1703.27C1001.34 1704.05 1001.93 1704.69 1002.67 1705.17C1003.41 1705.65 1004.26 1705.89 1005.21 1705.89ZM1017.86 1708.02L1028.02 1687.93V1687.75H1016.31V1685.31H1030.86V1687.88L1020.75 1708.02H1017.86Z"
- fill="white"
- />
- </g>
- <g filter="url(#filter1_d_5_2)">
- <path
- d="M962.448 1531.36V1529.1L972.429 1513.31H974.071V1516.81H972.962L965.42 1528.74V1528.92H978.862V1531.36H962.448ZM973.139 1536.02V1530.67V1529.62V1513.31H975.757V1536.02H973.139ZM983.903 1536.19C983.353 1536.19 982.885 1536 982.492 1535.61C982.102 1535.22 981.906 1534.74 981.906 1534.2C981.906 1533.65 982.102 1533.18 982.492 1532.79C982.885 1532.4 983.353 1532.2 983.903 1532.2C984.449 1532.2 984.917 1532.4 985.31 1532.79C985.701 1533.18 985.899 1533.65 985.899 1534.2C985.899 1534.56 985.805 1534.89 985.62 1535.2C985.443 1535.5 985.204 1535.74 984.899 1535.93C984.603 1536.11 984.272 1536.19 983.903 1536.19ZM990.549 1536.02V1534.02L998.047 1525.82C998.926 1524.86 999.65 1524.02 1000.22 1523.31C1000.79 1522.59 1001.21 1521.92 1001.48 1521.29C1001.77 1520.66 1001.91 1519.99 1001.91 1519.3C1001.91 1518.5 1001.71 1517.81 1001.33 1517.22C1000.95 1516.64 1000.44 1516.19 999.777 1515.87C999.119 1515.55 998.38 1515.4 997.557 1515.4C996.686 1515.4 995.926 1515.58 995.275 1515.94C994.63 1516.29 994.133 1516.79 993.776 1517.44C993.43 1518.08 993.256 1518.83 993.256 1519.7H990.638C990.638 1518.37 990.945 1517.2 991.559 1516.19C992.173 1515.19 993.008 1514.4 994.065 1513.84C995.129 1513.28 996.324 1513 997.648 1513C998.978 1513 1000.16 1513.28 1001.19 1513.84C1002.21 1514.4 1003.02 1515.16 1003.6 1516.12C1004.19 1517.07 1004.48 1518.13 1004.48 1519.3C1004.48 1520.13 1004.33 1520.95 1004.02 1521.75C1003.73 1522.54 1003.21 1523.42 1002.47 1524.4C1001.74 1525.37 1000.72 1526.55 999.421 1527.95L994.32 1533.4V1533.58H1004.88V1536.02H990.549ZM1009.86 1536.02V1513.31H1012.61V1524.57H1012.87L1023.08 1513.31H1026.67L1017.13 1523.55L1026.67 1536.02H1023.34L1015.45 1525.46L1012.61 1528.65V1536.02H1009.86Z"
- fill="white"
- />
- </g>
- <path
- d="M981.641 1429.12C982.123 1429.11 982.123 1429.11 984.551 1429.1C986.581 1429.09 988.611 1429.09 990.638 1429.1C993.74 1429.11 996.837 1429.08 999.936 1429.04C1001.91 1429.04 1003.88 1429.04 1005.86 1429.04C1006.78 1429.03 1007.71 1429.02 1008.66 1429C1012.71 1429.05 1015.52 1429.46 1019.05 1431.36C1022.18 1436.32 1021.68 1441.07 1021.55 1446.73C1021.54 1447.91 1021.54 1449.09 1021.53 1450.32C1021.5 1454.09 1021.43 1457.86 1021.37 1461.64C1021.34 1464.2 1021.32 1466.76 1021.3 1469.32C1021.24 1475.6 1021.92 1482.55 1021.81 1488.83C1021.81 1488.83 1021.68 1493.31 1015.86 1490.58C1014.61 1489.71 1013.36 1488.85 1012.08 1487.96C1010.42 1486.83 1008.77 1485.69 1007.12 1484.56C1006.29 1483.99 1005.47 1483.42 1004.63 1482.83C1003.82 1482.29 1003.02 1481.74 1002.2 1481.18C1001.46 1480.67 1000.73 1480.17 999.975 1479.65C997.428 1478.23 995.489 1477.5 992.54 1477.18C989.516 1479.06 986.87 1480.93 984.116 1483.12C975.26 1490.05 972.994 1492.06 967.443 1491.21C967.443 1491.21 967.591 1486.92 967.565 1482.89C967.56 1482.35 967.56 1482.35 967.529 1479.6C967.529 1478.42 967.529 1477.25 967.531 1476.04C967.526 1474.83 967.521 1473.63 967.513 1472.38C967.508 1469.83 967.505 1467.28 967.51 1464.73C967.516 1460.82 967.484 1456.91 967.453 1453C967.45 1450.52 967.448 1448.05 967.451 1445.57C967.438 1444.4 967.424 1443.23 967.414 1442.02C967.422 1440.93 967.43 1439.84 967.437 1438.71C967.437 1437.75 967.437 1436.8 967.435 1435.81C968.827 1428.62 975.185 1429.09 981.641 1429.12Z"
- fill="white"
- fillOpacity="0.9"
- />
- <g filter="url(#filter2_d_5_2)">
- <path
- d="M963.428 1365.33C962.126 1365.33 960.955 1365.07 959.912 1364.55C958.871 1364.03 958.035 1363.32 957.406 1362.42C956.778 1361.52 956.435 1360.49 956.375 1359.34H959.037C959.141 1360.37 959.607 1361.22 960.435 1361.89C961.27 1362.56 962.267 1362.89 963.428 1362.89C964.359 1362.89 965.187 1362.67 965.913 1362.23C966.644 1361.8 967.217 1361.2 967.63 1360.44C968.052 1359.67 968.263 1358.8 968.263 1357.83C968.263 1356.84 968.044 1355.96 967.61 1355.18C967.18 1354.4 966.589 1353.78 965.835 1353.33C965.08 1352.88 964.219 1352.65 963.251 1352.64C962.556 1352.64 961.843 1352.74 961.111 1352.97C960.378 1353.18 959.776 1353.46 959.303 1353.8L956.729 1353.49L958.106 1342.31H969.905V1344.75H960.411L959.612 1351.45H959.748C960.211 1351.08 960.796 1350.77 961.499 1350.53C962.202 1350.28 962.933 1350.16 963.696 1350.16C965.085 1350.16 966.321 1350.49 967.409 1351.16C968.505 1351.82 969.361 1352.72 969.983 1353.86C970.61 1355.01 970.925 1356.32 970.925 1357.79C970.925 1359.24 970.6 1360.53 969.949 1361.67C969.306 1362.8 968.419 1363.69 967.287 1364.35C966.157 1365 964.869 1365.33 963.428 1365.33ZM978.425 1361.91L978.248 1363.11C978.121 1363.95 977.928 1364.85 977.67 1365.82C977.418 1366.78 977.155 1367.68 976.882 1368.53C976.609 1369.38 976.385 1370.06 976.205 1370.56H974.209C974.306 1370.09 974.433 1369.46 974.587 1368.68C974.743 1367.91 974.899 1367.04 975.052 1366.08C975.216 1365.13 975.349 1364.15 975.453 1363.15L975.586 1361.91H978.425ZM988.499 1342.31V1365.02H985.748V1345.19H985.616L980.073 1348.87V1346.08L985.748 1342.31H988.499ZM994.5 1360.36V1358.1L1004.48 1342.31H1006.12V1345.81H1005.01L997.472 1357.74V1357.92H1010.91V1360.36H994.5ZM1005.19 1365.02V1359.67V1358.62V1342.31H1007.81V1365.02H1005.19ZM1022.34 1365.33C1020.82 1365.33 1019.47 1365.06 1018.31 1364.52C1017.15 1363.97 1016.24 1363.22 1015.59 1362.27C1014.94 1361.31 1014.62 1360.21 1014.62 1358.99C1014.62 1358.02 1014.8 1357.14 1015.19 1356.32C1015.57 1355.51 1016.1 1354.82 1016.76 1354.27C1017.44 1353.72 1018.19 1353.37 1019.02 1353.22V1353.09C1017.93 1352.81 1017.06 1352.2 1016.42 1351.26C1015.78 1350.31 1015.46 1349.24 1015.47 1348.03C1015.46 1346.88 1015.75 1345.85 1016.34 1344.94C1016.94 1344.03 1017.75 1343.31 1018.78 1342.79C1019.82 1342.26 1021.01 1342 1022.34 1342C1023.66 1342 1024.83 1342.26 1025.87 1342.79C1026.91 1343.31 1027.72 1344.03 1028.31 1344.94C1028.91 1345.85 1029.21 1346.88 1029.22 1348.03C1029.21 1349.24 1028.88 1350.31 1028.23 1351.26C1027.59 1352.2 1026.73 1352.81 1025.67 1353.09V1353.22C1026.49 1353.37 1027.23 1353.72 1027.89 1354.27C1028.55 1354.82 1029.07 1355.51 1029.46 1356.32C1029.85 1357.14 1030.05 1358.02 1030.06 1358.99C1030.05 1360.21 1029.72 1361.31 1029.06 1362.27C1028.41 1363.22 1027.51 1363.97 1026.35 1364.52C1025.19 1365.06 1023.86 1365.33 1022.34 1365.33ZM1022.34 1362.89C1023.37 1362.89 1024.26 1362.72 1025 1362.39C1025.75 1362.06 1026.33 1361.59 1026.73 1360.98C1027.14 1360.38 1027.35 1359.67 1027.36 1358.85C1027.35 1357.99 1027.13 1357.24 1026.69 1356.58C1026.25 1355.92 1025.66 1355.4 1024.9 1355.03C1024.16 1354.65 1023.3 1354.46 1022.34 1354.46C1021.38 1354.46 1020.51 1354.65 1019.75 1355.03C1018.99 1355.4 1018.4 1355.92 1017.96 1356.58C1017.53 1357.24 1017.32 1357.99 1017.33 1358.85C1017.32 1359.67 1017.52 1360.38 1017.92 1360.98C1018.33 1361.59 1018.91 1362.06 1019.66 1362.39C1020.41 1362.72 1021.31 1362.89 1022.34 1362.89ZM1022.34 1352.11C1023.16 1352.11 1023.88 1351.95 1024.51 1351.62C1025.14 1351.3 1025.64 1350.84 1026 1350.26C1026.36 1349.68 1026.55 1348.99 1026.56 1348.21C1026.55 1347.44 1026.37 1346.77 1026.01 1346.2C1025.66 1345.63 1025.17 1345.18 1024.54 1344.87C1023.91 1344.55 1023.18 1344.4 1022.34 1344.4C1021.49 1344.4 1020.75 1344.55 1020.11 1344.87C1019.48 1345.18 1018.99 1345.63 1018.64 1346.2C1018.29 1346.77 1018.12 1347.44 1018.13 1348.21C1018.12 1348.99 1018.29 1349.68 1018.65 1350.26C1019.01 1350.84 1019.51 1351.3 1020.15 1351.62C1020.78 1351.95 1021.52 1352.11 1022.34 1352.11Z"
- fill="white"
- />
- </g>
- <path
- fillRule="evenodd"
- clipRule="evenodd"
- d="M977.188 1256.62C988.956 1252.25 1004.57 1253.5 1015.53 1259.22C1022.37 1263.77 1028.69 1269.31 1031.15 1277.23C1032.49 1287.77 1032.28 1297.64 1025.47 1306.32C1020.61 1311.36 1015.45 1315.75 1009.85 1320C1008.42 1321.11 1006.98 1322.22 1005.5 1323.36C1001.33 1325.71 1001.27 1325.82 998.234 1326.81C997.737 1326.63 998.414 1326.92 995.895 1326.04C995.66 1324.9 995.673 1323.02 995.452 1317.87C993.813 1317.75 989.532 1317.17 987.84 1317.05C976.496 1315.59 969.272 1310.69 962.152 1302.07C956.676 1294.74 956.213 1287.47 957.309 1278.61C960.494 1268.07 967.95 1262.26 977.188 1256.62ZM976.374 1282.47C973.498 1282.47 971.169 1284.8 971.169 1287.68C971.169 1290.55 973.498 1292.88 976.374 1292.88C979.247 1292.88 981.579 1290.55 981.579 1287.68C981.579 1284.8 979.25 1282.47 976.374 1282.47ZM994.552 1282.47C991.679 1282.47 989.347 1284.8 989.347 1287.68C989.347 1290.55 991.679 1292.88 994.552 1292.88C997.427 1292.88 999.756 1290.55 999.756 1287.68C999.756 1284.8 997.427 1282.47 994.552 1282.47ZM1012.73 1282.47C1009.85 1282.47 1007.52 1284.8 1007.52 1287.68C1007.52 1290.55 1009.85 1292.88 1012.73 1292.88C1015.6 1292.88 1017.93 1290.55 1017.93 1287.68C1017.93 1284.8 1015.6 1282.47 1012.73 1282.47Z"
- fill="white"
- fillOpacity="0.9"
- />
- <g filter="url(#filter3_d_5_2)">
- <path
- d="M947.525 1193.06V1190.68L954.89 1183.05C955.676 1182.22 956.324 1181.49 956.834 1180.87C957.352 1180.24 957.737 1179.64 957.992 1179.07C958.247 1178.51 958.375 1177.9 958.375 1177.27C958.375 1176.54 958.206 1175.92 957.865 1175.4C957.524 1174.87 957.061 1174.46 956.472 1174.18C955.884 1173.89 955.223 1173.74 954.484 1173.74C953.706 1173.74 953.027 1173.9 952.444 1174.22C951.864 1174.54 951.419 1174.99 951.106 1175.57C950.794 1176.15 950.638 1176.83 950.638 1177.61H947.502C947.502 1176.28 947.806 1175.12 948.418 1174.13C949.027 1173.14 949.862 1172.37 950.924 1171.83C951.989 1171.28 953.196 1171 954.549 1171C955.916 1171 957.12 1171.27 958.161 1171.82C959.21 1172.36 960.03 1173.09 960.618 1174.03C961.206 1174.95 961.5 1176 961.5 1177.17C961.5 1177.98 961.347 1178.77 961.042 1179.54C960.746 1180.31 960.225 1181.17 959.481 1182.12C958.737 1183.06 957.701 1184.21 956.376 1185.55L952.051 1190.08V1190.24H961.852V1193.06H947.525ZM973.063 1193.35C971.73 1193.35 970.533 1193.1 969.471 1192.59C968.415 1192.07 967.572 1191.36 966.942 1190.46C966.31 1189.56 965.974 1188.54 965.932 1187.38H969.12C969.198 1188.32 969.612 1189.09 970.364 1189.69C971.114 1190.29 972.014 1190.59 973.063 1190.59C973.898 1190.59 974.64 1190.4 975.283 1190.02C975.936 1189.63 976.446 1189.09 976.815 1188.41C977.19 1187.73 977.378 1186.96 977.378 1186.09C977.378 1185.2 977.185 1184.41 976.802 1183.72C976.42 1183.02 975.894 1182.48 975.22 1182.08C974.554 1181.68 973.789 1181.48 972.925 1181.47C972.266 1181.47 971.603 1181.59 970.937 1181.81C970.27 1182.04 969.732 1182.34 969.321 1182.71L966.315 1182.26L967.535 1171.3H979.504V1174.11H970.268L969.576 1180.2H969.703C970.13 1179.79 970.692 1179.44 971.395 1179.17C972.103 1178.89 972.86 1178.76 973.669 1178.76C974.994 1178.76 976.173 1179.07 977.208 1179.7C978.249 1180.33 979.067 1181.19 979.663 1182.28C980.264 1183.37 980.563 1184.61 980.555 1186.02C980.563 1187.43 980.243 1188.69 979.597 1189.79C978.96 1190.9 978.075 1191.77 976.943 1192.41C975.816 1193.04 974.523 1193.35 973.063 1193.35ZM991.792 1193.35C990.267 1193.35 988.922 1193.09 987.753 1192.57C986.59 1192.04 985.679 1191.32 985.021 1190.4C984.362 1189.48 984.037 1188.43 984.045 1187.25C984.037 1186.33 984.225 1185.49 984.607 1184.72C984.997 1183.94 985.526 1183.29 986.189 1182.77C986.856 1182.25 987.6 1181.91 988.422 1181.77V1181.65C987.34 1181.38 986.47 1180.8 985.82 1179.9C985.167 1179 984.844 1177.97 984.852 1176.8C984.844 1175.69 985.138 1174.7 985.734 1173.83C986.335 1172.95 987.16 1172.26 988.209 1171.75C989.258 1171.25 990.452 1171 991.792 1171C993.117 1171 994.298 1171.25 995.342 1171.76C996.391 1172.27 997.216 1172.96 997.817 1173.84C998.421 1174.71 998.725 1175.7 998.73 1176.8C998.725 1177.97 998.392 1179 997.731 1179.9C997.073 1180.8 996.216 1181.38 995.16 1181.65V1181.77C995.974 1181.91 996.708 1182.25 997.361 1182.77C998.02 1183.29 998.543 1183.94 998.933 1184.72C999.329 1185.49 999.532 1186.33 999.54 1187.25C999.532 1188.43 999.199 1189.48 998.54 1190.4C997.882 1191.32 996.966 1192.04 995.797 1192.57C994.637 1193.09 993.302 1193.35 991.792 1193.35ZM991.792 1190.67C992.69 1190.67 993.471 1190.52 994.129 1190.22C994.788 1189.91 995.298 1189.49 995.66 1188.95C996.021 1188.41 996.206 1187.77 996.211 1187.04C996.206 1186.28 996.008 1185.62 995.618 1185.03C995.235 1184.45 994.715 1184 994.054 1183.66C993.395 1183.33 992.641 1183.17 991.792 1183.17C990.933 1183.17 990.174 1183.33 989.507 1183.66C988.841 1184 988.315 1184.45 987.933 1185.03C987.55 1185.62 987.363 1186.28 987.371 1187.04C987.363 1187.77 987.537 1188.41 987.891 1188.95C988.253 1189.49 988.766 1189.91 989.432 1190.22C990.098 1190.52 990.884 1190.67 991.792 1190.67ZM991.792 1180.54C992.513 1180.54 993.156 1180.39 993.715 1180.1C994.275 1179.81 994.715 1179.41 995.032 1178.89C995.358 1178.38 995.524 1177.77 995.532 1177.08C995.524 1176.4 995.363 1175.8 995.043 1175.29C994.73 1174.78 994.296 1174.39 993.736 1174.11C993.177 1173.83 992.529 1173.69 991.792 1173.69C991.04 1173.69 990.382 1173.83 989.815 1174.11C989.255 1174.39 988.82 1174.78 988.508 1175.29C988.196 1175.8 988.042 1176.4 988.05 1177.08C988.042 1177.77 988.198 1178.38 988.518 1178.89C988.836 1179.41 989.276 1179.81 989.835 1180.1C990.403 1180.39 991.053 1180.54 991.792 1180.54ZM1004.84 1193.26C1004.26 1193.26 1003.76 1193.05 1003.34 1192.64C1002.92 1192.22 1002.71 1191.72 1002.71 1191.13C1002.71 1190.55 1002.92 1190.06 1003.34 1189.65C1003.76 1189.23 1004.26 1189.02 1004.84 1189.02C1005.42 1189.02 1005.92 1189.23 1006.34 1189.65C1006.76 1190.06 1006.96 1190.55 1006.96 1191.13C1006.96 1191.52 1006.87 1191.88 1006.67 1192.21C1006.48 1192.52 1006.22 1192.78 1005.9 1192.97C1005.58 1193.16 1005.23 1193.26 1004.84 1193.26ZM1016.81 1171.3V1193.06H1013.51V1174.59H1013.38L1008.18 1177.99V1174.85L1013.61 1171.3H1016.81ZM1022.99 1193.06V1171.3H1026.28V1181.69H1026.54L1035.67 1171.3H1039.8L1031.03 1181.09L1039.84 1193.06H1035.88L1028.85 1183.33L1026.28 1186.29V1193.06H1022.99Z"
- fill="white"
- />
- </g>
- <path
- d="M988.553 1150.16C977.873 1141.93 955.578 1127.87 956.619 1105.96C956.619 1094.76 965.067 1085.02 976.051 1085.02C981.746 1085.02 987.12 1085 994.708 1092.81C1001.48 1084.91 1007.48 1085 1013.17 1085C1024.16 1085 1032.52 1094.76 1032.52 1105.96C1033.34 1127.56 1011.24 1143.14 1000.59 1150.16C996.579 1152.24 991.838 1151.72 988.553 1150.16Z"
- fill="white"
- fillOpacity="0.9"
- />
- <path
- d="M994.631 876.5C1026.17 876.5 1051.73 902.056 1051.73 933.58C1051.73 965.105 1026.17 990.661 994.631 990.661C963.097 990.661 937.533 965.105 937.533 933.58C937.533 902.056 963.097 876.5 994.631 876.5Z"
- fill="#9D9D9D"
- stroke="white"
- />
- <path
- d="M993.947 1019.18C1009.74 1019.18 1022.55 1006.38 1022.55 990.592C1022.55 974.801 1009.74 962 993.947 962C978.152 962 965.347 974.801 965.347 990.592C965.347 1006.38 978.152 1019.18 993.947 1019.18Z"
- fill="#FE2C55"
- />
- <path
- d="M1008.87 988.577H980.264V993.747H1008.87V988.577Z"
- fill="white"
- />
- <path
- d="M991.979 976.864V1005.46H997.151V976.864H991.979Z"
- fill="white"
- />
- <path
- d="M998.114 252.67C1009.71 252.67 1019.12 243.27 1019.12 231.675C1019.12 220.08 1009.71 210.68 998.114 210.68C986.516 210.68 977.113 220.08 977.113 231.675C977.113 243.27 986.516 252.67 998.114 252.67Z"
- stroke="white"
- strokeWidth="4"
- />
- <path
- d="M1015.72 246.31C1014.9 245.494 1013.58 245.494 1012.76 246.31C1011.94 247.126 1011.94 248.449 1012.76 249.265L1024.78 261.282C1025.6 262.098 1026.92 262.098 1027.74 261.282C1028.55 260.466 1028.55 259.143 1027.74 258.327L1015.72 246.31Z"
- fill="white"
- />
- <path
- d="M791.47 268H734.394C732.965 268 731.807 269.158 731.807 270.587V270.587C731.807 272.015 732.965 273.174 734.394 273.174H791.47C792.899 273.174 794.058 272.015 794.058 270.587C794.058 269.158 792.899 268 791.47 268Z"
- fill="white"
- />
- <path
- d="M686.65 236.274V206H706.051V210.597H692.136V218.816H704.722V223.413H692.136V236.274H686.65ZM720.094 236.717C717.874 236.717 715.954 236.229 714.327 235.254C712.701 234.278 711.438 232.913 710.541 231.159C709.653 229.405 709.211 227.355 709.211 225.01C709.211 222.664 709.653 220.61 710.541 218.846C711.438 217.082 712.701 215.712 714.327 214.736C715.954 213.761 717.874 213.273 720.094 213.273C722.311 213.273 724.234 213.761 725.861 214.736C727.487 215.712 728.744 217.082 729.629 218.846C730.527 220.61 730.975 222.664 730.975 225.01C730.975 227.355 730.527 229.405 729.629 231.159C728.744 232.913 727.487 234.278 725.861 235.254C724.234 236.229 722.311 236.717 720.094 236.717ZM720.123 232.43C721.325 232.43 722.332 232.1 723.139 231.44C723.948 230.77 724.549 229.873 724.942 228.75C725.348 227.626 725.549 226.375 725.549 224.995C725.549 223.605 725.348 222.349 724.942 221.226C724.549 220.092 723.948 219.191 723.139 218.52C722.332 217.85 721.325 217.515 720.123 217.515C718.892 217.515 717.866 217.85 717.047 218.52C716.24 219.191 715.633 220.092 715.228 221.226C714.835 222.349 714.637 223.605 714.637 224.995C714.637 226.375 714.835 227.626 715.228 228.75C715.633 229.873 716.24 230.77 717.047 231.44C717.866 232.1 718.892 232.43 720.123 232.43ZM736.559 236.274V213.568H741.751V217.353H741.985C742.402 216.042 743.11 215.032 744.117 214.322C745.132 213.603 746.29 213.243 747.591 213.243C747.885 213.243 748.216 213.258 748.583 213.288C748.955 213.307 749.267 213.342 749.512 213.391V218.314C749.285 218.235 748.926 218.166 748.434 218.106C747.95 218.037 747.482 218.003 747.029 218.003C746.053 218.003 745.176 218.215 744.398 218.639C743.628 219.053 743.021 219.629 742.579 220.368C742.134 221.107 741.913 221.96 741.913 222.925V236.274H736.559ZM763.645 206H769.842L777.248 219.393H777.545L784.954 206H791.148L780.132 224.98V236.274H774.661V224.98L763.645 206ZM801.516 236.717C799.298 236.717 797.375 236.229 795.749 235.254C794.122 234.278 792.86 232.913 791.965 231.159C791.077 229.405 790.632 227.355 790.632 225.01C790.632 222.664 791.077 220.61 791.965 218.846C792.86 217.082 794.122 215.712 795.749 214.736C797.375 213.761 799.298 213.273 801.516 213.273C803.733 213.273 805.656 213.761 807.283 214.736C808.909 215.712 810.166 217.082 811.053 218.846C811.951 220.61 812.399 222.664 812.399 225.01C812.399 227.355 811.951 229.405 811.053 231.159C810.166 232.913 808.909 234.278 807.283 235.254C805.656 236.229 803.733 236.717 801.516 236.717ZM801.547 232.43C802.749 232.43 803.754 232.1 804.563 231.44C805.37 230.77 805.971 229.873 806.367 228.75C806.77 227.626 806.973 226.375 806.973 224.995C806.973 223.605 806.77 222.349 806.367 221.226C805.971 220.092 805.37 219.191 804.563 218.52C803.754 217.85 802.749 217.515 801.547 217.515C800.313 217.515 799.288 217.85 798.471 218.52C797.661 219.191 797.055 220.092 796.652 221.226C796.256 222.349 796.061 223.605 796.061 224.995C796.061 226.375 796.256 227.626 796.652 228.75C797.055 229.873 797.661 230.77 798.471 231.44C799.288 232.1 800.313 232.43 801.547 232.43ZM832.458 226.724V213.568H837.812V236.274H832.622V232.238H832.385C831.873 233.509 831.03 234.549 829.856 235.357C828.693 236.165 827.259 236.569 825.554 236.569C824.066 236.569 822.749 236.239 821.606 235.579C820.472 234.909 819.584 233.938 818.944 232.667C818.304 231.386 817.984 229.838 817.984 228.025V213.568H823.337V227.198C823.337 228.636 823.73 229.779 824.518 230.627C825.307 231.474 826.343 231.898 827.623 231.898C828.412 231.898 829.177 231.706 829.916 231.322C830.655 230.937 831.261 230.366 831.735 229.607C832.216 228.838 832.458 227.877 832.458 226.724Z"
- fill="white"
- />
- <path
- d="M439.279 236.486V206.212H458.058V210.144H443.847V219.368H456.71V223.285H443.847V236.486H439.279ZM472.262 236.944C470.133 236.944 468.275 236.456 466.688 235.48C465.1 234.505 463.867 233.14 462.99 231.386C462.113 229.632 461.676 227.582 461.676 225.236C461.676 222.881 462.113 220.822 462.99 219.058C463.867 217.294 465.1 215.924 466.688 214.948C468.275 213.973 470.133 213.485 472.262 213.485C474.391 213.485 476.249 213.973 477.837 214.948C479.424 215.924 480.655 217.294 481.532 219.058C482.409 220.822 482.849 222.881 482.849 225.236C482.849 227.582 482.409 229.632 481.532 231.386C480.655 233.14 479.424 234.505 477.837 235.48C476.249 236.456 474.391 236.944 472.262 236.944ZM472.275 233.234C473.657 233.234 474.8 232.869 475.708 232.14C476.613 231.41 477.285 230.44 477.717 229.228C478.162 228.016 478.383 226.68 478.383 225.222C478.383 223.773 478.162 222.443 477.717 221.231C477.285 220.009 476.613 219.028 475.708 218.289C474.8 217.55 473.657 217.18 472.275 217.18C470.886 217.18 469.733 217.55 468.817 218.289C467.908 219.028 467.234 220.009 466.792 221.231C466.357 222.443 466.141 223.773 466.141 225.222C466.141 226.68 466.357 228.016 466.792 229.228C467.234 230.44 467.908 231.41 468.817 232.14C469.733 232.869 470.886 233.234 472.275 233.234ZM493.245 206.212V236.486H488.824V206.212H493.245ZM504.657 206.212V236.486H500.236V206.212H504.657ZM521.211 236.944C519.082 236.944 517.224 236.456 515.637 235.48C514.049 234.505 512.818 233.14 511.941 231.386C511.064 229.632 510.624 227.582 510.624 225.236C510.624 222.881 511.064 220.822 511.941 219.058C512.818 217.294 514.049 215.924 515.637 214.948C517.224 213.973 519.082 213.485 521.211 213.485C523.34 213.485 525.198 213.973 526.785 214.948C528.373 215.924 529.606 217.294 530.483 219.058C531.36 220.822 531.798 222.881 531.798 225.236C531.798 227.582 531.36 229.632 530.483 231.386C529.606 233.14 528.373 234.505 526.785 235.48C525.198 236.456 523.34 236.944 521.211 236.944ZM521.227 233.234C522.606 233.234 523.751 232.869 524.657 232.14C525.562 231.41 526.234 230.44 526.668 229.228C527.111 228.016 527.332 226.68 527.332 225.222C527.332 223.773 527.111 222.443 526.668 221.231C526.234 220.009 525.562 219.028 524.657 218.289C523.751 217.55 522.606 217.18 521.227 217.18C519.837 217.18 518.684 217.55 517.765 218.289C516.86 219.028 516.183 220.009 515.741 221.231C515.306 222.443 515.09 223.773 515.09 225.222C515.09 226.68 515.306 228.016 515.741 229.228C516.183 230.44 516.86 231.41 517.765 232.14C518.684 232.869 519.837 233.234 521.227 233.234ZM542.007 236.486L535.324 213.78H539.894L544.344 230.455H544.565L549.031 213.78H553.601L558.035 230.381H558.256L562.678 213.78H567.248L560.578 236.486H556.068L551.456 220.093H551.115L546.501 236.486H542.007ZM572.484 236.486V213.78H576.905V236.486H572.484ZM574.716 210.277C573.949 210.277 573.288 210.021 572.736 209.509C572.192 208.986 571.922 208.365 571.922 207.646C571.922 206.917 572.192 206.296 572.736 205.784C573.288 205.261 573.949 205 574.716 205C575.487 205 576.14 205.261 576.684 205.784C577.236 206.296 577.511 206.917 577.511 207.646C577.511 208.365 577.236 208.986 576.684 209.509C576.14 210.021 575.487 210.277 574.716 210.277ZM588.314 223.005V236.486H583.895V213.78H588.137V217.476H588.418C588.941 216.274 589.758 215.308 590.872 214.579C591.996 213.849 593.412 213.485 595.117 213.485C596.665 213.485 598.021 213.81 599.184 214.461C600.345 215.101 601.248 216.057 601.888 217.328C602.528 218.599 602.851 220.171 602.851 222.044V236.486H598.43V222.576C598.43 220.93 598 219.644 597.141 218.718C596.285 217.782 595.106 217.313 593.607 217.313C592.585 217.313 591.671 217.535 590.872 217.979C590.084 218.422 589.459 219.072 588.996 219.93C588.54 220.777 588.314 221.802 588.314 223.005ZM619.343 245.473C617.539 245.473 615.985 245.236 614.684 244.763C613.393 244.291 612.339 243.665 611.52 242.886C610.703 242.108 610.091 241.255 609.688 240.329L613.487 238.762C613.753 239.196 614.109 239.654 614.552 240.137C615.004 240.63 615.616 241.048 616.386 241.393C617.164 241.738 618.164 241.911 619.387 241.911C621.063 241.911 622.447 241.502 623.543 240.684C624.636 239.876 625.182 238.585 625.182 236.811V232.347H624.901C624.636 232.83 624.251 233.367 623.748 233.958C623.257 234.549 622.577 235.062 621.708 235.495C620.842 235.929 619.712 236.146 618.322 236.146C616.529 236.146 614.911 235.727 613.472 234.889C612.043 234.042 610.911 232.795 610.073 231.149C609.243 229.494 608.829 227.459 608.829 225.044C608.829 222.63 609.24 220.561 610.057 218.836C610.885 217.111 612.019 215.791 613.459 214.874C614.898 213.948 616.529 213.485 618.351 213.485C619.762 213.485 620.901 213.721 621.768 214.194C622.635 214.657 623.311 215.2 623.793 215.82C624.287 216.441 624.667 216.988 624.933 217.461H625.258V213.78H629.591V236.988C629.591 238.939 629.135 240.541 628.23 241.792C627.322 243.044 626.096 243.97 624.547 244.571C623.009 245.172 621.276 245.473 619.343 245.473ZM619.298 232.48C620.571 232.48 621.646 232.184 622.523 231.593C623.41 230.992 624.079 230.134 624.532 229.021C624.995 227.897 625.227 226.552 625.227 224.985C625.227 223.458 625 222.113 624.547 220.95C624.095 219.787 623.428 218.88 622.551 218.23C621.674 217.57 620.589 217.24 619.298 217.24C617.969 217.24 616.86 217.584 615.972 218.274C615.085 218.954 614.414 219.881 613.961 221.053C613.516 222.226 613.295 223.537 613.295 224.985C613.295 226.473 613.521 227.779 613.976 228.902C614.429 230.026 615.098 230.903 615.985 231.534C616.883 232.164 617.987 232.48 619.298 232.48Z"
- fill="#C0C0C0"
- />
- <path
- d="M234.87 236.274V206H253.856V209.932H239.439V219.156H252.865V223.073H239.439V232.341H254.033V236.274H234.87ZM264.134 213.568L269.147 222.408L274.203 213.568H279.039L271.957 224.921L279.098 236.274H274.263L269.147 227.789L264.046 236.274H259.196L266.263 224.921L259.285 213.568H264.134ZM284.621 244.788V213.568H288.938V217.249H289.308C289.563 216.776 289.932 216.229 290.416 215.608C290.898 214.988 291.569 214.445 292.428 213.982C293.284 213.509 294.419 213.273 295.827 213.273C297.661 213.273 299.298 213.736 300.737 214.662C302.177 215.589 303.306 216.924 304.123 218.668C304.951 220.412 305.364 222.511 305.364 224.965C305.364 227.419 304.956 229.523 304.139 231.277C303.319 233.022 302.195 234.367 300.766 235.313C299.337 236.249 297.706 236.717 295.871 236.717C294.492 236.717 293.362 236.485 292.485 236.022C291.619 235.559 290.939 235.017 290.445 234.396C289.953 233.775 289.573 233.223 289.308 232.741H289.04V244.788H284.621ZM288.951 224.921C288.951 226.517 289.183 227.917 289.646 229.119C290.109 230.321 290.781 231.263 291.658 231.942C292.535 232.613 293.609 232.948 294.882 232.948C296.201 232.948 297.307 232.598 298.195 231.898C299.08 231.189 299.751 230.228 300.204 229.016C300.667 227.803 300.899 226.439 300.899 224.921C300.899 223.423 300.672 222.078 300.22 220.885C299.775 219.693 299.106 218.752 298.208 218.062C297.32 217.372 296.212 217.028 294.882 217.028C293.599 217.028 292.516 217.358 291.629 218.018C290.752 218.678 290.086 219.599 289.633 220.782C289.18 221.965 288.951 223.344 288.951 224.921ZM315.779 206V236.274H311.36V206H315.779ZM332.336 236.732C330.207 236.732 328.349 236.244 326.762 235.268C325.174 234.293 323.943 232.928 323.066 231.174C322.189 229.42 321.749 227.37 321.749 225.024C321.749 222.669 322.189 220.61 323.066 218.846C323.943 217.082 325.174 215.712 326.762 214.736C328.349 213.761 330.207 213.273 332.336 213.273C334.465 213.273 336.323 213.761 337.91 214.736C339.498 215.712 340.731 217.082 341.608 218.846C342.485 220.61 342.922 222.669 342.922 225.024C342.922 227.37 342.485 229.42 341.608 231.174C340.731 232.928 339.498 234.293 337.91 235.268C336.323 236.244 334.465 236.732 332.336 236.732ZM332.352 233.022C333.731 233.022 334.876 232.657 335.781 231.928C336.687 231.198 337.359 230.228 337.793 229.016C338.236 227.803 338.457 226.468 338.457 225.01C338.457 223.561 338.236 222.231 337.793 221.019C337.359 219.797 336.687 218.816 335.781 218.077C334.876 217.338 333.731 216.968 332.352 216.968C330.962 216.968 329.809 217.338 328.89 218.077C327.985 218.816 327.308 219.797 326.866 221.019C326.431 222.231 326.215 223.561 326.215 225.01C326.215 226.468 326.431 227.803 326.866 229.016C327.308 230.228 327.985 231.198 328.89 231.928C329.809 232.657 330.962 233.022 332.352 233.022ZM348.9 236.274V213.568H353.173V217.175H353.41C353.824 215.953 354.553 214.993 355.596 214.293C356.653 213.583 357.845 213.228 359.175 213.228C359.453 213.228 359.778 213.238 360.15 213.258C360.536 213.278 360.838 213.302 361.054 213.332V217.56C360.877 217.51 360.562 217.456 360.106 217.397C359.653 217.328 359.201 217.293 358.748 217.293C357.702 217.293 356.77 217.515 355.953 217.959C355.143 218.392 354.503 218.998 354.03 219.777C353.556 220.545 353.319 221.423 353.319 222.408V236.274H348.9ZM374.896 236.732C372.658 236.732 370.732 236.254 369.116 235.298C367.507 234.332 366.266 232.977 365.389 231.233C364.52 229.479 364.088 227.424 364.088 225.069C364.088 222.743 364.52 220.693 365.389 218.919C366.266 217.146 367.489 215.761 369.056 214.766C370.633 213.771 372.475 213.273 374.586 213.273C375.866 213.273 377.108 213.485 378.313 213.908C379.515 214.332 380.592 214.997 381.55 215.904C382.505 216.811 383.26 217.988 383.812 219.437C384.363 220.876 384.639 222.625 384.639 224.685V226.251H366.586V222.94H380.309C380.309 221.777 380.072 220.748 379.598 219.851C379.125 218.944 378.458 218.23 377.602 217.707C376.754 217.185 375.76 216.924 374.615 216.924C373.373 216.924 372.288 217.229 371.362 217.841C370.446 218.441 369.735 219.23 369.233 220.206C368.741 221.171 368.494 222.221 368.494 223.354V225.941C368.494 227.458 368.759 228.75 369.293 229.814C369.834 230.878 370.589 231.691 371.554 232.253C372.52 232.805 373.649 233.081 374.94 233.081C375.778 233.081 376.543 232.962 377.233 232.726C377.922 232.48 378.518 232.115 379.021 231.632C379.525 231.149 379.908 230.553 380.176 229.843L384.358 230.597C384.025 231.829 383.424 232.908 382.555 233.835C381.698 234.751 380.618 235.465 379.317 235.978C378.026 236.48 376.551 236.732 374.896 236.732Z"
- fill="#C0C0C0"
- />
- <path
- d="M96.4754 244.508C96.4754 247.382 94.1452 249.712 91.2705 249.712H47.4236C44.5489 249.712 42.2188 247.382 42.2188 244.508V239.399H96.4754V244.508ZM47.3676 239.45V244.328H91.4358V239.45H47.3676ZM91.2705 199.109C94.1452 199.109 96.4754 201.439 96.4754 204.313V209.173H42.2188V204.313C42.2188 201.439 44.5489 199.11 47.4236 199.109H91.2705ZM47.3676 204.262V209.14H91.4358V204.262H47.3676Z"
- fill="white"
- />
- <path
- d="M39.3633 235.03V214.217H44.3953V230.945H53.0564V235.03H39.3633ZM60.6934 214.217V235.03H55.6614V214.217H60.6934ZM68.5795 214.217L73.2352 229.461H73.408L78.0639 214.217H83.6956L76.6711 235.03H69.972L62.9476 214.217H68.5795ZM85.9373 235.03V214.217H100.454V218.303H90.9693V222.571H99.7116V226.666H90.9693V230.945H100.454V235.03H85.9373Z"
- fill="white"
- />
- <path
- d="M59.0617 189.84L58.7406 190.161C57.7243 191.177 57.7243 192.824 58.7406 193.84L67.204 202.301C68.2203 203.317 69.868 203.317 70.8843 202.301L71.2054 201.98C72.2217 200.964 72.2217 199.317 71.2054 198.301L62.742 189.84C61.7257 188.824 60.078 188.824 59.0617 189.84Z"
- fill="white"
- />
- <path
- d="M80.4113 190.161L80.0902 189.84C79.0739 188.824 77.4261 188.824 76.4098 189.84L67.9464 198.3C66.9301 199.316 66.9301 200.964 67.9464 201.98L68.2675 202.301C69.2839 203.317 70.9316 203.317 71.9479 202.301L80.4113 193.84C81.4276 192.824 81.4276 191.177 80.4113 190.161Z"
- fill="white"
- />
- <path
- d="M79.951 41.7658V65.0386H76.4283V45.2886H76.2919L70.7237 48.9249V45.5613L76.5305 41.7658H79.951ZM95.716 41.7658V65.0386H92.1933V45.2886H92.0569L86.4887 48.9249V45.5613L92.2955 41.7658H95.716ZM104.936 62.1522C104.314 62.1522 103.78 61.9325 103.333 61.4931C102.886 61.0461 102.663 60.5083 102.663 59.8795C102.663 59.2583 102.886 58.728 103.333 58.2886C103.78 57.8416 104.314 57.6181 104.936 57.6181C105.557 57.6181 106.091 57.8416 106.538 58.2886C106.985 58.728 107.208 59.2583 107.208 59.8795C107.208 60.2961 107.102 60.6787 106.89 61.0272C106.686 61.3681 106.413 61.6408 106.072 61.8454C105.731 62.0499 105.352 62.1522 104.936 62.1522ZM104.936 50.4931C104.314 50.4931 103.78 50.2734 103.333 49.834C102.886 49.3871 102.663 48.8492 102.663 48.2204C102.663 47.5992 102.886 47.0689 103.333 46.6295C103.78 46.1825 104.314 45.959 104.936 45.959C105.557 45.959 106.091 46.1825 106.538 46.6295C106.985 47.0689 107.208 47.5992 107.208 48.2204C107.208 48.6371 107.102 49.0196 106.89 49.3681C106.686 49.709 106.413 49.9818 106.072 50.1863C105.731 50.3908 105.352 50.4931 104.936 50.4931ZM120.036 65.3568C118.612 65.3568 117.331 65.084 116.195 64.5386C115.066 63.9855 114.165 63.228 113.49 62.2658C112.816 61.3037 112.456 60.2052 112.411 58.9704H115.82C115.903 59.9704 116.346 60.7924 117.149 61.4363C117.952 62.0802 118.915 62.4022 120.036 62.4022C120.93 62.4022 121.721 62.1977 122.411 61.7886C123.108 61.3719 123.653 60.7999 124.047 60.0727C124.449 59.3454 124.649 58.5158 124.649 57.584C124.649 56.6371 124.445 55.7924 124.036 55.0499C123.627 54.3075 123.062 53.7242 122.343 53.2999C121.63 52.8757 120.812 52.6598 119.888 52.6522C119.183 52.6522 118.475 52.7734 117.763 53.0158C117.051 53.2583 116.475 53.5765 116.036 53.9704L112.82 53.4931L114.127 41.7658H126.922V44.7772H117.047L116.308 51.2886H116.445C116.899 50.8492 117.502 50.4818 118.252 50.1863C119.009 49.8908 119.82 49.7431 120.683 49.7431C122.1 49.7431 123.362 50.0802 124.468 50.7545C125.581 51.4287 126.456 52.3492 127.093 53.5158C127.737 54.6749 128.055 56.0083 128.047 57.5158C128.055 59.0234 127.714 60.3681 127.024 61.5499C126.343 62.7318 125.396 63.6636 124.183 64.3454C122.979 65.0196 121.596 65.3568 120.036 65.3568ZM132.838 65.0386V62.4931L140.713 54.334C141.554 53.4477 142.247 52.6711 142.792 52.0045C143.345 51.3302 143.758 50.6901 144.031 50.084C144.304 49.478 144.44 48.834 144.44 48.1522C144.44 47.3795 144.258 46.7128 143.895 46.1522C143.531 45.584 143.035 45.1484 142.406 44.8454C141.777 44.5348 141.069 44.3795 140.281 44.3795C139.448 44.3795 138.72 44.5499 138.099 44.8908C137.478 45.2318 137.001 45.7128 136.667 46.334C136.334 46.9552 136.167 47.6825 136.167 48.5158H132.815C132.815 47.0992 133.141 45.8605 133.792 44.7999C134.444 43.7393 135.338 42.9174 136.474 42.334C137.61 41.7431 138.902 41.4477 140.349 41.4477C141.811 41.4477 143.099 41.7393 144.213 42.3227C145.334 42.8984 146.209 43.6863 146.838 44.6863C147.466 45.6787 147.781 46.7999 147.781 48.0499C147.781 48.9136 147.618 49.7583 147.292 50.584C146.974 51.4098 146.417 52.3302 145.622 53.3454C144.826 54.353 143.72 55.5765 142.304 57.0158L137.679 61.8568V62.0272H148.156V65.0386H132.838Z"
- fill="white"
- />
- <rect
- x="906.516"
- y="40.8203"
- width="7.56738"
- height="24.4583"
- rx="2"
- fill="white"
- />
- <rect
- x="895.677"
- y="46.0703"
- width="7.56738"
- height="19.2083"
- rx="2"
- fill="white"
- />
- <rect
- x="884.838"
- y="51.4316"
- width="7.56738"
- height="13.8469"
- rx="2"
- fill="white"
- />
- <rect
- x="874"
- y="55.7026"
- width="7.56738"
- height="9.57568"
- rx="2"
- fill="white"
- />
- <path
- d="M941.078 40.9033C934.711 40.9033 928.344 43.4627 923.774 47.6293C923.516 47.8578 923.508 48.2463 923.75 48.4977L925.836 50.6229C926.078 50.8742 926.485 50.8818 926.742 50.6457C928.563 49.0004 930.641 47.6902 932.938 46.7609C935.516 45.7098 938.258 45.1842 941.086 45.1842C943.914 45.1842 946.656 45.7174 949.235 46.7609C951.531 47.6979 953.61 49.0004 955.43 50.6457C955.688 50.8818 956.094 50.8742 956.336 50.6229L958.422 48.4977C958.664 48.2539 958.656 47.8654 958.399 47.6293C953.813 43.4627 947.445 40.9033 941.078 40.9033Z"
- fill="white"
- />
- <path
- d="M929.922 54.7285L932.156 56.8842C932.399 57.1127 932.781 57.1279 933.031 56.907C935.242 54.9951 938.078 53.9439 941.07 53.9439C944.063 53.9439 946.899 54.9875 949.11 56.907C949.36 57.1279 949.742 57.1127 949.985 56.8842L952.219 54.7285C952.477 54.4771 952.469 54.0734 952.195 53.8373C949.266 51.2551 945.352 49.6783 941.063 49.6783C936.774 49.6783 932.86 51.2551 929.93 53.8373C929.672 54.0734 929.664 54.4771 929.922 54.7285ZM941.078 58.2857C939.25 58.2857 937.594 59.0322 936.438 60.2281C936.203 60.4719 936.211 60.8451 936.453 61.0812L940.625 65.0955C940.875 65.3393 941.281 65.3393 941.531 65.0955L945.703 61.0812C945.945 60.8451 945.953 60.4719 945.719 60.2281C944.563 59.0398 942.906 58.2857 941.078 58.2857Z"
- fill="white"
- />
- <rect
- x="968.492"
- y="38.5908"
- width="51.4128"
- height="29"
- rx="8"
- fill="#34C759"
- />
- <path
- d="M980.991 44.1363V61.5908H977.301V47.6391H977.198L973.201 50.1448V46.8721L977.522 44.1363H980.991ZM991.743 61.9743C990.277 61.9687 989.016 61.6079 987.959 60.892C986.908 60.176 986.098 59.1391 985.53 57.7812C984.968 56.4232 984.689 54.7897 984.695 52.8806C984.695 50.9772 984.976 49.355 985.539 48.0141C986.107 46.6732 986.916 45.6533 987.968 44.9545C989.024 44.2499 990.283 43.8976 991.743 43.8976C993.203 43.8976 994.459 44.2499 995.51 44.9545C996.567 45.659 997.379 46.6817 997.948 48.0226C998.516 49.3579 998.797 50.9772 998.791 52.8806C998.791 54.7954 998.507 56.4317 997.939 57.7897C997.377 59.1476 996.57 60.1846 995.519 60.9005C994.468 61.6164 993.209 61.9743 991.743 61.9743ZM991.743 58.9147C992.743 58.9147 993.541 58.4118 994.138 57.4062C994.735 56.4005 995.03 54.892 995.024 52.8806C995.024 51.5567 994.888 50.4545 994.615 49.5738C994.348 48.6931 993.968 48.0312 993.473 47.588C992.985 47.1448 992.408 46.9232 991.743 46.9232C990.749 46.9232 989.953 47.4204 989.357 48.4147C988.76 49.409 988.459 50.8976 988.453 52.8806C988.453 54.2215 988.587 55.3408 988.854 56.2385C989.127 57.1306 989.51 57.801 990.004 58.2499C990.499 58.6931 991.078 58.9147 991.743 58.9147ZM1008.03 61.9743C1006.56 61.9687 1005.3 61.6079 1004.24 60.892C1003.19 60.176 1002.38 59.1391 1001.81 57.7812C1001.25 56.4232 1000.97 54.7897 1000.98 52.8806C1000.98 50.9772 1001.26 49.355 1001.82 48.0141C1002.39 46.6732 1003.2 45.6533 1004.25 44.9545C1005.31 44.2499 1006.57 43.8976 1008.03 43.8976C1009.49 43.8976 1010.74 44.2499 1011.79 44.9545C1012.85 45.659 1013.66 46.6817 1014.23 48.0226C1014.8 49.3579 1015.08 50.9772 1015.07 52.8806C1015.07 54.7954 1014.79 56.4317 1014.22 57.7897C1013.66 59.1476 1012.85 60.1846 1011.8 60.9005C1010.75 61.6164 1009.49 61.9743 1008.03 61.9743ZM1008.03 58.9147C1009.03 58.9147 1009.82 58.4118 1010.42 57.4062C1011.02 56.4005 1011.31 54.892 1011.31 52.8806C1011.31 51.5567 1011.17 50.4545 1010.9 49.5738C1010.63 48.6931 1010.25 48.0312 1009.76 47.588C1009.27 47.1448 1008.69 46.9232 1008.03 46.9232C1007.03 46.9232 1006.24 47.4204 1005.64 48.4147C1005.04 49.409 1004.74 50.8976 1004.74 52.8806C1004.74 54.2215 1004.87 55.3408 1005.14 56.2385C1005.41 57.1306 1005.79 57.801 1006.29 58.2499C1006.78 58.6931 1007.36 58.9147 1008.03 58.9147Z"
- fill="white"
- />
- <path
- d="M1022.16 48.2734V48.2734C1023.94 48.2734 1025.38 49.7175 1025.38 51.4988V54.6167C1025.38 56.398 1023.94 57.842 1022.16 57.842V57.842V48.2734Z"
- fill="#34C759"
- />
- <defs>
- <filter
- id="filter0_d_5_2"
- x="954.752"
- y="1684"
- width="78.1084"
- height="32.5605"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dy="1" />
- <feGaussianBlur stdDeviation="1" />
- <feComposite in2="hardAlpha" operator="out" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
- />
- <feBlend
- mode="normal"
- in2="BackgroundImageFix"
- result="effect1_dropShadow_5_2"
- />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="effect1_dropShadow_5_2"
- result="shape"
- />
- </filter>
- <filter
- id="filter1_d_5_2"
- x="960.448"
- y="1512"
- width="68.2227"
- height="27.1934"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dy="1" />
- <feGaussianBlur stdDeviation="1" />
- <feComposite in2="hardAlpha" operator="out" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
- />
- <feBlend
- mode="normal"
- in2="BackgroundImageFix"
- result="effect1_dropShadow_5_2"
- />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="effect1_dropShadow_5_2"
- result="shape"
- />
- </filter>
- <filter
- id="filter2_d_5_2"
- x="954.375"
- y="1341"
- width="77.6875"
- height="32.5605"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dy="1" />
- <feGaussianBlur stdDeviation="1" />
- <feComposite in2="hardAlpha" operator="out" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
- />
- <feBlend
- mode="normal"
- in2="BackgroundImageFix"
- result="effect1_dropShadow_5_2"
- />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="effect1_dropShadow_5_2"
- result="shape"
- />
- </filter>
- <filter
- id="filter3_d_5_2"
- x="945.502"
- y="1170"
- width="96.333"
- height="26.353"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dy="1" />
- <feGaussianBlur stdDeviation="1" />
- <feComposite in2="hardAlpha" operator="out" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
- />
- <feBlend
- mode="normal"
- in2="BackgroundImageFix"
- result="effect1_dropShadow_5_2"
- />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="effect1_dropShadow_5_2"
- result="shape"
- />
- </filter>
- </defs>
- </svg>
- );
-}
diff --git a/apps/web/src/lib/guides/grid.ts b/apps/web/src/lib/guides/grid.ts
deleted file mode 100644
index 0b707de7..00000000
--- a/apps/web/src/lib/guides/grid.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const GRID_MIN = 1;
-export const GRID_MAX = 24;
-
-export const DEFAULT_GRID_CONFIG = { rows: 3, cols: 3 } as const;
diff --git a/apps/web/src/lib/guides/index.ts b/apps/web/src/lib/guides/index.ts
deleted file mode 100644
index 9ea75631..00000000
--- a/apps/web/src/lib/guides/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index 8000d989..00000000
--- a/apps/web/src/lib/guides/registry.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 5b776090..00000000
--- a/apps/web/src/lib/guides/types.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ReactNode } from "react";
-
-export interface GridConfig {
- rows: number;
- cols: number;
-}
-
-export interface GuideRenderProps {
- width: number;
- height: number;
-}
-
-export interface GuideDefinition {
- id: string;
- label: string;
- renderPreview: () => ReactNode;
- renderTriggerIcon: () => ReactNode;
- renderOverlay: (props: GuideRenderProps) => ReactNode;
- renderOptions?: () => ReactNode;
-}
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
deleted file mode 100644
index e2f9cafb..00000000
--- a/apps/web/src/lib/masks/__tests__/snap.test.ts
+++ /dev/null
@@ -1,487 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- findClosestPointOnCustomMaskSegment,
- getCustomMaskClosedStateAfterPointRemoval,
- insertPointIntoCustomMaskSegment,
- removeCustomMaskPoints,
-} from "@/lib/masks/custom-path";
-import {
- appendPointToCustomMask,
- customMaskDefinition,
- insertPointOnCustomMaskSegment,
-} from "@/lib/masks/definitions/custom";
-import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
-import { textMaskDefinition } from "@/lib/masks/definitions/text";
-import { getMaskSnapGeometry } from "@/lib/masks/geometry";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- CustomMaskParams,
- RectangleMaskParams,
- SplitMaskParams,
- TextMaskParams,
-} from "@/lib/masks/types";
-
-const bounds: ElementBounds = {
- cx: 200,
- cy: 150,
- width: 200,
- height: 100,
- rotation: 0,
-};
-
-const canvasSize = {
- width: 400,
- height: 300,
-};
-
-const snapThreshold = {
- x: 8,
- y: 8,
-};
-
-function buildSplitParams(
- overrides: Partial<SplitMaskParams> = {},
-): SplitMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- rotation: 0,
- ...overrides,
- };
-}
-
-function buildRectangleParams(
- overrides: Partial<RectangleMaskParams> = {},
-): RectangleMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- width: 0.4,
- height: 0.2,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function buildTextMaskParams(
- overrides: Partial<TextMaskParams> = {},
-): TextMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- content: "Mask",
- fontSize: 15,
- fontFamily: "Arial",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: 0,
- lineHeight: 1.2,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function buildCustomMaskParams(
- overrides: Partial<CustomMaskParams> = {},
-): CustomMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- path: [
- {
- id: "a",
- x: -0.2,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- {
- id: "b",
- x: 0.2,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- {
- id: "c",
- x: 0,
- y: 0.2,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ],
- closed: true,
- centerX: 0,
- centerY: 0,
- 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 }]);
- });
-
- test("snaps text mask movement using intrinsic text bounds", () => {
- const params = buildTextMaskParams({
- centerX: 0.03,
- centerY: -0.04,
- });
- const result = textMaskDefinition.interaction.snap?.({
- handleId: "position",
- startParams: params,
- proposedParams: params,
- 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 custom mask movement using path geometry bounds", () => {
- const params = buildCustomMaskParams({
- centerX: 0.03,
- centerY: -0.04,
- });
- const result = customMaskDefinition.interaction.snap?.({
- handleId: "position",
- startParams: params,
- proposedParams: params,
- 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("marks blank text masks inactive", () => {
- expect(
- textMaskDefinition.isActive?.(
- buildTextMaskParams({
- content: " ",
- }),
- ),
- ).toBe(false);
- });
-});
-
-describe("custom mask creation", () => {
- test("anchors the first point at the click position", () => {
- const params = buildCustomMaskParams({
- path: [],
- closed: false,
- });
- const next = appendPointToCustomMask({
- params,
- canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
- bounds,
- });
-
- expect(next.centerX).toBeCloseTo(0.1);
- expect(next.centerY).toBeCloseTo(-0.1);
- expect(next.rotation).toBe(0);
- expect(next.scale).toBe(1);
- expect(next.path).toHaveLength(1);
- });
-});
-
-describe("custom mask point deletion", () => {
- test("removes the selected points by id", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
- points,
- pointIds: ["b"],
- });
-
- expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
- });
-
- test("reopens a closed path once fewer than three points remain", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
- points,
- pointIds: ["c"],
- });
-
- expect(
- getCustomMaskClosedStateAfterPointRemoval({
- wasClosed: true,
- remainingPointCount: nextPoints.length,
- }),
- ).toBe(false);
- });
-});
-
-describe("custom mask point insertion", () => {
- test("finds the closest point on the clicked segment", () => {
- const params = buildCustomMaskParams();
- const points = params.path;
- const closestPoint = findClosestPointOnCustomMaskSegment({
- points,
- segmentIndex: 0,
- canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- });
-
- expect(closestPoint).not.toBeNull();
- expect(closestPoint?.t).toBeCloseTo(0.5, 1);
- expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
- expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
- });
-
- test("splits a segment into two segments at the insertion point", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = insertPointIntoCustomMaskSegment({
- points,
- segmentIndex: 0,
- pointId: "new",
- t: 0.5,
- closed: true,
- });
-
- expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
- expect(nextPoints[1]).toMatchObject({
- id: "new",
- x: 0,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- });
- });
-
- test("builds updated custom mask params for a clicked segment", () => {
- const result = insertPointOnCustomMaskSegment({
- params: buildCustomMaskParams(),
- segmentIndex: 0,
- canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
- bounds,
- pointId: "new",
- });
-
- expect(result).not.toBeNull();
- const nextPoints = result?.params.path ?? [];
- expect(nextPoints).toHaveLength(4);
- expect(nextPoints.some((point) => point.id === "new")).toBe(true);
- });
-});
diff --git a/apps/web/src/lib/masks/custom-path.ts b/apps/web/src/lib/masks/custom-path.ts
deleted file mode 100644
index 94a9043c..00000000
--- a/apps/web/src/lib/masks/custom-path.ts
+++ /dev/null
@@ -1,842 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-
-export interface CustomMaskPathPoint {
- id: string;
- x: number;
- y: number;
- inX: number;
- inY: number;
- outX: number;
- outY: number;
-}
-
-export type CustomMaskHandlePart = "anchor" | "in" | "out";
-
-function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
- if (!value || typeof value !== "object") {
- return false;
- }
-
- const candidate = value as Record<string, unknown>;
- return (
- typeof candidate.id === "string" &&
- typeof candidate.x === "number" &&
- typeof candidate.y === "number" &&
- typeof candidate.inX === "number" &&
- typeof candidate.inY === "number" &&
- typeof candidate.outX === "number" &&
- typeof candidate.outY === "number"
- );
-}
-
-export function parseCustomMaskHandleId({
- handleId,
-}: {
- handleId: string;
-}): { pointId: string; part: CustomMaskHandlePart } | null {
- const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
- if (!match) {
- return null;
- }
-
- return {
- pointId: match[1],
- part: match[2] as CustomMaskHandlePart,
- };
-}
-
-export function parseCustomMaskSegmentHandleId({
- handleId,
-}: {
- handleId: string;
-}): { segmentIndex: number } | null {
- const match = /^segment:(\d+)$/.exec(handleId);
- if (!match) {
- return null;
- }
-
- return {
- segmentIndex: Number.parseInt(match[1], 10),
- };
-}
-
-export function parseCustomMaskPath({
- path,
-}: {
- path: string;
-}): CustomMaskPathPoint[] {
- if (!path) {
- return [];
- }
-
- try {
- const parsed = JSON.parse(path);
- return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
- } catch {
- return [];
- }
-}
-
-export function serializeCustomMaskPath({
- points,
-}: {
- points: CustomMaskPathPoint[];
-}): string {
- return JSON.stringify(points);
-}
-
-export function removeCustomMaskPoints({
- points,
- pointIds,
-}: {
- points: CustomMaskPathPoint[];
- pointIds: string[];
-}): CustomMaskPathPoint[] {
- if (pointIds.length === 0) {
- return points;
- }
- const pointIdsToRemove = new Set(pointIds);
- return points.filter((point) => !pointIdsToRemove.has(point.id));
-}
-
-export function getCustomMaskClosedStateAfterPointRemoval({
- wasClosed,
- remainingPointCount,
-}: {
- wasClosed: boolean;
- remainingPointCount: number;
-}): boolean {
- return wasClosed && remainingPointCount >= 3;
-}
-
-function rotatePoint({
- x,
- y,
- rotationDegrees,
-}: {
- x: number;
- y: number;
- rotationDegrees: number;
-}): { x: number; y: number } {
- const angleRad = (rotationDegrees * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- return {
- x: x * cos - y * sin,
- y: x * sin + y * cos,
- };
-}
-
-export function getCustomMaskCenterCanvasPoint({
- centerX,
- centerY,
- bounds,
-}: {
- centerX: number;
- centerY: number;
- bounds: ElementBounds;
-}): { x: number; y: number } {
- return {
- x: bounds.cx + centerX * bounds.width,
- y: bounds.cy + centerY * bounds.height,
- };
-}
-
-export function customMaskLocalPointToCanvas({
- point,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
-}: {
- point: { x: number; y: number };
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
-}): { x: number; y: number } {
- const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
- const scaledLocal = {
- x: point.x * bounds.width * scale,
- y: point.y * bounds.height * scale,
- };
- const rotated = rotatePoint({
- x: scaledLocal.x,
- y: scaledLocal.y,
- rotationDegrees: rotation,
- });
-
- return {
- x: center.x + rotated.x,
- y: center.y + rotated.y,
- };
-}
-
-export function customMaskCanvasPointToLocal({
- point,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
-}: {
- point: { x: number; y: number };
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
-}): { x: number; y: number } {
- const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
- const translated = {
- x: point.x - center.x,
- y: point.y - center.y,
- };
- const rotated = rotatePoint({
- x: translated.x,
- y: translated.y,
- rotationDegrees: -rotation,
- });
-
- return {
- x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale),
- y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale),
- };
-}
-
-export function getCustomMaskCanvasGeometry({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
-}: {
- points: CustomMaskPathPoint[];
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
-}) {
- const anchors = points.map((point) => ({
- id: point.id,
- anchor: customMaskLocalPointToCanvas({
- point: { x: point.x, y: point.y },
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- }),
- inHandle: customMaskLocalPointToCanvas({
- point: { x: point.x + point.inX, y: point.y + point.inY },
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- }),
- outHandle: customMaskLocalPointToCanvas({
- point: { x: point.x + point.outX, y: point.y + point.outY },
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- }),
- }));
-
- const geometryPoints = anchors.flatMap((point) => [
- point.anchor,
- point.inHandle,
- point.outHandle,
- ]);
- if (geometryPoints.length === 0) {
- return {
- anchors,
- bounds: null,
- };
- }
-
- const geometryBounds = getCanvasPointBounds({
- points: geometryPoints,
- });
-
- return {
- anchors,
- bounds: geometryBounds,
- };
-}
-
-export interface CanvasPoint {
- x: number;
- y: number;
-}
-
-function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- width: number;
- height: number;
- centerX: number;
- centerY: number;
-} | null {
- if (points.length === 0) {
- return null;
- }
-
- const [firstPoint, ...restPoints] = points;
- let minX = firstPoint.x;
- let maxX = firstPoint.x;
- let minY = firstPoint.y;
- let maxY = firstPoint.y;
-
- for (const point of restPoints) {
- minX = Math.min(minX, point.x);
- maxX = Math.max(maxX, point.x);
- minY = Math.min(minY, point.y);
- maxY = Math.max(maxY, point.y);
- }
-
- return {
- minX,
- maxX,
- minY,
- maxY,
- width: Math.max(1, maxX - minX),
- height: Math.max(1, maxY - minY),
- centerX: (minX + maxX) / 2,
- centerY: (minY + maxY) / 2,
- };
-}
-
-export interface CustomMaskCanvasSegment {
- index: number;
- startPointId: string;
- endPointId: string;
- start: CanvasPoint;
- startOut: CanvasPoint;
- endIn: CanvasPoint;
- end: CanvasPoint;
- pathData: string;
-}
-
-function clampUnit(value: number): number {
- return Math.min(1, Math.max(0, value));
-}
-
-function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
- const dx = a.x - b.x;
- const dy = a.y - b.y;
- return dx * dx + dy * dy;
-}
-
-function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
- return {
- x: a.x + (b.x - a.x) * t,
- y: a.y + (b.y - a.y) * t,
- };
-}
-
-function evaluateCubicBezier({
- p0,
- p1,
- p2,
- p3,
- t,
-}: {
- p0: CanvasPoint;
- p1: CanvasPoint;
- p2: CanvasPoint;
- p3: CanvasPoint;
- t: number;
-}): CanvasPoint {
- const oneMinusT = 1 - t;
- return {
- x:
- oneMinusT ** 3 * p0.x +
- 3 * oneMinusT ** 2 * t * p1.x +
- 3 * oneMinusT * t ** 2 * p2.x +
- t ** 3 * p3.x,
- y:
- oneMinusT ** 3 * p0.y +
- 3 * oneMinusT ** 2 * t * p1.y +
- 3 * oneMinusT * t ** 2 * p2.y +
- t ** 3 * p3.y,
- };
-}
-
-function getCustomMaskSegmentIndices({
- points,
- segmentIndex,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- segmentIndex: number;
- closed: boolean;
-}): { startIndex: number; endIndex: number } | null {
- const segmentCount = getCustomMaskSegmentCount({ points, closed });
- if (segmentIndex < 0 || segmentIndex >= segmentCount) {
- return null;
- }
-
- return {
- startIndex: segmentIndex,
- endIndex: (segmentIndex + 1) % points.length,
- };
-}
-
-export function getCustomMaskSegmentCount({
- points,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- closed: boolean;
-}): number {
- if (points.length < 2) {
- return 0;
- }
- return closed ? points.length : points.length - 1;
-}
-
-export function getCustomMaskCanvasSegments({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
- closed: boolean;
-}): CustomMaskCanvasSegment[] {
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- });
- const segmentCount = getCustomMaskSegmentCount({ points, closed });
-
- return Array.from({ length: segmentCount }, (_, segmentIndex) => {
- const start = geometry.anchors[segmentIndex];
- const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length];
- return {
- index: segmentIndex,
- startPointId: start.id,
- endPointId: end.id,
- start: start.anchor,
- startOut: start.outHandle,
- endIn: end.inHandle,
- end: end.anchor,
- pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`,
- };
- });
-}
-
-export function findClosestPointOnCustomMaskSegment({
- points,
- segmentIndex,
- canvasPoint,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- segmentIndex: number;
- canvasPoint: CanvasPoint;
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
- closed: boolean;
-}): { t: number; point: CanvasPoint } | null {
- const segment = getCustomMaskCanvasSegments({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- closed,
- }).find((candidate) => candidate.index === segmentIndex);
- if (!segment) {
- return null;
- }
-
- const sampleCount = 24;
- let bestT = 0;
- let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
-
- for (let step = 0; step <= sampleCount; step++) {
- const t = step / sampleCount;
- const point = evaluateCubicBezier({
- p0: segment.start,
- p1: segment.startOut,
- p2: segment.endIn,
- p3: segment.end,
- t,
- });
- const distanceSquared = getDistanceSquared(canvasPoint, point);
- if (distanceSquared < bestDistanceSquared) {
- bestDistanceSquared = distanceSquared;
- bestT = t;
- }
- }
-
- let searchStep = 1 / sampleCount;
- for (let iteration = 0; iteration < 8; iteration++) {
- const candidates = [bestT - searchStep, bestT, bestT + searchStep]
- .map(clampUnit)
- .map((t) => ({
- t,
- point: evaluateCubicBezier({
- p0: segment.start,
- p1: segment.startOut,
- p2: segment.endIn,
- p3: segment.end,
- t,
- }),
- }));
- for (const candidate of candidates) {
- const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
- if (distanceSquared < bestDistanceSquared) {
- bestDistanceSquared = distanceSquared;
- bestT = candidate.t;
- }
- }
- searchStep /= 2;
- }
-
- const clampedT = Math.min(0.999, Math.max(0.001, bestT));
- return {
- t: clampedT,
- point: evaluateCubicBezier({
- p0: segment.start,
- p1: segment.startOut,
- p2: segment.endIn,
- p3: segment.end,
- t: clampedT,
- }),
- };
-}
-
-export function insertPointIntoCustomMaskSegment({
- points,
- segmentIndex,
- pointId,
- t,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- segmentIndex: number;
- pointId: string;
- t: number;
- closed: boolean;
-}): CustomMaskPathPoint[] {
- const indices = getCustomMaskSegmentIndices({
- points,
- segmentIndex,
- closed,
- });
- if (!indices) {
- return points;
- }
-
- const startPoint = points[indices.startIndex];
- const endPoint = points[indices.endIndex];
- const clampedT = Math.min(0.999, Math.max(0.001, t));
- const p0 = { x: startPoint.x, y: startPoint.y };
- const p1 = {
- x: startPoint.x + startPoint.outX,
- y: startPoint.y + startPoint.outY,
- };
- const p2 = {
- x: endPoint.x + endPoint.inX,
- y: endPoint.y + endPoint.inY,
- };
- const p3 = { x: endPoint.x, y: endPoint.y };
- const p01 = lerpPoint(p0, p1, clampedT);
- const p12 = lerpPoint(p1, p2, clampedT);
- const p23 = lerpPoint(p2, p3, clampedT);
- const p012 = lerpPoint(p01, p12, clampedT);
- const p123 = lerpPoint(p12, p23, clampedT);
- const splitPoint = lerpPoint(p012, p123, clampedT);
-
- const nextPoints = [...points];
- nextPoints[indices.startIndex] = {
- ...startPoint,
- outX: p01.x - startPoint.x,
- outY: p01.y - startPoint.y,
- };
- nextPoints[indices.endIndex] = {
- ...endPoint,
- inX: p23.x - endPoint.x,
- inY: p23.y - endPoint.y,
- };
- nextPoints.splice(indices.endIndex, 0, {
- id: pointId,
- x: splitPoint.x,
- y: splitPoint.y,
- inX: p012.x - splitPoint.x,
- inY: p012.y - splitPoint.y,
- outX: p123.x - splitPoint.x,
- outY: p123.y - splitPoint.y,
- });
- return nextPoints;
-}
-
-export function getCustomMaskLocalBounds({
- points,
- bounds,
-}: {
- points: CustomMaskPathPoint[];
- bounds: ElementBounds;
-}) {
- if (points.length === 0) {
- return null;
- }
-
- const values = points.flatMap((point) => [
- { x: point.x * bounds.width, y: point.y * bounds.height },
- {
- x: (point.x + point.inX) * bounds.width,
- y: (point.y + point.inY) * bounds.height,
- },
- {
- x: (point.x + point.outX) * bounds.width,
- y: (point.y + point.outY) * bounds.height,
- },
- ]);
-
- const localBounds = getCanvasPointBounds({
- points: values,
- });
- if (!localBounds) {
- return null;
- }
-
- return {
- width: localBounds.width,
- height: localBounds.height,
- };
-}
-
-export function recenterCustomMaskPath({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
-}: {
- points: CustomMaskPathPoint[];
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
-}) {
- if (points.length === 0) {
- return { centerX, centerY, points };
- }
-
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- });
- if (!geometry.bounds) {
- return { centerX, centerY, points };
- }
-
- const nextCenterCanvas = {
- x: geometry.bounds.centerX,
- y: geometry.bounds.centerY,
- };
- const nextCenterLocal = {
- x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width,
- y:
- bounds.height === 0
- ? 0
- : (nextCenterCanvas.y - bounds.cy) / bounds.height,
- };
-
- const nextPoints = geometry.anchors.map((point) => {
- const anchor = customMaskCanvasPointToLocal({
- point: point.anchor,
- centerX: nextCenterLocal.x,
- centerY: nextCenterLocal.y,
- rotation,
- scale,
- bounds,
- });
- const inHandle = customMaskCanvasPointToLocal({
- point: point.inHandle,
- centerX: nextCenterLocal.x,
- centerY: nextCenterLocal.y,
- rotation,
- scale,
- bounds,
- });
- const outHandle = customMaskCanvasPointToLocal({
- point: point.outHandle,
- centerX: nextCenterLocal.x,
- centerY: nextCenterLocal.y,
- rotation,
- scale,
- bounds,
- });
-
- return {
- id: point.id,
- x: anchor.x,
- y: anchor.y,
- inX: inHandle.x - anchor.x,
- inY: inHandle.y - anchor.y,
- outX: outHandle.x - anchor.x,
- outY: outHandle.y - anchor.y,
- };
- });
-
- return {
- centerX: nextCenterLocal.x,
- centerY: nextCenterLocal.y,
- points: nextPoints,
- };
-}
-
-export function buildCustomMaskPath2D({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
- closed: boolean;
-}): Path2D {
- const path = new Path2D();
- if (points.length === 0) {
- return path;
- }
-
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- });
- const anchors = geometry.anchors;
- path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y);
-
- for (let index = 1; index < anchors.length; index++) {
- const previous = anchors[index - 1];
- const current = anchors[index];
- path.bezierCurveTo(
- previous.outHandle.x,
- previous.outHandle.y,
- current.inHandle.x,
- current.inHandle.y,
- current.anchor.x,
- current.anchor.y,
- );
- }
-
- if (closed && anchors.length > 1) {
- const last = anchors[anchors.length - 1];
- const first = anchors[0];
- path.bezierCurveTo(
- last.outHandle.x,
- last.outHandle.y,
- first.inHandle.x,
- first.inHandle.y,
- first.anchor.x,
- first.anchor.y,
- );
- path.closePath();
- }
-
- return path;
-}
-
-export function buildCustomMaskSvgPath({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- closed,
-}: {
- points: CustomMaskPathPoint[];
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
- bounds: ElementBounds;
- closed: boolean;
-}): string {
- if (points.length === 0) {
- return "";
- }
-
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX,
- centerY,
- rotation,
- scale,
- bounds,
- });
- const anchors = geometry.anchors;
- const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`];
-
- for (let index = 1; index < anchors.length; index++) {
- const previous = anchors[index - 1];
- const current = anchors[index];
- segments.push(
- `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`,
- );
- }
-
- if (closed && anchors.length > 1) {
- const last = anchors[anchors.length - 1];
- const first = anchors[0];
- segments.push(
- `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`,
- );
- segments.push("Z");
- }
-
- return segments.join(" ");
-}
diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts
deleted file mode 100644
index fe0f1a85..00000000
--- a/apps/web/src/lib/masks/definitions/box-like.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import {
- DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
- MIN_MASK_DIMENSION,
-} from "@/lib/masks/dimensions";
-import { computeFeatherUpdate } from "../param-update";
-import type {
- BaseMaskParams,
- MaskDefaultContext,
- MaskFeatures,
- MaskInteractionDefinition,
- MaskParamUpdateArgs,
- RectangleMaskParams,
-} from "@/lib/masks/types";
-import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
-import {
- getBoxMaskHandlePositions,
- getBoxMaskOverlays,
-} from "@/lib/masks/handle-positions";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-
-const PERCENTAGE_DISPLAY: Pick<
- NumberParamDefinition,
- "displayMultiplier" | "step"
-> = {
- displayMultiplier: 100,
- step: 1,
-};
-
-export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
- keyof RectangleMaskParams & string
->[] = [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "width",
- label: "Width",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "height",
- label: "Height",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- {
- key: "scale",
- label: "Scale",
- type: "number",
- default: 1,
- min: 1,
- max: 500,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "strokeAlign",
- label: "Stroke Align",
- type: "select",
- default: "center",
- options: [
- { value: "inside", label: "Inside" },
- { value: "center", label: "Center" },
- { value: "outside", label: "Outside" },
- ],
- },
-];
-
-export function getDefaultBaseMaskParams(): BaseMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- };
-}
-
-export function getStrokeOffset({
- strokeAlign,
- strokeWidth,
-}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
- if (strokeAlign === "inside") {
- return -(strokeWidth / 2);
- }
-
- if (strokeAlign === "outside") {
- return strokeWidth / 2;
- }
-
- return 0;
-}
-
-export function getDefaultSquareMaskParams({
- elementSize,
-}: MaskDefaultContext): RectangleMaskParams {
- const absWidth = Math.abs(elementSize?.width ?? 0);
- const absHeight = Math.abs(elementSize?.height ?? 0);
- const shortSide = Math.min(absWidth, absHeight);
- const squareSide =
- shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
- const width =
- absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
- const height =
- absHeight > 0
- ? squareSide / absHeight
- : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
-
- return {
- ...getDefaultBaseMaskParams(),
- centerX: 0,
- centerY: 0,
- width,
- height,
- rotation: 0,
- scale: 1,
- };
-}
-
-export function getBoxLikeGeometry({
- params,
- width,
- height,
-}: {
- params: RectangleMaskParams;
- width: number;
- height: number;
-}) {
- return {
- centerX: width / 2 + params.centerX * width,
- centerY: height / 2 + params.centerY * height,
- maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
- maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
- rotationRad: (params.rotation * Math.PI) / 180,
- };
-}
-
-export function buildBoxMaskInteraction({
- sizeMode,
- buildOverlayPath,
- showBoundingBox = true,
-}: {
- sizeMode: MaskFeatures["sizeMode"];
- buildOverlayPath?: (args: { width: number; height: number }) => string;
- showBoundingBox?: boolean;
-}): MaskInteractionDefinition<RectangleMaskParams> {
- return {
- getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
- return {
- handles: getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode,
- bounds,
- displayScale,
- }),
- overlays: getBoxMaskOverlays({
- params,
- bounds,
- pathData: buildOverlayPath?.({
- width: params.width * bounds.width * scaleX,
- height: params.height * bounds.height * scaleY,
- }),
- showBoundingBox,
- }),
- };
- },
- snap(args) {
- return snapMaskInteraction(args);
- },
- };
-}
-
-export function computeBoxMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- bounds,
-}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
- 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/cinematic-bars.ts b/apps/web/src/lib/masks/definitions/cinematic-bars.ts
deleted file mode 100644
index ef9c6e00..00000000
--- a/apps/web/src/lib/masks/definitions/cinematic-bars.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type {
- MaskDefaultContext,
- MaskDefinition,
- RectangleMaskParams,
-} from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getDefaultBaseMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function getDefaultCinematicBarsMaskParams({
- elementSize,
-}: MaskDefaultContext): RectangleMaskParams {
- const absWidth = Math.abs(elementSize?.width ?? 0);
- const absHeight = Math.abs(elementSize?.height ?? 0);
- const diagonal =
- absWidth > 0 && absHeight > 0
- ? Math.sqrt(absWidth ** 2 + absHeight ** 2)
- : 0;
- const fullSpanWidth =
- absWidth > 0 ? diagonal / absWidth : Math.SQRT2;
-
- return {
- ...getDefaultBaseMaskParams(),
- centerX: 0,
- centerY: 0,
- width: Math.max(fullSpanWidth, 1),
- height: 0.6,
- rotation: 0,
- scale: 1,
- };
-}
-
-function buildBandPath({
- 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 cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "cinematic-bars",
- name: "Cinematic Bars",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "height-only",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "height-only",
- buildOverlayPath({ width, height }) {
- return `M 0,0 H ${width} V ${height} H 0 Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "cinematic-bars",
- params: getDefaultCinematicBarsMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const maskWidth = Math.max(params.width * width, width);
- const maskHeight = Math.max(params.height, 0.01) * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
-
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
-
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1),
- halfHeight: Math.max(
- (Math.max(params.height, 0.01) * height) / 2 + offset,
- 1,
- ),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/custom.ts b/apps/web/src/lib/masks/definitions/custom.ts
deleted file mode 100644
index 0fc69929..00000000
--- a/apps/web/src/lib/masks/definitions/custom.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-import { generateUUID } from "@/utils/id";
-import type { ParamDefinition } from "@/lib/params";
-import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- CustomMask,
- CustomMaskParams,
- MaskDefinition,
- MaskHandlePosition,
- MaskOverlay,
- MaskParamUpdateArgs,
-} from "@/lib/masks/types";
-import {
- buildCustomMaskPath2D,
- buildCustomMaskSvgPath,
- customMaskCanvasPointToLocal,
- findClosestPointOnCustomMaskSegment,
- getCustomMaskCanvasSegments,
- getCustomMaskCanvasGeometry,
- getCustomMaskLocalBounds,
- getCustomMaskSegmentCount,
- insertPointIntoCustomMaskSegment,
- parseCustomMaskHandleId,
- recenterCustomMaskPath,
- type CustomMaskPathPoint,
-} from "@/lib/masks/custom-path";
-import { getBoxMaskHandlePositions } from "@/lib/masks/handle-positions";
-import { computeFeatherUpdate } from "@/lib/masks/param-update";
-import {
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "@/lib/masks/geometry";
-import {
- snapPosition,
- snapRotation,
- snapScale,
-} from "@/lib/preview/preview-snap";
-
-const PERCENTAGE_DISPLAY = {
- displayMultiplier: 100,
- step: 1,
-} as const;
-
-const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & 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: "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,
- },
-];
-
-function getCustomMaskDisplayHandles({
- params,
- displayScale,
- bounds,
-}: {
- params: CustomMaskParams;
- displayScale: number;
- bounds: ElementBounds;
-}): {
- handles: MaskHandlePosition[];
- overlays: MaskOverlay[];
-} {
- const points = params.path;
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- const handles: MaskHandlePosition[] = [];
- const overlays: MaskOverlay[] = [];
-
- if (points.length > 0) {
- overlays.push({
- id: "path",
- type: "canvas-path",
- pathData: buildCustomMaskSvgPath({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- }),
- coordinateSpace: "canvas",
- });
- }
-
- if (params.closed) {
- const segmentStrokeWidth = 12;
- overlays.push(
- ...getCustomMaskCanvasSegments({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: true,
- }).map((segment) => ({
- id: `segment:${segment.index}`,
- type: "canvas-path" as const,
- pathData: segment.pathData,
- coordinateSpace: "canvas" as const,
- handleId: `segment:${segment.index}`,
- cursor: PEN_CURSOR,
- strokeOpacity: 0,
- strokeWidth: segmentStrokeWidth,
- })),
- );
- }
-
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
- if (params.closed && localBounds) {
- handles.push(
- ...getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width: (localBounds.width * params.scale) / bounds.width,
- height: (localBounds.height * params.scale) / bounds.height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode: "uniform",
- showScaleHandle: false,
- bounds,
- displayScale,
- }),
- );
- }
-
- geometry.anchors.forEach((point) => {
- handles.push({
- id: `point:${point.id}:anchor`,
- x: point.anchor.x,
- y: point.anchor.y,
- cursor: params.closed ? "move" : "pointer",
- kind: "point",
- });
- });
-
- return {
- handles,
- overlays,
- };
-}
-
-function updateCustomMaskPoint({
- points,
- pointId,
- updater,
-}: {
- points: CustomMaskPathPoint[];
- pointId: string;
- updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
-}) {
- return points.map((point) => (point.id === pointId ? updater(point) : point));
-}
-
-function computeCustomMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
-}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
-
- if (handleId === "rotation") {
- 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,
- };
- }
-
- 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),
- });
- }
-
- if (handleId === "scale") {
- const startDistance = Math.hypot(
- startCanvasX - pivotX,
- startCanvasY - pivotY,
- );
- const currentDistance = Math.hypot(
- startCanvasX + deltaX - pivotX,
- startCanvasY + deltaY - pivotY,
- );
- const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
- return {
- scale: Math.max(0.01, startParams.scale * scaleFactor),
- };
- }
-
- const parsedHandle = parseCustomMaskHandleId({ handleId });
- if (!parsedHandle) {
- return {};
- }
-
- const points = startParams.path;
- const currentPoint = {
- x: startCanvasX + deltaX,
- y: startCanvasY + deltaY,
- };
- const localPoint = customMaskCanvasPointToLocal({
- point: currentPoint,
- centerX: startParams.centerX,
- centerY: startParams.centerY,
- rotation: startParams.rotation,
- scale: startParams.scale,
- bounds,
- });
-
- return {
- path: updateCustomMaskPoint({
- points,
- pointId: parsedHandle.pointId,
- updater: (point) => {
- if (parsedHandle.part === "anchor") {
- return {
- ...point,
- x: localPoint.x,
- y: localPoint.y,
- };
- }
-
- if (parsedHandle.part === "in") {
- return {
- ...point,
- inX: localPoint.x - point.x,
- inY: localPoint.y - point.y,
- };
- }
-
- return {
- ...point,
- outX: localPoint.x - point.x,
- outY: localPoint.y - point.y,
- };
- },
- }),
- };
-}
-
-export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
- type: "custom",
- name: "Custom",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "uniform",
- },
- params: CUSTOM_MASK_PARAMS,
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- return getCustomMaskDisplayHandles({ params, bounds, displayScale });
- },
- snap({
- handleId,
- proposedParams,
- startParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) {
- const points = startParams.path;
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
- if (!startParams.closed || !localBounds) {
- return {
- params: proposedParams,
- activeLines: [],
- };
- }
-
- const position = {
- x: proposedParams.centerX * bounds.width,
- y: proposedParams.centerY * bounds.height,
- };
-
- if (handleId === "position") {
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: position,
- canvasSize: bounds,
- elementSize: {
- width: localBounds.width * proposedParams.scale,
- height: localBounds.height * proposedParams.scale,
- },
- rotation: proposedParams.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "rotation") {
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
- }
-
- if (handleId === "scale") {
- const { snappedScale, activeLines } = snapScale({
- proposedScale: proposedParams.scale,
- position,
- baseWidth: localBounds.width,
- baseHeight: localBounds.height,
- rotation: proposedParams.rotation,
- canvasSize: bounds,
- snapThreshold,
- preferredEdges: {
- right: true,
- bottom: true,
- },
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(0.01, snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return {
- params: proposedParams,
- activeLines: [],
- };
- },
- },
- buildDefault(): Omit<CustomMask, "id"> {
- return {
- type: "custom",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- path: [],
- closed: false,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- },
- };
- },
- computeParamUpdate: computeCustomMaskParamUpdate,
- isActive(params) {
- return params.closed;
- },
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- const points = params.path;
- if (!params.closed) {
- return new Path2D();
- }
-
- return buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
- },
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- if (!params.closed) {
- return;
- }
-
- const points = params.path;
- const path = buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
-
- ctx.save();
- ctx.strokeStyle = params.strokeColor;
- ctx.lineWidth = params.strokeWidth;
- ctx.lineJoin = "round";
- ctx.lineCap = "round";
- ctx.stroke(path);
-
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
-
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
- ctx.restore();
- },
- },
-};
-
-export function appendPointToCustomMask({
- params,
- canvasPoint,
- bounds,
-}: {
- params: CustomMaskParams;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
-}): CustomMaskParams {
- const points = params.path;
-
- if (points.length === 0) {
- return {
- ...params,
- centerX:
- bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
- centerY:
- bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
- rotation: 0,
- scale: 1,
- path: [
- {
- id: generateUUID(),
- x: 0,
- y: 0,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ],
- };
- }
-
- const localPoint = customMaskCanvasPointToLocal({
- point: canvasPoint,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
- const nextPoints = [
- ...points,
- {
- id: generateUUID(),
- x: localPoint.x,
- y: localPoint.y,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ];
- const recentered = recenterCustomMaskPath({
- points: nextPoints,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- return {
- ...params,
- centerX: recentered.centerX,
- centerY: recentered.centerY,
- path: recentered.points,
- };
-}
-
-export function insertPointOnCustomMaskSegment({
- params,
- segmentIndex,
- canvasPoint,
- bounds,
- pointId = generateUUID(),
-}: {
- params: CustomMaskParams;
- segmentIndex: number;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
- pointId?: string;
-}): { params: CustomMaskParams; pointId: string } | null {
- const points = params.path;
- if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
- return null;
- }
-
- const closestPoint = findClosestPointOnCustomMaskSegment({
- points,
- segmentIndex,
- canvasPoint,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- });
- if (!closestPoint) {
- return null;
- }
-
- const nextPoints = insertPointIntoCustomMaskSegment({
- points,
- segmentIndex,
- pointId,
- t: closestPoint.t,
- closed: params.closed,
- });
- if (nextPoints.length === points.length) {
- return null;
- }
-
- const recentered = recenterCustomMaskPath({
- points: nextPoints,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- return {
- pointId,
- params: {
- ...params,
- centerX: recentered.centerX,
- centerY: recentered.centerY,
- path: recentered.points,
- },
- };
-}
diff --git a/apps/web/src/lib/masks/definitions/diamond.ts b/apps/web/src/lib/masks/definitions/diamond.ts
deleted file mode 100644
index 56930678..00000000
--- a/apps/web/src/lib/masks/definitions/diamond.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildDiamondPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const points = [
- { x: centerX, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY },
- { x: centerX, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(points[0].x, points[0].y);
- for (const point of points.slice(1)) {
- path.lineTo(point.x, point.y);
- }
- path.closePath();
- return path;
-}
-
-export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "diamond",
- name: "Diamond",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "diamond",
- 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 buildDiamondPath({
- 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 buildDiamondPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts
deleted file mode 100644
index 2eb85f0c..00000000
--- a/apps/web/src/lib/masks/definitions/ellipse.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
-} from "./box-like";
-
-export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "ellipse",
- name: "Ellipse",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- 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`;
- },
- }),
- 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/heart.ts b/apps/web/src/lib/masks/definitions/heart.ts
deleted file mode 100644
index 429c5d98..00000000
--- a/apps/web/src/lib/masks/definitions/heart.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildHeartPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const toPoint = ({
- localX,
- localY,
- }: {
- localX: number;
- localY: number;
- }) =>
- rotatePoint({
- x: centerX + localX,
- y: centerY + localY,
- centerX,
- centerY,
- rotationRad,
- });
-
- const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
- const rightControl1 = toPoint({
- localX: halfWidth,
- localY: -halfHeight * 1.225,
- });
- const rightControl2 = toPoint({
- localX: halfWidth,
- localY: -halfHeight * 0.125,
- });
- const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
- const leftControl1 = toPoint({
- localX: -halfWidth,
- localY: -halfHeight * 0.125,
- });
- const leftControl2 = toPoint({
- localX: -halfWidth,
- localY: -halfHeight * 1.225,
- });
-
- const path = new Path2D();
- path.moveTo(start.x, start.y);
- path.bezierCurveTo(
- rightControl1.x,
- rightControl1.y,
- rightControl2.x,
- rightControl2.y,
- bottom.x,
- bottom.y,
- );
- path.bezierCurveTo(
- leftControl1.x,
- leftControl1.y,
- leftControl2.x,
- leftControl2.y,
- start.x,
- start.y,
- );
- path.closePath();
- return path;
-}
-
-export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "heart",
- name: "Heart",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- const cx = width / 2;
- const cy = height / 2;
- const halfWidth = width / 2;
- const halfHeight = height / 2;
- return [
- `M ${cx},${cy - halfHeight * 0.475}`,
- `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
- `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
- "Z",
- ].join(" ");
- },
- }),
- buildDefault(context) {
- return {
- type: "heart",
- 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 buildHeartPath({
- 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 buildHeartPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts
deleted file mode 100644
index fd00cab1..00000000
--- a/apps/web/src/lib/masks/definitions/index.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
-import { masksRegistry, type MaskIconProps } from "../registry";
-import { cinematicBarsMaskDefinition } from "./cinematic-bars";
-import { customMaskDefinition } from "./custom";
-import { diamondMaskDefinition } from "./diamond";
-import { ellipseMaskDefinition } from "./ellipse";
-import { heartMaskDefinition } from "./heart";
-import { rectangleMaskDefinition } from "./rectangle";
-import { splitMaskDefinition } from "./split";
-import { starMaskDefinition } from "./star";
-import { textMaskDefinition } from "./text";
-import {
- MinusSignIcon,
- PanelRightDashedIcon,
- SquareIcon,
- CircleIcon,
- FavouriteIcon,
- DiamondIcon,
- StarsIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-
-function registerDefaultMask<TParams extends BaseMaskParams>({
- definition,
- icon,
-}: {
- definition: MaskDefinition<TParams>;
- icon: MaskIconProps;
-}) {
- if (masksRegistry.has(definition.type)) {
- return;
- }
-
- masksRegistry.registerMask({ definition, icon });
-}
-
-export function registerDefaultMasks(): void {
- registerDefaultMask({
- definition: splitMaskDefinition,
- icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
- });
- registerDefaultMask({
- definition: cinematicBarsMaskDefinition,
- icon: { icon: MinusSignIcon },
- });
- registerDefaultMask({
- definition: rectangleMaskDefinition,
- icon: { icon: SquareIcon },
- });
- registerDefaultMask({
- definition: ellipseMaskDefinition,
- icon: { icon: CircleIcon },
- });
- registerDefaultMask({
- definition: heartMaskDefinition,
- icon: { icon: FavouriteIcon },
- });
- registerDefaultMask({
- definition: diamondMaskDefinition,
- icon: { icon: DiamondIcon },
- });
- registerDefaultMask({
- definition: starMaskDefinition,
- icon: { icon: StarsIcon },
- });
- registerDefaultMask({
- definition: textMaskDefinition,
- icon: { icon: TextFontIcon },
- });
- registerDefaultMask({
- definition: customMaskDefinition,
- icon: { icon: SquareIcon },
- });
-}
diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts
deleted file mode 100644
index 832b8307..00000000
--- a/apps/web/src/lib/masks/definitions/rectangle.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildRectanglePath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const corners = [
- { x: centerX - halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY + halfHeight },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(corners[0].x, corners[0].y);
- for (const corner of corners.slice(1)) {
- path.lineTo(corner.x, corner.y);
- }
- path.closePath();
- return path;
-}
-
-export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "rectangle",
- name: "Rectangle",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- }),
- 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
deleted file mode 100644
index c1a14d90..00000000
--- a/apps/web/src/lib/masks/definitions/split.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import { computeFeatherUpdate } from "../param-update";
-import type {
- MaskDefinition,
- MaskParamUpdateArgs,
- SplitMaskParams,
-} from "@/lib/masks/types";
-import { halfPlaneSign, lineEdgeIntersection } from "../utils";
-import {
- getLineMaskHandlePositions,
- getLineMaskOverlay,
-} from "@/lib/masks/handle-positions";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-
-// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
-// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
-// exactly on the split line, which produces spurious midpoint vertices.
-const NORMAL_SNAP_EPSILON = 1e-10;
-
-// Guards against collinear vertices from float noise at canvas edges.
-const MIN_POLYGON_AREA_PX = 0.5;
-const INTERSECTION_EPSILON = 1e-6;
-
-function polygonArea({ vertices }: { vertices: [number, number][] }): number {
- let area = 0;
- for (let i = 0; i < vertices.length; i++) {
- const [x1, y1] = vertices[i];
- const [x2, y2] = vertices[(i + 1) % vertices.length];
- area += x1 * y2 - x2 * y1;
- }
- return Math.abs(area) * 0.5;
-}
-
-function splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- width: number;
- height: number;
-}): { normalX: number; normalY: number; lineX: number; lineY: number } {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX =
- Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
- const normalY =
- Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
- const lineX = width / 2 + centerX * width;
- const lineY = height / 2 + centerY * height;
- return { normalX, normalY, lineX, lineY };
-}
-
-function pointsEqual(
- a: { x: number; y: number },
- b: { x: number; y: number },
-): boolean {
- return (
- Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
- Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
- );
-}
-
-export function getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
-}: {
- resolvedParams: unknown;
- width: number;
- height: number;
-}): [{ x: number; y: number }, { x: number; y: number }] | null {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
- const intersections: { x: number; y: number }[] = [];
-
- for (const [x1, y1, x2, y2] of edges) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
-
- if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
- continue;
- }
-
- intersections.push(hit);
- }
-
- if (intersections.length !== 2) {
- return null;
- }
-
- return [intersections[0], intersections[1]];
-}
-
-function computeSplitMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
- canvasSize,
-}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
- if (handleId === "position") {
- const rawX = startParams.centerX + deltaX / bounds.width;
- const rawY = startParams.centerY + deltaY / bounds.height;
-
- const minX = -bounds.cx / bounds.width;
- const maxX = (canvasSize.width - bounds.cx) / bounds.width;
- const minY = -bounds.cy / bounds.height;
- const maxY = (canvasSize.height - bounds.cy) / bounds.height;
-
- return {
- centerX: Math.max(minX, Math.min(maxX, rawX)),
- centerY: Math.max(minY, Math.min(maxY, rawY)),
- };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.cos(angleRad),
- directionY: -Math.sin(angleRad),
- });
- }
-
- if (handleId === "rotation") {
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
- const startAngle =
- (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
- Math.PI;
- const currentAngle =
- (Math.atan2(
- startCanvasY + deltaY - pivotY,
- startCanvasX + deltaX - pivotX,
- ) *
- 180) /
- Math.PI;
-
- let deltaAngle = currentAngle - startAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
-
- return {
- rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
- };
- }
-
- return {};
-}
-
-export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
- type: "split",
- name: "Split",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "none",
- },
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- return {
- handles: getLineMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- feather: params.feather,
- bounds,
- displayScale,
- }),
- overlays: [
- getLineMaskOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- bounds,
- }),
- ],
- };
- },
- snap(args) {
- return snapMaskInteraction(args);
- },
- },
- 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: {
- renderMaskHandlesFeather: true,
- 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/definitions/star.ts b/apps/web/src/lib/masks/definitions/star.ts
deleted file mode 100644
index d311ab80..00000000
--- a/apps/web/src/lib/masks/definitions/star.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-const STAR_INNER_RADIUS_RATIO = 0.45;
-const STAR_VERTEX_COUNT = 10;
-
-function buildStarPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const path = new Path2D();
-
- for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
- const isOuterVertex = index % 2 === 0;
- const radiusX = isOuterVertex
- ? halfWidth
- : halfWidth * STAR_INNER_RADIUS_RATIO;
- const radiusY = isOuterVertex
- ? halfHeight
- : halfHeight * STAR_INNER_RADIUS_RATIO;
- const angle = (index * Math.PI) / 5 - Math.PI / 2;
- const point = rotatePoint({
- x: centerX + radiusX * Math.cos(angle),
- y: centerY + radiusY * Math.sin(angle),
- centerX,
- centerY,
- rotationRad,
- });
-
- if (index === 0) {
- path.moveTo(point.x, point.y);
- } else {
- path.lineTo(point.x, point.y);
- }
- }
-
- path.closePath();
- return path;
-}
-
-function buildOverlayStarPath({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): string {
- const centerX = width / 2;
- const centerY = height / 2;
- const halfWidth = width / 2;
- const halfHeight = height / 2;
- const segments: string[] = [];
-
- for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
- const isOuterVertex = index % 2 === 0;
- const radiusX = isOuterVertex
- ? halfWidth
- : halfWidth * STAR_INNER_RADIUS_RATIO;
- const radiusY = isOuterVertex
- ? halfHeight
- : halfHeight * STAR_INNER_RADIUS_RATIO;
- const angle = (index * Math.PI) / 5 - Math.PI / 2;
- const x = centerX + radiusX * Math.cos(angle);
- const y = centerY + radiusY * Math.sin(angle);
- segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`);
- }
-
- return `${segments.join(" ")} Z`;
-}
-
-export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "star",
- name: "Star",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- return buildOverlayStarPath({ width, height });
- },
- }),
- buildDefault(context) {
- return {
- type: "star",
- 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 buildStarPath({
- 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 buildStarPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/text.ts b/apps/web/src/lib/masks/definitions/text.ts
deleted file mode 100644
index b774ed28..00000000
--- a/apps/web/src/lib/masks/definitions/text.ts
+++ /dev/null
@@ -1,438 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import type {
- MaskDefinition,
- MaskParamUpdateArgs,
- TextMask,
- TextMaskParams,
-} from "@/lib/masks/types";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography";
-import {
- drawMeasuredTextLayout,
- measureTextLayout,
- strokeMeasuredTextLayout,
-} from "@/lib/text/primitives";
-import { getTextMeasurementContext } from "@/lib/text/measure-element";
-import { getTextVisualRect } from "@/lib/text/layout";
-import {
- getBoxMaskHandlePositions,
- getBoxMaskRectOverlay,
-} from "@/lib/masks/handle-positions";
-import { computeFeatherUpdate } from "@/lib/masks/param-update";
-import {
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "@/lib/masks/geometry";
-import {
- snapPosition,
- snapRotation,
- snapScale,
- type ScaleEdgePreference,
-} from "@/lib/preview/preview-snap";
-
-const PERCENTAGE_DISPLAY = {
- displayMultiplier: 100,
- step: 1,
-} as const;
-
-const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
-
-const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & 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: "fontSize",
- label: "Size",
- type: "number",
- default: DEFAULTS.text.element.fontSize,
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- step: 1,
- },
- {
- 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,
- },
-];
-
-function measureTextMask({
- params,
- height,
-}: {
- params: TextMaskParams;
- height: number;
-}) {
- const layout = measureTextLayout({
- text: {
- content: params.content,
- fontSize: params.fontSize,
- fontFamily: params.fontFamily,
- fontWeight: params.fontWeight,
- fontStyle: params.fontStyle,
- textAlign: TEXT_MASK_ALIGNMENT,
- textDecoration: params.textDecoration,
- letterSpacing: params.letterSpacing,
- lineHeight: params.lineHeight,
- },
- canvasHeight: height,
- ctx: getTextMeasurementContext(),
- });
- const visualRect = getTextVisualRect({
- textAlign: layout.textAlign,
- block: layout.block,
- background: { enabled: false, color: "transparent" },
- fontSizeRatio: layout.fontSizeRatio,
- });
-
- return {
- layout,
- intrinsicWidth: Math.max(1, visualRect.width),
- intrinsicHeight: Math.max(1, visualRect.height),
- };
-}
-
-function getScalePreferredEdges({
- handleId,
-}: {
- handleId: string;
-}): ScaleEdgePreference | undefined {
- if (handleId !== "scale") {
- return undefined;
- }
-
- return {
- right: true,
- bottom: true,
- };
-}
-
-function computeTextMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
-}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
-
- if (handleId === "rotation") {
- 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,
- };
- }
-
- 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),
- });
- }
-
- if (handleId === "scale") {
- const startDistance = Math.hypot(
- startCanvasX - pivotX,
- startCanvasY - pivotY,
- );
- const currentDistance = Math.hypot(
- startCanvasX + deltaX - pivotX,
- startCanvasY + deltaY - pivotY,
- );
- const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
- return {
- scale: Math.max(0.01, startParams.scale * scaleFactor),
- };
- }
-
- return {};
-}
-
-export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
- type: "text",
- name: "Text",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "uniform",
- },
- params: TEXT_MASK_PARAMS,
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- const { intrinsicWidth, intrinsicHeight } = measureTextMask({
- params,
- height: bounds.height,
- });
- const width = (intrinsicWidth * params.scale) / bounds.width;
- const height = (intrinsicHeight * params.scale) / bounds.height;
- return {
- handles: getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width,
- height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode: "uniform",
- bounds,
- displayScale,
- }),
- overlays: [
- getBoxMaskRectOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width,
- height,
- rotation: params.rotation,
- bounds,
- }),
- ],
- };
- },
- snap({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) {
- const { intrinsicWidth, intrinsicHeight } = measureTextMask({
- params: startParams,
- height: bounds.height,
- });
- const position = {
- x: proposedParams.centerX * bounds.width,
- y: proposedParams.centerY * bounds.height,
- };
-
- if (handleId === "position") {
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: position,
- canvasSize: bounds,
- elementSize: {
- width: intrinsicWidth * proposedParams.scale,
- height: intrinsicHeight * proposedParams.scale,
- },
- rotation: proposedParams.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "rotation") {
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
- }
-
- if (handleId === "scale") {
- const { snappedScale, activeLines } = snapScale({
- proposedScale: proposedParams.scale,
- position,
- baseWidth: intrinsicWidth,
- baseHeight: intrinsicHeight,
- rotation: proposedParams.rotation,
- canvasSize: bounds,
- snapThreshold,
- preferredEdges: getScalePreferredEdges({ handleId }),
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(0.01, snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return {
- params: proposedParams,
- activeLines: [],
- };
- },
- },
- buildDefault(): Omit<TextMask, "id"> {
- return {
- type: "text",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- content: "Mask",
- fontSize: DEFAULTS.text.element.fontSize,
- fontFamily: DEFAULTS.text.element.fontFamily,
- fontWeight: DEFAULTS.text.element.fontWeight,
- fontStyle: DEFAULTS.text.element.fontStyle,
- textDecoration: DEFAULTS.text.element.textDecoration,
- letterSpacing: DEFAULTS.text.letterSpacing,
- lineHeight: DEFAULTS.text.lineHeight,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- },
- };
- },
- computeParamUpdate: computeTextMaskParamUpdate,
- isActive(params) {
- return params.content.trim().length > 0;
- },
- renderer: {
- renderMask({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
-
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- ctx.restore();
- },
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
-
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
-
- strokeMeasuredTextLayout({
- ctx,
- layout,
- strokeColor: params.strokeColor,
- strokeWidth: params.strokeWidth,
- });
-
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- }
-
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- }
-
- ctx.restore();
- },
- },
-};
diff --git a/apps/web/src/lib/masks/dimensions.ts b/apps/web/src/lib/masks/dimensions.ts
deleted file mode 100644
index dcfcdc0c..00000000
--- a/apps/web/src/lib/masks/dimensions.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6;
-export const MIN_MASK_DIMENSION = 0.01;
diff --git a/apps/web/src/lib/masks/feather.ts b/apps/web/src/lib/masks/feather.ts
deleted file mode 100644
index 83e80a0c..00000000
--- a/apps/web/src/lib/masks/feather.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const MAX_FEATHER = 1000;
-export const FEATHER_HANDLE_SCALE = 0.11;
diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts
deleted file mode 100644
index a5b05030..00000000
--- a/apps/web/src/lib/masks/geometry.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import type { RectangleMaskParams } from "@/lib/masks/types";
-
-type CenterMaskParams = {
- centerX: number;
- centerY: number;
-};
-
-type SnapGeometryParams = CenterMaskParams & {
- rotation?: number;
- width?: number;
- height?: number;
- scale?: number;
-};
-
-export function hasCenterParams(
- params: Partial<CenterMaskParams>,
-): params is CenterMaskParams {
- return (
- typeof params.centerX === "number" && typeof params.centerY === "number"
- );
-}
-
-export function isRectangleMaskParams(
- params: SnapGeometryParams,
-): 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: CenterMaskParams;
- 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;
-}): { centerX: number; centerY: number } {
- 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: SnapGeometryParams;
- 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
deleted file mode 100644
index d32b8424..00000000
--- a/apps/web/src/lib/masks/handle-positions.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- MaskFeatures,
- MaskHandlePosition,
- MaskLineOverlay,
- MaskOverlay,
- MaskRectOverlay,
- RectangleMaskParams,
- MaskShapeOverlay,
-} from "@/lib/masks/types";
-
-const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
-const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
-const LINE_EXTENT_MULTIPLIER = 50;
-
-const CURSOR = {
- rotate: "crosshair",
- resizeDiagonal: "nwse-resize",
- resizeHorizontal: "ew-resize",
- resizeVertical: "ns-resize",
-} as const;
-
-/**
- * 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;
-}): { start: { x: number; y: number }; end: { x: number; y: number } } {
- 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 getLineMaskOverlay({
- centerX,
- centerY,
- rotation,
- bounds,
- handleId = "position",
- cursor = "move",
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- bounds: ElementBounds;
- handleId?: string;
- cursor?: string;
-}): MaskLineOverlay {
- const { start, end } = getLineMaskLinePoints({
- centerX,
- centerY,
- rotation,
- bounds,
- });
-
- return {
- id: "line",
- type: "line",
- start,
- end,
- handleId,
- cursor,
- };
-}
-
-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,
- kind: "icon",
- icon: "rotate",
- },
- {
- id: "feather",
- x: cx - normalX * featherOffset,
- y: cy - normalY * featherOffset,
- cursor: CURSOR.resizeHorizontal,
- kind: "icon",
- icon: "feather",
- },
- ];
-}
-
-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,
- showScaleHandle = true,
- bounds,
- displayScale,
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- feather: number;
- sizeMode: MaskFeatures["sizeMode"];
- showScaleHandle?: boolean;
- 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,
- kind: "icon",
- icon: "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,
- kind: "icon",
- icon: "feather",
- });
-
- 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,
- kind: "corner",
- });
- }
- 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,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- } 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,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- } 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,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- } else if (sizeMode === "uniform" && showScaleHandle) {
- const point = rotatePoint({
- localX: halfWidth,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "scale",
- x: point.x,
- y: point.y,
- cursor: CURSOR.resizeDiagonal,
- kind: "corner",
- });
- }
-
- return handles;
-}
-
-export function getBoxMaskRectOverlay({
- centerX,
- centerY,
- width,
- height,
- rotation,
- bounds,
- handleId = "position",
- cursor = "move",
- dashed = false,
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- bounds: ElementBounds;
- handleId?: string;
- cursor?: string;
- dashed?: boolean;
-}): MaskRectOverlay {
- return {
- id: "bounding-box",
- type: "rect",
- center: {
- x: bounds.cx + centerX * bounds.width,
- y: bounds.cy + centerY * bounds.height,
- },
- width: width * bounds.width,
- height: height * bounds.height,
- rotation,
- handleId,
- cursor,
- dashed,
- };
-}
-
-export function getBoxMaskShapeOverlay({
- centerX,
- centerY,
- width,
- height,
- rotation,
- bounds,
- pathData,
- handleId = "position",
- cursor = "move",
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- bounds: ElementBounds;
- pathData: string;
- handleId?: string;
- cursor?: string;
-}): MaskShapeOverlay {
- return {
- id: "shape-outline",
- type: "shape",
- center: {
- x: bounds.cx + centerX * bounds.width,
- y: bounds.cy + centerY * bounds.height,
- },
- width: width * bounds.width,
- height: height * bounds.height,
- rotation,
- pathData,
- handleId,
- cursor,
- };
-}
-
-export function getBoxMaskOverlays({
- params,
- bounds,
- pathData,
- showBoundingBox = true,
-}: {
- params: Pick<
- RectangleMaskParams,
- "centerX" | "centerY" | "width" | "height" | "rotation"
- >;
- bounds: ElementBounds;
- pathData?: string;
- showBoundingBox?: boolean;
-}): MaskOverlay[] {
- const overlays: MaskOverlay[] = [];
- if (showBoundingBox) {
- overlays.push(
- getBoxMaskRectOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- bounds,
- dashed: Boolean(pathData),
- }),
- );
- }
-
- if (pathData) {
- overlays.push(
- getBoxMaskShapeOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- bounds,
- pathData,
- }),
- );
- }
-
- return overlays;
-}
diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts
deleted file mode 100644
index 43871b44..00000000
--- a/apps/web/src/lib/masks/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 4f8693eb..00000000
--- a/apps/web/src/lib/masks/param-update.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
-
-export function computeFeatherUpdate({
- startFeather,
- deltaX,
- deltaY,
- directionX,
- directionY,
-}: {
- startFeather: number;
- deltaX: number;
- deltaY: number;
- directionX: number;
- directionY: number;
-}): { feather: number } {
- const projection = deltaX * directionX + deltaY * directionY;
- return {
- feather: Math.max(
- 0,
- Math.min(
- MAX_FEATHER,
- Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
- ),
- ),
- };
-}
diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts
deleted file mode 100644
index ae8ddd20..00000000
--- a/apps/web/src/lib/masks/registry.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { MAX_FEATHER } from "@/lib/masks/feather";
-import type { ParamDefinition } from "@/lib/params";
-import type {
- BaseMaskParams,
- MaskDefaultContext,
- MaskDefinition,
- MaskInteractionResult,
- MaskSnapArgs,
- MaskSnapResult,
- 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 interface RegisteredMaskDefinition {
- type: MaskType;
- name: string;
- features: MaskDefinition<BaseMaskParams>["features"];
- params: ParamDefinition<string>[];
- renderer: MaskDefinition<BaseMaskParams>["renderer"];
- interaction: {
- getInteraction(args: {
- params: BaseMaskParams;
- bounds: Parameters<
- MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
- >[0]["bounds"];
- displayScale: number;
- scaleX: number;
- scaleY: number;
- }): MaskInteractionResult;
- snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
- };
- isActive?: (params: BaseMaskParams) => boolean;
- buildDefault(
- context: MaskDefaultContext,
- ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
- computeParamUpdate(
- args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
- ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
- icon: MaskIconProps;
-}
-
-export class MasksRegistry extends DefinitionRegistry<
- MaskType,
- RegisteredMaskDefinition
-> {
- constructor() {
- super("mask");
- }
-
- registerMask<TParams extends BaseMaskParams>({
- definition,
- icon,
- }: {
- definition: MaskDefinition<TParams>;
- icon: MaskIconProps;
- }): void {
- const withBaseParams: RegisteredMaskDefinition = {
- type: definition.type,
- name: definition.name,
- features: definition.features,
- params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
- renderer: definition.renderer,
- interaction: {
- getInteraction(args) {
- return definition.interaction.getInteraction(args as never);
- },
- snap: definition.interaction.snap
- ? (args) => definition.interaction.snap?.(args as never) as never
- : undefined,
- },
- isActive: definition.isActive
- ? (params) => definition.isActive?.(params as TParams) ?? true
- : undefined,
- buildDefault(context) {
- return definition.buildDefault(context);
- },
- computeParamUpdate(args) {
- return definition.computeParamUpdate(args as never);
- },
- icon,
- };
- this.register(definition.type, withBaseParams);
- }
-}
-
-export const masksRegistry = new MasksRegistry();
diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts
deleted file mode 100644
index 7396dd82..00000000
--- a/apps/web/src/lib/masks/snap.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import {
- snapPosition,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
-import {
- isRectangleMaskParams,
- getMaskSnapGeometry,
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "./geometry";
-
-type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
-
-type MaskSnapResult<TParams extends SharedMaskParams> = {
- params: TParams;
- 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: SharedMaskParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
- 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: SharedMaskParams;
-}): MaskSnapResult<SharedMaskParams> {
- 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: SharedMaskParams;
- proposedParams: SharedMaskParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
- 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<TParams extends SharedMaskParams>({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- handleId: string;
- startParams: TParams;
- proposedParams: TParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<TParams> {
- if (handleId === "position") {
- return snapMaskPosition({
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) as MaskSnapResult<TParams>;
- }
-
- if (handleId === "rotation") {
- return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
- }
-
- return snapBoxMaskSize({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) as MaskSnapResult<TParams>;
-}
diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts
deleted file mode 100644
index 5a0d126d..00000000
--- a/apps/web/src/lib/masks/types.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import type { ParamDefinition } from "@/lib/params";
-import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
-import type {
- TextDecoration,
- TextFontStyle,
- TextFontWeight,
-} from "@/lib/text/primitives";
-
-export type MaskType =
- | "split"
- | "cinematic-bars"
- | "rectangle"
- | "ellipse"
- | "heart"
- | "diamond"
- | "star"
- | "text"
- | "custom";
-
-export interface BaseMaskParams {
- 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 TextMaskParams extends BaseMaskParams {
- content: string;
- fontSize: number;
- fontFamily: string;
- fontWeight: TextFontWeight;
- fontStyle: TextFontStyle;
- textDecoration: TextDecoration;
- letterSpacing: number;
- lineHeight: number;
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
-}
-
-export interface CustomMaskParams extends BaseMaskParams {
- path: CustomMaskPathPoint[];
- closed: boolean;
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
-}
-
-export interface SplitMask {
- id: string;
- type: "split";
- params: SplitMaskParams;
-}
-
-export interface CinematicBarsMask {
- id: string;
- type: "cinematic-bars";
- params: RectangleMaskParams;
-}
-
-export interface RectangleMask {
- id: string;
- type: "rectangle";
- params: RectangleMaskParams;
-}
-
-export interface EllipseMask {
- id: string;
- type: "ellipse";
- params: RectangleMaskParams;
-}
-
-export interface HeartMask {
- id: string;
- type: "heart";
- params: RectangleMaskParams;
-}
-
-export interface DiamondMask {
- id: string;
- type: "diamond";
- params: RectangleMaskParams;
-}
-
-export interface StarMask {
- id: string;
- type: "star";
- params: RectangleMaskParams;
-}
-
-export interface TextMask {
- id: string;
- type: "text";
- params: TextMaskParams;
-}
-
-export interface CustomMask {
- id: string;
- type: "custom";
- params: CustomMaskParams;
-}
-
-export type Mask =
- | SplitMask
- | CinematicBarsMask
- | RectangleMask
- | EllipseMask
- | HeartMask
- | DiamondMask
- | StarMask
- | TextMask
- | CustomMask;
-
-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;
- renderMaskHandlesFeather?: boolean;
- renderStroke?: (params: {
- resolvedParams: unknown;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- }) => void;
-}
-
-export interface MaskFeatures {
- hasPosition: boolean;
- hasRotation: boolean;
- sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
-}
-
-export type MaskHandleIcon = "rotate" | "feather";
-
-export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
-
-export interface MaskHandlePosition {
- id: string;
- x: number;
- y: number;
- cursor: string;
- kind: MaskHandleKind;
- isSelected?: boolean;
- edgeAxis?: "horizontal" | "vertical";
- rotation?: number;
- icon?: MaskHandleIcon;
-}
-
-export interface MaskLineOverlay {
- id: string;
- type: "line";
- start: { x: number; y: number };
- end: { x: number; y: number };
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskRectOverlay {
- id: string;
- type: "rect";
- center: { x: number; y: number };
- width: number;
- height: number;
- rotation: number;
- dashed?: boolean;
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskShapeOverlay {
- id: string;
- type: "shape";
- center: { x: number; y: number };
- width: number;
- height: number;
- rotation: number;
- pathData: string;
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskCanvasPathOverlay {
- id: string;
- type: "canvas-path";
- pathData: string;
- coordinateSpace?: "canvas" | "overlay";
- cursor?: string;
- handleId?: string;
- strokeWidth?: number;
- strokeOpacity?: number;
-}
-
-export type MaskOverlay =
- | MaskLineOverlay
- | MaskRectOverlay
- | MaskShapeOverlay
- | MaskCanvasPathOverlay;
-
-export interface MaskDefaultContext {
- elementSize?: { width: number; height: number };
-}
-
-export interface MaskParamUpdateArgs<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- handleId: string;
- startParams: TParams;
- deltaX: number;
- deltaY: number;
- startCanvasX: number;
- startCanvasY: number;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
-}
-
-export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
- handleId: string;
- startParams: TParams;
- proposedParams: TParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}
-
-export interface MaskSnapResult<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- params: TParams;
- activeLines: SnapLine[];
-}
-
-export interface MaskInteractionResult {
- handles: MaskHandlePosition[];
- overlays: MaskOverlay[];
-}
-
-export interface MaskInteractionDefinition<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- getInteraction(args: {
- params: TParams;
- bounds: ElementBounds;
- displayScale: number;
- scaleX: number;
- scaleY: number;
- }): MaskInteractionResult;
- snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
-}
-
-export interface MaskDefinition<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- type: MaskType;
- name: string;
- features: MaskFeatures;
- params: ParamDefinition<keyof TParams & string>[];
- renderer: MaskRenderer;
- interaction: MaskInteractionDefinition<TParams>;
- /** When defined and returning false, the mask is not applied and the element renders fully visible. */
- isActive?: (params: TParams) => boolean;
- buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
- computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
-}
diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts
deleted file mode 100644
index b1ac2c39..00000000
--- a/apps/web/src/lib/masks/utils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-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
deleted file mode 100644
index f901c3bf..00000000
--- a/apps/web/src/lib/media/audio-mastering.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-const MASTER_LIMITER_THRESHOLD_DB = -1;
-const MASTER_LIMITER_KNEE_DB = 0;
-const MASTER_LIMITER_RATIO = 20;
-const MASTER_LIMITER_ATTACK_SECONDS = 0.001;
-const MASTER_LIMITER_RELEASE_SECONDS = 0.12;
-const MASTER_OUTPUT_HEADROOM = 0.98;
-
-export function getAudioBufferPeak({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): number {
- let peak = 0;
-
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- const magnitude = Math.abs(channelData[index]);
- if (magnitude > peak) {
- peak = magnitude;
- }
- }
- }
-
- return peak;
-}
-
-export function createAudioMasteringChain({
- audioContext,
- destination,
-}: {
- audioContext: AudioContext | OfflineAudioContext;
- destination: AudioNode;
-}): {
- input: GainNode;
-} {
- const input = audioContext.createGain();
- const limiter = audioContext.createDynamicsCompressor();
- const outputGain = audioContext.createGain();
-
- limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB;
- limiter.knee.value = MASTER_LIMITER_KNEE_DB;
- limiter.ratio.value = MASTER_LIMITER_RATIO;
- limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS;
- limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS;
- outputGain.gain.value = MASTER_OUTPUT_HEADROOM;
-
- input.connect(limiter);
- limiter.connect(outputGain);
- outputGain.connect(destination);
-
- return { input };
-}
-
-export async function applyAudioMasteringToBuffer({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): Promise<AudioBuffer> {
- if (getAudioBufferPeak({ audioBuffer }) <= MASTER_OUTPUT_HEADROOM) {
- return audioBuffer;
- }
-
- const offlineContext = new OfflineAudioContext(
- audioBuffer.numberOfChannels,
- Math.max(1, audioBuffer.length),
- audioBuffer.sampleRate,
- );
- const source = offlineContext.createBufferSource();
- source.buffer = audioBuffer;
-
- const { input } = createAudioMasteringChain({
- audioContext: offlineContext,
- destination: offlineContext.destination,
- });
- source.connect(input);
- source.start(0);
-
- const renderedBuffer = await offlineContext.startRendering();
- clampAudioBufferPeak({
- audioBuffer: renderedBuffer,
- maxPeak: MASTER_OUTPUT_HEADROOM,
- });
- return renderedBuffer;
-}
-
-function clampAudioBufferPeak({
- audioBuffer,
- maxPeak,
-}: {
- audioBuffer: AudioBuffer;
- maxPeak: number;
-}): void {
- for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
- const channelData = audioBuffer.getChannelData(channel);
- for (let index = 0; index < channelData.length; index++) {
- channelData[index] = Math.max(
- -maxPeak,
- Math.min(maxPeak, channelData[index]),
- );
- }
- }
-}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
deleted file mode 100644
index 675cc3a5..00000000
--- a/apps/web/src/lib/media/audio.ts
+++ /dev/null
@@ -1,868 +0,0 @@
-import type {
- AudioElement,
- VideoElement,
- LibraryAudioElement,
- RetimeConfig,
- SceneTracks,
-} from "@/lib/timeline";
-import { shouldMaintainPitch } from "@/lib/retime/rate";
-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 { doesElementHaveEnabledAudio } from "@/lib/timeline/audio-separation";
-import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
-import { canTrackHaveAudio } from "@/lib/timeline";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
-import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import {
- computeRmsBuckets,
- type SampleBucket,
-} from "@/lib/media/waveform-summary";
-
-const MAX_AUDIO_CHANNELS = 2;
-const EXPORT_SAMPLE_RATE = 44100;
-
-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 {
- const AudioContextConstructor =
- window.AudioContext ||
- (window as typeof window & { webkitAudioContext?: typeof AudioContext })
- .webkitAudioContext;
-
- return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
-}
-
-export interface DecodedAudio {
- samples: Float32Array;
- sampleRate: number;
-}
-
-export async function decodeAudioToFloat32({
- audioBlob,
- sampleRate,
-}: {
- audioBlob: Blob;
- sampleRate?: number;
-}): Promise<DecodedAudio> {
- const audioContext = createAudioContext({ sampleRate });
- const arrayBuffer = await audioBlob.arrayBuffer();
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
-
- // mix down to mono
- const numChannels = audioBuffer.numberOfChannels;
- const length = audioBuffer.length;
- const samples = new Float32Array(length);
-
- for (let i = 0; i < length; i++) {
- let sum = 0;
- for (let channel = 0; channel < numChannels; channel++) {
- sum += audioBuffer.getChannelData(channel)[i];
- }
- samples[i] = sum / numChannels;
- }
-
- return { samples, sampleRate: audioBuffer.sampleRate };
-}
-
-export interface AudibleElementCandidate {
- element: AudioElement | VideoElement;
- mediaAsset: MediaAsset | null;
-}
-
-export function collectAudibleCandidates({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): AudibleElementCandidate[] {
- const allTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const mediaMap = new Map(mediaAssets.map((a) => [a.id, a]));
- const candidates: AudibleElementCandidate[] = [];
-
- for (const track of allTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.duration <= 0) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- candidates.push({ element, mediaAsset });
- }
- }
-
- return candidates;
-}
-
-export function timelineHasAudio({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): boolean {
- return collectAudibleCandidates({ tracks, mediaAssets }).some(
- ({ element }) => element.muted !== true,
- );
-}
-
-export async function collectAudioElements({
- tracks,
- mediaAssets,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- audioContext: AudioContext;
-}): Promise<CollectedAudioElement[]> {
- const candidates = collectAudibleCandidates({ tracks, mediaAssets });
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((media) => [media.id, media]),
- );
- const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
-
- for (const { element, mediaAsset } of candidates) {
- if (element.type === "audio") {
- pendingElements.push(
- resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted === true,
- retime: element.retime,
- };
- }),
- );
- continue;
- }
-
- if (element.type === "video") {
- if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
-
- pendingElements.push(
- resolveAudioBufferForAsset({
- asset: mediaAsset,
- audioContext,
- }).then((audioBuffer) => {
- if (!audioBuffer) return null;
- return {
- timelineElement: element,
- buffer: audioBuffer,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume: resolveEffectiveAudioGain({
- element,
- trackMuted: false,
- localTime: 0,
- }),
- muted: element.muted ?? false,
- retime: element.retime,
- };
- }),
- );
- }
- }
-
- const resolvedElements = await Promise.all(pendingElements);
- const audioElements: CollectedAudioElement[] = [];
- for (const element of resolvedElements) {
- if (element) audioElements.push(element);
- }
- return audioElements;
-}
-
-async function resolveAudioBufferForElement({
- element,
- mediaMap,
- audioContext,
-}: {
- element: AudioElement;
- mediaMap: Map<string, MediaAsset>;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- try {
- if (element.sourceType === "upload") {
- const asset = mediaMap.get(element.mediaId);
- if (!asset) return null;
- return await resolveAudioBufferForAsset({ asset, audioContext });
- }
-
- if (element.buffer) return element.buffer;
-
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const arrayBuffer = await response.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio:", error);
- return null;
- }
-}
-
-async function resolveAudioBufferForAsset({
- asset,
- audioContext,
-}: {
- asset: MediaAsset;
- audioContext: AudioContext;
-}): Promise<AudioBuffer | null> {
- if (asset.type === "audio") {
- try {
- const arrayBuffer = await asset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
- } catch (error) {
- console.warn("Failed to decode audio asset:", error);
- return null;
- }
- }
-
- const input = new Input({
- source: new BlobSource(asset.file),
- formats: ALL_FORMATS,
- });
-
- try {
- const audioTrack = await input.getPrimaryAudioTrack();
- if (!audioTrack) return null;
-
- const sink = new AudioBufferSink(audioTrack);
- const targetSampleRate = audioContext.sampleRate;
-
- const chunks: AudioBuffer[] = [];
- let totalSamples = 0;
-
- for await (const { buffer } of sink.buffers(0)) {
- chunks.push(buffer);
- totalSamples += buffer.length;
- }
-
- if (chunks.length === 0) return null;
-
- const nativeSampleRate = chunks[0].sampleRate;
- const numChannels = Math.min(
- MAX_AUDIO_CHANNELS,
- chunks[0].numberOfChannels,
- );
-
- const nativeChannels = Array.from(
- { length: numChannels },
- () => new Float32Array(totalSamples),
- );
- 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),
- );
- 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 nativeBuffer = audioContext.createBuffer(
- numChannels,
- totalSamples,
- nativeSampleRate,
- );
- for (let ch = 0; ch < numChannels; ch++) {
- nativeBuffer.copyToChannel(nativeChannels[ch], ch);
- }
-
- const sourceNode = offlineContext.createBufferSource();
- sourceNode.buffer = nativeBuffer;
- sourceNode.connect(offlineContext.destination);
- sourceNode.start(0);
-
- return await offlineContext.startRendering();
- } catch (error) {
- console.warn("Failed to decode asset audio:", error);
- return null;
- } finally {
- input.dispose();
- }
-}
-
-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;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- volume: number;
- muted: boolean;
- retime?: RetimeConfig;
-}
-
-async function fetchLibraryAudioSource({
- element,
- volume,
-}: {
- element: LibraryAudioElement;
- volume: number;
-}): Promise<AudioMixSource | null> {
- try {
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-async function fetchLibraryAudioClip({
- element,
- muted,
- volume,
-}: {
- element: LibraryAudioElement;
- muted: boolean;
- volume: number;
-}): Promise<AudioClipSource | null> {
- try {
- const response = await fetch(element.sourceUrl);
- if (!response.ok) {
- throw new Error(`Library audio fetch failed: ${response.status}`);
- }
-
- const blob = await response.blob();
- const file = new File([blob], `${element.name}.mp3`, {
- type: "audio/mpeg",
- });
-
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: element.id,
- file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- volume,
- muted,
- retime: element.retime,
- };
- } catch (error) {
- console.warn("Failed to fetch library audio:", error);
- return null;
- }
-}
-
-function collectMediaAudioSource({
- element,
- mediaAsset,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- volume: number;
-}): AudioMixSource {
- return {
- timelineElement: element,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- retime: element.retime,
- };
-}
-
-function collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
-}: {
- element: AudioCapableElement;
- mediaAsset: MediaAsset;
- muted: boolean;
- volume: number;
-}): AudioClipSource {
- return {
- timelineElement: element,
- id: element.id,
- sourceKey: mediaAsset.id,
- file: mediaAsset.file,
- startTime: element.startTime / TICKS_PER_SECOND,
- duration: element.duration / TICKS_PER_SECOND,
- trimStart: element.trimStart / TICKS_PER_SECOND,
- trimEnd: element.trimEnd / TICKS_PER_SECOND,
- volume,
- muted,
- retime: element.retime,
- };
-}
-
-export async function collectAudioMixSources({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioMixSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const audioMixSources: AudioMixSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
-
- for (const track of orderedTracks) {
- if (canTrackHaveAudio(track) && track.muted) continue;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
- if (element.muted === true) continue;
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
- const volume = resolveEffectiveAudioGain({
- element,
- localTime: 0,
- });
-
- if (element.type === "audio") {
- if (element.sourceType === "upload") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- } else {
- pendingLibrarySources.push(
- fetchLibraryAudioSource({ element, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset, volume }),
- );
- }
- }
- }
- }
-
- const resolvedLibrarySources = await Promise.all(pendingLibrarySources);
- for (const source of resolvedLibrarySources) {
- if (source) audioMixSources.push(source);
- }
-
- return audioMixSources;
-}
-
-export async function collectAudioClips({
- tracks,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
-}): Promise<AudioClipSource[]> {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const clips: AudioClipSource[] = [];
- const mediaMap = new Map<string, MediaAsset>(
- mediaAssets.map((asset) => [asset.id, asset]),
- );
- const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
-
- for (const track of orderedTracks) {
- const isTrackMuted = canTrackHaveAudio(track) && track.muted;
-
- for (const element of track.elements) {
- if (!canElementHaveAudio(element)) continue;
-
- const mediaAsset = hasMediaId(element)
- ? (mediaMap.get(element.mediaId) ?? null)
- : null;
- if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
-
- 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") {
- const mediaAsset = mediaMap.get(element.mediaId);
- if (!mediaAsset) continue;
-
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- } else {
- pendingLibraryClips.push(
- fetchLibraryAudioClip({ element, muted, volume }),
- );
- }
- continue;
- }
-
- if (element.type === "video") {
- if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
- clips.push(
- collectMediaAudioClip({
- element,
- mediaAsset,
- muted,
- volume,
- }),
- );
- }
- }
- }
- }
-
- const resolvedLibraryClips = await Promise.all(pendingLibraryClips);
- for (const clip of resolvedLibraryClips) {
- if (clip) clips.push(clip);
- }
-
- return clips;
-}
-
-export async function createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration,
- sampleRate = EXPORT_SAMPLE_RATE,
- audioContext,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- duration: number;
- sampleRate?: number;
- audioContext?: AudioContext;
-}): Promise<AudioBuffer | null> {
- const context = audioContext ?? createAudioContext({ sampleRate });
-
- const audioElements = await collectAudioElements({
- tracks,
- mediaAssets,
- audioContext: context,
- });
-
- if (audioElements.length === 0) return null;
-
- const outputChannels = 2;
- const durationSeconds = duration / TICKS_PER_SECOND;
- const outputLength = Math.ceil(durationSeconds * sampleRate);
- const outputBuffer = context.createBuffer(
- outputChannels,
- outputLength,
- sampleRate,
- );
-
- 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 await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
-}
-
-function collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): Float32Array {
- const channels = buffer.numberOfChannels;
- 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 { bucketStart: start, bucketEnd: end } = getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex: i,
- bucketCount: count,
- });
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > peaks[i]) peaks[i] = abs;
- }
- }
- }
-
- return peaks;
-}
-
-export function extractPeakRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return Array.from(
- collectPeakRange({
- buffer,
- count,
- startSample,
- endSample,
- }),
- );
-}
-
-export function getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount,
-}: {
- startSample: number;
- endSample: number;
- bucketIndex: number;
- bucketCount: number;
-}): {
- bucketStart: number;
- bucketEnd: number;
-} {
- const rangeLength = Math.max(0, endSample - startSample);
- const bucketStart =
- startSample + Math.floor((bucketIndex * rangeLength) / bucketCount);
- const bucketEnd =
- startSample + Math.floor(((bucketIndex + 1) * rangeLength) / bucketCount);
- return {
- bucketStart,
- bucketEnd: Math.max(bucketStart, bucketEnd),
- };
-}
-
-export function extractRmsBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- return computeRmsBuckets({ buffer, buckets });
-}
-
-/**
- * Computes per-bucket waveform amplitude using the maximum RMS over a short
- * analysis window inside each bucket.
- *
- * A naive mean-RMS over a whole bucket averages silence together with nearby
- * sound, which smears transitions (e.g. the onset of speech) across the
- * bucket and makes the waveform respond late. Taking the max over fixed
- * short windows (~20 ms) preserves the smooth, non-jittery RMS character
- * while making transitions land where they actually happen in the audio.
- *
- * Channels are combined per-window before taking the max, so the measure
- * reflects total energy regardless of stereo layout.
- */
-export function extractRmsRange({
- buffer,
- count,
- startSample,
- endSample,
-}: {
- buffer: AudioBuffer;
- count: number;
- startSample: number;
- endSample: number;
-}): number[] {
- return extractRmsBuckets({
- buffer,
- buckets: Array.from({ length: count }, (_, bucketIndex) =>
- getSampleBucketRange({
- startSample,
- endSample,
- bucketIndex,
- bucketCount: count,
- }),
- ),
- });
-}
-
-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 { startTime, duration: elementDuration } = element;
-
- const outputStartSample = Math.floor(startTime * sampleRate);
- const renderedLength = Math.ceil(elementDuration * sampleRate);
-
- const outputChannels = 2;
- for (let channel = 0; channel < outputChannels; channel++) {
- const outputData = outputBuffer.getChannelData(channel);
- const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
- const sourceData = buffer.getChannelData(sourceChannel);
-
- for (let i = 0; i < renderedLength; i++) {
- const outputIndex = outputStartSample + i;
- if (outputIndex >= outputLength) break;
-
- const clipTime = i / sampleRate;
- const sourceTime =
- trimStart + getSourceTimeAtClipTime({ clipTime, retime });
- const sourceIndex = sourceTime * buffer.sampleRate;
- if (sourceIndex >= sourceData.length) break;
-
- 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
deleted file mode 100644
index dc73da23..00000000
--- a/apps/web/src/lib/media/media-utils.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { MediaAsset, MediaType } from "@/lib/media/types";
-
-export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
-
-export function mediaSupportsAudio({
- media,
-}: {
- media: MediaAsset | null | undefined;
-}): boolean {
- if (!media) return false;
- return SUPPORTS_AUDIO.includes(media.type);
-}
-
-export const getMediaTypeFromFile = ({
- file,
-}: {
- file: File;
-}): MediaType | null => {
- const { type } = file;
-
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
-
- return null;
-};
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts
deleted file mode 100644
index 1e97fc42..00000000
--- a/apps/web/src/lib/media/mediabunny.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
-import { createTimelineAudioBuffer } from "@/lib/media/audio";
-import type { SceneTracks } from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-export async function getVideoInfo({
- videoFile,
-}: {
- videoFile: File;
-}): Promise<{
- duration: number;
- width: number;
- height: number;
- fps: number;
- hasAudio: boolean;
-}> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const duration = await input.computeDuration();
- const videoTrack = await input.getPrimaryVideoTrack();
-
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- const packetStats = await videoTrack.computePacketStats(100);
- const fps = packetStats.averagePacketRate;
- const audioTrack = await input.getPrimaryAudioTrack();
-
- return {
- duration,
- width: videoTrack.displayWidth,
- height: videoTrack.displayHeight,
- fps,
- hasAudio: audioTrack !== null,
- };
-}
-
-const SAMPLE_RATE = 44100;
-const NUM_CHANNELS = 2;
-const EMPTY_TIMELINE_SILENT_DURATION_SECONDS = 0.1;
-const MIN_SILENT_DURATION_SECONDS = 0.001;
-
-export const extractTimelineAudio = async ({
- tracks,
- mediaAssets,
- totalDuration,
- onProgress,
-}: {
- tracks: SceneTracks;
- mediaAssets: MediaAsset[];
- totalDuration: number;
- onProgress?: (progress: number) => void;
-}): Promise<Blob> => {
- if (totalDuration === 0) {
- return createWavBlob({
- samples: new Float32Array(
- SAMPLE_RATE * EMPTY_TIMELINE_SILENT_DURATION_SECONDS,
- ),
- });
- }
-
- onProgress?.(10);
-
- const audioBuffer = await createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration: totalDuration,
- sampleRate: SAMPLE_RATE,
- });
-
- if (!audioBuffer) {
- const silentDurationSeconds = Math.max(
- MIN_SILENT_DURATION_SECONDS,
- totalDuration / TICKS_PER_SECOND,
- );
- const silentSamples = new Float32Array(
- Math.ceil(silentDurationSeconds * SAMPLE_RATE) * NUM_CHANNELS,
- );
- return createWavBlob({ samples: silentSamples });
- }
-
- onProgress?.(90);
-
- const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
- onProgress?.(100);
-
- return createWavBlob({ samples: interleavedSamples });
-};
-
-function interleaveAudioBuffer({
- audioBuffer,
-}: {
- audioBuffer: AudioBuffer;
-}): Float32Array {
- const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
- const interleavedSamples = new Float32Array(
- audioBuffer.length * NUM_CHANNELS,
- );
-
- for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
- for (let channel = 0; channel < NUM_CHANNELS; channel++) {
- const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
- interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
- audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
- }
- }
-
- return interleavedSamples;
-}
-
-function createWavBlob({ samples }: { samples: Float32Array }): Blob {
- const numChannels = NUM_CHANNELS;
- const bitsPerSample = 16;
- const bytesPerSample = bitsPerSample / 8;
- const numSamples = samples.length / numChannels;
- const dataSize = numSamples * numChannels * bytesPerSample;
- const buffer = new ArrayBuffer(44 + dataSize);
- const view = new DataView(buffer);
-
- // riff header
- writeString({ view, offset: 0, str: "RIFF" });
- view.setUint32(4, 36 + dataSize, true);
- writeString({ view, offset: 8, str: "WAVE" });
-
- // fmt chunk
- writeString({ view, offset: 12, str: "fmt " });
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, numChannels, true);
- view.setUint32(24, SAMPLE_RATE, true);
- view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
- view.setUint16(32, numChannels * bytesPerSample, true);
- view.setUint16(34, bitsPerSample, true);
-
- // data chunk
- writeString({ view, offset: 36, str: "data" });
- view.setUint32(40, dataSize, true);
-
- // convert float32 to int16 and write
- let offset = 44;
- for (let i = 0; i < samples.length; i++) {
- const sample = Math.max(-1, Math.min(1, samples[i]));
- const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
- view.setInt16(offset, int16, true);
- offset += 2;
- }
-
- return new Blob([buffer], { type: "audio/wav" });
-}
-
-function writeString({
- view,
- offset,
- str,
-}: {
- view: DataView;
- offset: number;
- str: string;
-}): void {
- for (let i = 0; i < str.length; i++) {
- view.setUint8(offset + i, str.charCodeAt(i));
- }
-}
diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts
deleted file mode 100644
index 547e7ed8..00000000
--- a/apps/web/src/lib/media/processing.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
-import { toast } from "sonner";
-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";
-
-export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
-
-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,
-}: {
- width: number;
- height: number;
-}): { width: number; height: number } => {
- const aspectRatio = width / height;
- let targetWidth = width;
- let targetHeight = height;
-
- if (targetWidth > THUMBNAIL_MAX_WIDTH) {
- targetWidth = THUMBNAIL_MAX_WIDTH;
- targetHeight = Math.round(targetWidth / aspectRatio);
- }
- if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
- targetHeight = THUMBNAIL_MAX_HEIGHT;
- targetWidth = Math.round(targetHeight * aspectRatio);
- }
-
- return { width: targetWidth, height: targetHeight };
-};
-
-const renderToThumbnailDataUrl = ({
- width,
- height,
- draw,
-}: {
- width: number;
- height: number;
- draw: ({
- context,
- width,
- height,
- }: {
- context: CanvasRenderingContext2D;
- width: number;
- height: number;
- }) => void;
-}): string => {
- const size = getThumbnailSize({ width, height });
- const canvas = document.createElement("canvas");
- canvas.width = size.width;
- canvas.height = size.height;
- const context = canvas.getContext("2d");
-
- if (!context) {
- throw new Error("Could not get canvas context");
- }
-
- draw({ context, width: size.width, height: size.height });
- return canvas.toDataURL("image/jpeg", 0.8);
-};
-
-async function generateThumbnail({
- videoFile,
- timeInSeconds,
-}: {
- videoFile: File;
- timeInSeconds: number;
-}): Promise<string> {
- const input = new Input({
- source: new BlobSource(videoFile),
- formats: ALL_FORMATS,
- });
-
- const videoTrack = await input.getPrimaryVideoTrack();
- if (!videoTrack) {
- throw new Error("No video track found in the file");
- }
-
- const canDecode = await videoTrack.canDecode();
- if (!canDecode) {
- throw new Error("Video codec not supported for decoding");
- }
-
- const sink = new VideoSampleSink(videoTrack);
-
- const frame = await sink.getSample(timeInSeconds);
-
- if (!frame) {
- throw new Error("Could not get frame at specified time");
- }
-
- try {
- return renderToThumbnailDataUrl({
- width: videoTrack.displayWidth,
- height: videoTrack.displayHeight,
- draw: ({ context, width, height }) => {
- frame.draw(context, 0, 0, width, height);
- },
- });
- } finally {
- frame.close();
- }
-}
-
-async function generateImageThumbnail({
- imageFile,
-}: {
- imageFile: File;
-}): 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 thumbnailUrl = renderToThumbnailDataUrl({
- width: image.naturalWidth,
- height: image.naturalHeight,
- draw: ({ context, width, height }) => {
- context.drawImage(image, 0, 0, width, height);
- },
- });
- resolve({
- thumbnailUrl,
- width: image.naturalWidth,
- height: image.naturalHeight,
- });
- } catch (error) {
- reject(
- error instanceof Error ? error : new Error("Could not render image"),
- );
- } finally {
- URL.revokeObjectURL(objectUrl);
- image.remove();
- }
- });
-
- image.addEventListener("error", () => {
- URL.revokeObjectURL(objectUrl);
- image.remove();
- reject(new Error("Could not load image"));
- });
-
- image.src = objectUrl;
- });
-}
-
-export async function processMediaAssets({
- files,
- onProgress,
-}: {
- files: FileList | File[];
- onProgress?: ({ progress }: { progress: number }) => void;
-}): Promise<ProcessedMediaAsset[]> {
- const fileArray = Array.from(files);
- const processedAssets: ProcessedMediaAsset[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getMediaTypeFromFile({ file });
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- 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 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 });
- duration = videoInfo.duration;
- width = videoInfo.width;
- height = videoInfo.height;
- fps = Number.isFinite(videoInfo.fps)
- ? Math.round(videoInfo.fps)
- : undefined;
- hasAudio = videoInfo.hasAudio;
-
- thumbnailUrl = await generateThumbnail({
- videoFile: file,
- timeInSeconds: 1,
- });
- } catch (error) {
- console.warn("Video processing failed", error);
- }
- } else if (fileType === "audio") {
- // For audio, we don't set width/height/fps (they'll be undefined)
- duration = await getMediaDuration({ file });
- }
-
- processedAssets.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- width,
- height,
- fps,
- hasAudio,
- });
-
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress({ progress: percent });
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedAssets;
-}
-
-const getMediaDuration = ({ file }: { file: File }): Promise<number> => {
- return new Promise((resolve, reject) => {
- const element = document.createElement(
- file.type.startsWith("video/") ? "video" : "audio",
- ) as HTMLVideoElement;
- const objectUrl = URL.createObjectURL(file);
-
- element.addEventListener("loadedmetadata", () => {
- resolve(element.duration);
- URL.revokeObjectURL(objectUrl);
- element.remove();
- });
-
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- URL.revokeObjectURL(objectUrl);
- element.remove();
- });
-
- element.src = objectUrl;
- element.load();
- });
-};
diff --git a/apps/web/src/lib/media/types.ts b/apps/web/src/lib/media/types.ts
deleted file mode 100644
index 4d3e4106..00000000
--- a/apps/web/src/lib/media/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { MediaAssetData } from "@/services/storage/types";
-
-export type MediaType = "image" | "video" | "audio";
-
-export interface MediaAsset
- extends Omit<MediaAssetData, "size" | "lastModified"> {
- file: File;
- url?: string;
-}
diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts
deleted file mode 100644
index 45846206..00000000
--- a/apps/web/src/lib/media/upload-toast.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { toast } from "sonner";
-
-export interface MediaUploadToastResult {
- uploadedCount: number;
- assetNames?: string[];
-}
-
-function getAssetLabel({ count }: { count: number }): string {
- return count === 1 ? "media asset" : "media assets";
-}
-
-function waitForNextPaint(): Promise<void> {
- return new Promise((resolve) => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => resolve());
- });
- });
-}
-
-export async function showMediaUploadToast<T extends MediaUploadToastResult>({
- filesCount,
- promise,
-}: {
- filesCount: number;
- promise: Promise<T> | (() => Promise<T>);
-}) {
- const run = typeof promise === "function" ? promise : () => promise;
- const toastPromise = toast.promise(async () => {
- await waitForNextPaint();
- return run();
- }, {
- loading: `Uploading ${getAssetLabel({ count: filesCount })}...`,
- success: ({ uploadedCount, assetNames }) => {
- if (uploadedCount === 1) {
- const assetName = assetNames?.[0];
- return assetName
- ? `${assetName} has been uploaded`
- : "1 media asset has been uploaded";
- }
-
- if (uploadedCount > 1) {
- return `${uploadedCount} media assets have been uploaded`;
- }
-
- return "No media assets were uploaded";
- },
- error: `Failed to upload ${getAssetLabel({ count: filesCount })}`,
- });
-
- return toastPromise.unwrap();
-}
diff --git a/apps/web/src/lib/media/waveform-summary.ts b/apps/web/src/lib/media/waveform-summary.ts
deleted file mode 100644
index 522d6e04..00000000
--- a/apps/web/src/lib/media/waveform-summary.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-"use client";
-
-import { getSourceTimeAtClipTime } from "@/lib/retime";
-import type { RetimeConfig } from "@/lib/timeline";
-
-const RMS_ANALYSIS_WINDOW_SECONDS = 0.02;
-const DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE = 128;
-
-function computePeakBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- const channels = buffer.numberOfChannels;
- const channelData: Float32Array[] = Array.from({ length: channels }, (_, c) =>
- buffer.getChannelData(c),
- );
-
- return buckets.map(({ bucketStart, bucketEnd }) => {
- let peak = 0;
- for (let c = 0; c < channels; c++) {
- const data = channelData[c];
- for (let j = bucketStart; j < bucketEnd; j++) {
- const abs = Math.abs(data[j] ?? 0);
- if (abs > peak) {
- peak = abs;
- }
- }
- }
- return peak;
- });
-}
-
-export interface SampleBucket {
- bucketStart: number;
- bucketEnd: number;
-}
-
-export interface SourceWaveformSummary {
- sourceKey: string;
- sampleRate: number;
- totalSamples: number;
- bucketSize: number;
- amplitudes: Float32Array;
-}
-
-export function buildWaveformSourceKey({
- kind,
- id,
-}: {
- kind: "media" | "library";
- id: string;
-}): string {
- return `${kind}:${id}`;
-}
-
-export function buildSourceWaveformSummary({
- sourceKey,
- buffer,
- bucketSize = DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE,
-}: {
- sourceKey: string;
- buffer: AudioBuffer;
- bucketSize?: number;
-}): SourceWaveformSummary {
- const safeBucketSize = Math.max(1, Math.floor(bucketSize));
- const bucketCount = Math.max(1, Math.ceil(buffer.length / safeBucketSize));
- const amplitudes = computePeakBuckets({
- buffer,
- buckets: Array.from({ length: bucketCount }, (_, bucketIndex) => {
- const bucketStart = bucketIndex * safeBucketSize;
- const bucketEnd = Math.min(buffer.length, bucketStart + safeBucketSize);
- return { bucketStart, bucketEnd };
- }),
- });
-
- return {
- sourceKey,
- sampleRate: buffer.sampleRate,
- totalSamples: buffer.length,
- bucketSize: safeBucketSize,
- amplitudes: Float32Array.from(amplitudes),
- };
-}
-
-export function buildWaveformSampleBuckets({
- clipLeftPx,
- clipRightPx,
- barCount,
- pixelsPerSecond,
- clipDurationSec,
- sourceStartSec,
- retime,
- sampleRate,
- maxSampleExclusive,
- barStepPx,
-}: {
- clipLeftPx: number;
- clipRightPx: number;
- barCount: number;
- pixelsPerSecond: number;
- clipDurationSec: number;
- sourceStartSec: number;
- retime?: RetimeConfig;
- sampleRate: number;
- maxSampleExclusive: number;
- barStepPx: number;
-}): SampleBucket[] {
- return Array.from({ length: barCount }, (_, index) => {
- const bucketLeftPx = clipLeftPx + index * barStepPx;
- const bucketRightPx = Math.min(clipRightPx, bucketLeftPx + barStepPx);
- const clipStartSec = Math.max(
- 0,
- Math.min(clipDurationSec, bucketLeftPx / pixelsPerSecond),
- );
- const clipEndSec = Math.max(
- clipStartSec,
- Math.min(clipDurationSec, bucketRightPx / pixelsPerSecond),
- );
- const sourceBucketStartSec =
- sourceStartSec +
- getSourceTimeAtClipTime({
- clipTime: clipStartSec,
- retime,
- });
- const sourceBucketEndSec =
- sourceStartSec +
- getSourceTimeAtClipTime({
- clipTime: clipEndSec,
- retime,
- });
-
- return {
- bucketStart: Math.max(0, Math.floor(sourceBucketStartSec * sampleRate)),
- bucketEnd: Math.min(
- maxSampleExclusive,
- Math.max(0, Math.ceil(sourceBucketEndSec * sampleRate)),
- ),
- };
- });
-}
-
-export function sampleSourceWaveformSummary({
- summary,
- buckets,
-}: {
- summary: SourceWaveformSummary;
- buckets: SampleBucket[];
-}): number[] {
- return buckets.map(({ bucketStart, bucketEnd }) => {
- if (bucketEnd <= bucketStart) {
- return 0;
- }
-
- const startIndex = Math.max(
- 0,
- Math.floor(bucketStart / summary.bucketSize),
- );
- const endIndex = Math.min(
- summary.amplitudes.length,
- Math.max(startIndex + 1, Math.ceil(bucketEnd / summary.bucketSize)),
- );
-
- let maxAmplitude = 0;
- for (let i = startIndex; i < endIndex; i++) {
- const amplitude = summary.amplitudes[i] ?? 0;
- if (amplitude > maxAmplitude) {
- maxAmplitude = amplitude;
- }
- }
-
- return maxAmplitude;
- });
-}
-
-export function computeRmsBuckets({
- buffer,
- buckets,
-}: {
- buffer: AudioBuffer;
- buckets: SampleBucket[];
-}): number[] {
- const channels = buffer.numberOfChannels;
- const maxWindowLength = Math.max(
- 1,
- Math.floor(buffer.sampleRate * RMS_ANALYSIS_WINDOW_SECONDS),
- );
-
- const channelData: Float32Array[] = new Array(channels);
- for (let c = 0; c < channels; c++) {
- channelData[c] = buffer.getChannelData(c);
- }
-
- const result = new Array<number>(buckets.length);
-
- for (let i = 0; i < buckets.length; i++) {
- const { bucketStart, bucketEnd } = buckets[i];
- const bucketLength = bucketEnd - bucketStart;
- if (bucketLength <= 0) {
- result[i] = 0;
- continue;
- }
-
- const windowLength = Math.max(1, Math.min(bucketLength, maxWindowLength));
- let maxMeanSquare = 0;
-
- for (let winStart = bucketStart; winStart < bucketEnd; ) {
- const winEnd = Math.min(winStart + windowLength, bucketEnd);
- const n = winEnd - winStart;
- if (n > 0) {
- let sum = 0;
- for (let c = 0; c < channels; c++) {
- const data = channelData[c];
- for (let j = winStart; j < winEnd; j++) {
- const v = data[j];
- sum += v * v;
- }
- }
- const meanSquare = sum / (n * channels);
- if (meanSquare > maxMeanSquare) {
- maxMeanSquare = meanSquare;
- }
- }
- winStart = winEnd;
- }
-
- result[i] = Math.sqrt(maxMeanSquare);
- }
-
- return result;
-}
diff --git a/apps/web/src/lib/panels/layout.ts b/apps/web/src/lib/panels/layout.ts
deleted file mode 100644
index 5a570469..00000000
--- a/apps/web/src/lib/panels/layout.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export const PANEL_CONFIG = {
- panels: {
- tools: 25,
- preview: 50,
- properties: 25,
- mainContent: 50,
- timeline: 50,
- },
-} as const;
diff --git a/apps/web/src/lib/params.ts b/apps/web/src/lib/params.ts
deleted file mode 100644
index 633bfbd0..00000000
--- a/apps/web/src/lib/params.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
deleted file mode 100644
index 39595c33..00000000
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
-import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
-import { measureTextElement } from "@/lib/text/measure-element";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-
-export interface ElementBounds {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
-}
-
-export interface ElementWithBounds {
- trackId: string;
- elementId: string;
- element: TimelineElement;
- bounds: ElementBounds;
-}
-
-function getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- sourceWidth: number;
- sourceHeight: number;
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const containScale = Math.min(
- canvasWidth / sourceWidth,
- canvasHeight / sourceHeight,
- );
- const scaledWidth = sourceWidth * containScale * transform.scaleX;
- const scaledHeight = sourceHeight * containScale * transform.scaleY;
- const cx = canvasWidth / 2 + transform.position.x;
- const cy = canvasHeight / 2 + transform.position.y;
-
- return {
- cx,
- cy,
- width: scaledWidth,
- height: scaledHeight,
- rotation: transform.rotate,
- };
-}
-
-function getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- rect: { left: number; top: number; width: number; height: number };
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const localCenterX = rect.left + rect.width / 2;
- const localCenterY = rect.top + rect.height / 2;
- const scaledCenterX = localCenterX * transform.scaleX;
- const scaledCenterY = localCenterY * transform.scaleY;
- const rotationRad = (transform.rotate * Math.PI) / 180;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
- return {
- cx:
- canvasWidth / 2 +
- transform.position.x +
- scaledCenterX * cos -
- scaledCenterY * sin,
- cy:
- canvasHeight / 2 +
- transform.position.y +
- scaledCenterX * sin +
- scaledCenterY * cos,
- width: rect.width * transform.scaleX,
- height: rect.height * transform.scaleY,
- rotation: transform.rotate,
- };
-}
-
-/**
- * Bounds policy: bounds reflect base content geometry (text glyphs + background,
- * sticker/image/video content area) and base transform. Post-effect spill (blur,
- * glow) and mask-clipped regions are intentionally excluded — handles manipulate
- * the canonical element geometry, not visual effect output.
- */
-function getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
-}: {
- element: TimelineElement;
- canvasSize: { width: number; height: number };
- mediaAsset?: MediaAsset | null;
- localTime: number;
-}): ElementBounds | null {
- if (element.type === "audio" || element.type === "effect") return null;
- if ("hidden" in element && element.hidden) return null;
-
- const { width: canvasWidth, height: canvasHeight } = canvasSize;
-
- if (element.type === "video" || element.type === "image") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const sourceWidth = mediaAsset?.width ?? canvasWidth;
- const sourceHeight = mediaAsset?.height ?? canvasHeight;
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
- });
- }
-
- if (element.type === "sticker") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- transform,
- });
- }
-
- if (element.type === "graphic") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
- sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
- transform,
- });
- }
-
- if (element.type === "text") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (!ctx) return null;
-
- const measured = measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
- });
-
- return getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect: measured.visualRect,
- transform,
- });
- }
-
- return null;
-}
-
-export const ROTATION_HANDLE_OFFSET = 24;
-
-export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-export type Edge = "right" | "left" | "bottom";
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getEdgeHandlePosition({
- bounds,
- edge,
-}: {
- bounds: ElementBounds;
- edge: Edge;
-}): { x: number; y: number } {
- const halfWidth = bounds.width / 2;
- const halfHeight = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
- const localY = edge === "bottom" ? halfHeight : 0;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- currentTime: number;
- canvasSize: { width: number; height: number };
- mediaAssets: MediaAsset[];
-}): ElementWithBounds[] {
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
- const orderedTracks = [
- ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
- ...(!tracks.main.hidden ? [tracks.main] : []),
- ].reverse();
-
- const result: ElementWithBounds[] = [];
-
- for (const track of orderedTracks) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .filter(
- (element) =>
- currentTime >= element.startTime &&
- currentTime < element.startTime + element.duration,
- )
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
-
- for (const element of elements) {
- const localTime = getElementLocalTime({
- timelineTime: currentTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const mediaAsset =
- element.type === "video" || element.type === "image"
- ? mediaMap.get(element.mediaId)
- : undefined;
- const bounds = getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
- });
- if (bounds) {
- result.push({
- trackId: track.id,
- elementId: element.id,
- element,
- bounds,
- });
- }
- }
- }
-
- return result;
-}
diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts
deleted file mode 100644
index 2552922b..00000000
--- a/apps/web/src/lib/preview/hit-test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import type { ElementWithBounds } from "./element-bounds";
-import type { ElementRef } from "@/lib/timeline/types";
-
-function pointInRotatedRect({
- px,
- py,
- cx,
- cy,
- width,
- height,
- rotation,
-}: {
- px: number;
- py: number;
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
-}): boolean {
- const angleRad = (rotation * Math.PI) / 180;
- const cos = Math.cos(-angleRad);
- const sin = Math.sin(-angleRad);
- const dx = px - cx;
- const dy = py - cy;
- const localX = dx * cos - dy * sin;
- const localY = dx * sin + dy * cos;
- const halfW = Math.abs(width) / 2;
- const halfH = Math.abs(height) / 2;
- return (
- localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
- );
-}
-
-export function getHitElements({
- canvasX,
- canvasY,
- elementsWithBounds,
-}: {
- canvasX: number;
- canvasY: number;
- elementsWithBounds: ElementWithBounds[];
-}): ElementWithBounds[] {
- const hits: ElementWithBounds[] = [];
-
- for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
- const { bounds } = elementsWithBounds[i];
- if (
- pointInRotatedRect({
- px: canvasX,
- py: canvasY,
- cx: bounds.cx,
- cy: bounds.cy,
- width: bounds.width,
- height: bounds.height,
- rotation: bounds.rotation,
- })
- ) {
- hits.push(elementsWithBounds[i]);
- }
- }
-
- 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
deleted file mode 100644
index 64184990..00000000
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-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,
- geometry,
- viewportRect,
-}: {
- clientX: number;
- clientY: number;
- geometry: PreviewViewportGeometry;
- viewportRect: DOMRect;
-}): { x: number; y: number } {
- const overlayX = clientX - viewportRect.left;
- const overlayY = clientY - viewportRect.top;
-
- return {
- x:
- geometry.centerX +
- (overlayX - geometry.viewportWidth / 2) / geometry.scale,
- y:
- geometry.centerY +
- (overlayY - geometry.viewportHeight / 2) / geometry.scale,
- };
-}
-
-export function canvasToOverlay({
- canvasX,
- canvasY,
- geometry,
-}: {
- canvasX: number;
- canvasY: number;
- geometry: PreviewViewportGeometry;
-}): { x: number; y: number } {
- const canvasOrigin = getCanvasOrigin({ geometry });
-
- return {
- x: canvasOrigin.x + canvasX * geometry.scale,
- y: canvasOrigin.y + canvasY * geometry.scale,
- };
-}
-
-export function positionToOverlay({
- positionX,
- positionY,
- geometry,
-}: {
- positionX: number;
- positionY: number;
- geometry: PreviewViewportGeometry;
-}): { x: number; y: number } {
- return canvasToOverlay({
- canvasX: geometry.canvasWidth / 2 + positionX,
- canvasY: geometry.canvasHeight / 2 + positionY,
- geometry,
- });
-}
-
-export function getDisplayScale({
- geometry,
-}: {
- geometry: PreviewViewportGeometry;
-}): { x: number; y: number } {
- return {
- x: geometry.scale,
- y: geometry.scale,
- };
-}
-
-export function screenPixelsToLogicalThreshold({
- geometry,
- screenPixels,
-}: {
- geometry: PreviewViewportGeometry;
- screenPixels: number;
-}): { x: number; y: number } {
- return {
- 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
deleted file mode 100644
index 86d35390..00000000
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ /dev/null
@@ -1,539 +0,0 @@
-export interface SnapLine {
- type: "horizontal" | "vertical";
- position: number;
-}
-
-const ROTATION_SNAP_STEP_DEGREES = 90;
-const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
-export const MIN_SCALE = 0.01;
-export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
-
-export interface SnapResult {
- snappedPosition: { x: number; y: number };
- activeLines: SnapLine[];
-}
-
-type ScaleEdge = "left" | "right" | "top" | "bottom";
-
-export interface ScaleEdgePreference {
- left?: boolean;
- right?: boolean;
- top?: boolean;
- bottom?: boolean;
-}
-
-function hasPreferredEdge({
- preferredEdges,
- edge,
-}: {
- preferredEdges?: ScaleEdgePreference;
- edge: ScaleEdge;
-}): boolean {
- return preferredEdges?.[edge] === true;
-}
-
-function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
- candidates,
- preferredEdges,
-}: {
- candidates: T[];
- preferredEdges?: ScaleEdgePreference;
-}): T | null {
- if (candidates.length === 0) {
- return null;
- }
-
- return candidates.reduce((bestCandidate, candidate) => {
- if (candidate.distance < bestCandidate.distance) {
- return candidate;
- }
- if (candidate.distance > bestCandidate.distance) {
- return bestCandidate;
- }
-
- const shouldPreferCandidate = hasPreferredEdge({
- preferredEdges,
- edge: candidate.edge,
- });
- const shouldPreferBestCandidate = hasPreferredEdge({
- preferredEdges,
- edge: bestCandidate.edge,
- });
-
- return shouldPreferCandidate && !shouldPreferBestCandidate
- ? candidate
- : bestCandidate;
- });
-}
-
-export function snapPosition({
- proposedPosition,
- canvasSize,
- elementSize,
- rotation = 0,
- snapThreshold,
-}: {
- proposedPosition: { x: number; y: number };
- canvasSize: { width: number; height: number };
- elementSize: { width: number; height: number };
- rotation?: number;
- snapThreshold: { x: number; y: number };
-}): SnapResult {
- const centerX = 0;
- const centerY = 0;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
- const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
- const activeLines: SnapLine[] = [];
-
- type AxisSnapCandidate = {
- snappedPosition: number;
- line: SnapLine;
- distance: number;
- };
-
- function getClosestAxisSnap({
- candidates,
- threshold,
- }: {
- candidates: AxisSnapCandidate[];
- threshold: number;
- }): AxisSnapCandidate | null {
- const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= threshold,
- );
- if (snapCandidatesWithinThreshold.length === 0) {
- return null;
- }
- return snapCandidatesWithinThreshold.reduce((closest, current) =>
- current.distance < closest.distance ? current : closest,
- );
- }
-
- const verticalTargets = [centerX, left, right];
- const horizontalTargets = [centerY, top, bottom];
-
- const xCandidates: AxisSnapCandidate[] = [];
- for (const targetX of verticalTargets) {
- xCandidates.push({
- snappedPosition: targetX,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX + halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - halfWidth - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX - halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x + halfWidth - targetX),
- });
- }
- const yCandidates: AxisSnapCandidate[] = [];
- for (const targetY of horizontalTargets) {
- yCandidates.push({
- snappedPosition: targetY,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY + halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - halfHeight - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY - halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y + halfHeight - targetY),
- });
- }
-
- const closestX = getClosestAxisSnap({
- candidates: xCandidates,
- threshold: snapThreshold.x,
- });
- const closestY = getClosestAxisSnap({
- candidates: yCandidates,
- threshold: snapThreshold.y,
- });
-
- const x = closestX?.snappedPosition ?? proposedPosition.x;
- const y = closestY?.snappedPosition ?? proposedPosition.y;
- if (closestX) {
- activeLines.push(closestX.line);
- }
- if (closestY) {
- activeLines.push(closestY.line);
- }
-
- return {
- snappedPosition: { x, y },
- activeLines,
- };
-}
-
-export interface ScaleSnapResult {
- snappedScale: number;
- activeLines: SnapLine[];
-}
-
-export function snapScale({
- proposedScale,
- position,
- baseWidth,
- baseHeight,
- rotation = 0,
- canvasSize,
- snapThreshold,
- preferredEdges,
-}: {
- proposedScale: number;
- position: { x: number; y: number };
- baseWidth: number;
- baseHeight: number;
- rotation?: number;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
- preferredEdges?: ScaleEdgePreference;
-}): ScaleSnapResult {
- const centerX = 0;
- const centerY = 0;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
- const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
-
- const leftEdge = position.x - aabbBaseHalfW * proposedScale;
- const rightEdge = position.x + aabbBaseHalfW * proposedScale;
- const topEdge = position.y - aabbBaseHalfH * proposedScale;
- const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
-
- interface SnapCandidate {
- scale: number;
- distance: number;
- lines: SnapLine[];
- edge: ScaleEdge;
- }
-
- const candidates: SnapCandidate[] = [];
-
- const verticalTargets = [
- { position: left, line: { type: "vertical" as const, position: left } },
- {
- position: centerX,
- line: { type: "vertical" as const, position: centerX },
- },
- { position: right, line: { type: "vertical" as const, position: right } },
- ];
-
- for (const target of verticalTargets) {
- const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= snapThreshold.x) {
- const scale = (position.x - target.position) / aabbBaseHalfW;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceLeft,
- lines: [target.line],
- edge: "left",
- });
- }
- }
- const distanceRight = Math.abs(rightEdge - target.position);
- if (distanceRight <= snapThreshold.x) {
- const scale = (target.position - position.x) / aabbBaseHalfW;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceRight,
- lines: [target.line],
- edge: "right",
- });
- }
- }
- }
-
- const horizontalTargets = [
- { position: top, line: { type: "horizontal" as const, position: top } },
- {
- position: centerY,
- line: { type: "horizontal" as const, position: centerY },
- },
- {
- position: bottom,
- line: { type: "horizontal" as const, position: bottom },
- },
- ];
-
- for (const target of horizontalTargets) {
- const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= snapThreshold.y) {
- const scale = (position.y - target.position) / aabbBaseHalfH;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceTop,
- lines: [target.line],
- edge: "top",
- });
- }
- }
- const distanceBottom = Math.abs(bottomEdge - target.position);
- if (distanceBottom <= snapThreshold.y) {
- const scale = (target.position - position.y) / aabbBaseHalfH;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceBottom,
- lines: [target.line],
- edge: "bottom",
- });
- }
- }
- }
-
- const best = pickClosestScaleCandidate({
- candidates,
- preferredEdges,
- });
- if (!best) {
- return { snappedScale: proposedScale, activeLines: [] };
- }
-
- const snappedLeft = position.x - aabbBaseHalfW * best.scale;
- const snappedRight = position.x + aabbBaseHalfW * best.scale;
- const snappedTop = position.y - aabbBaseHalfH * best.scale;
- const snappedBottom = position.y + aabbBaseHalfH * best.scale;
-
- const activeLines: SnapLine[] = [];
- const seenKeys = new Set<string>();
-
- function addLine({ line }: { line: SnapLine }) {
- const key = `${line.type}-${line.position}`;
- if (!seenKeys.has(key)) {
- seenKeys.add(key);
- activeLines.push(line);
- }
- }
-
- for (const target of verticalTargets) {
- if (
- (hasPreferredEdge({ preferredEdges, edge: "left" }) &&
- Math.abs(snappedLeft - target.position) <= 1) ||
- (hasPreferredEdge({ preferredEdges, edge: "right" }) &&
- Math.abs(snappedRight - target.position) <= 1) ||
- (!preferredEdges &&
- (Math.abs(snappedLeft - target.position) <= 1 ||
- Math.abs(snappedRight - target.position) <= 1))
- ) {
- addLine({ line: target.line });
- }
- }
- for (const target of horizontalTargets) {
- if (
- (hasPreferredEdge({ preferredEdges, edge: "top" }) &&
- Math.abs(snappedTop - target.position) <= 1) ||
- (hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
- Math.abs(snappedBottom - target.position) <= 1) ||
- (!preferredEdges &&
- (Math.abs(snappedTop - target.position) <= 1 ||
- Math.abs(snappedBottom - target.position) <= 1))
- ) {
- addLine({ line: target.line });
- }
- }
-
- return {
- snappedScale: best.scale,
- activeLines,
- };
-}
-
-export interface AxisSnapResult {
- snappedScale: number;
- /** Infinity when no snap candidate was within threshold */
- snapDistance: number;
- activeLines: SnapLine[];
-}
-
-export function snapScaleAxes({
- proposedScaleX,
- proposedScaleY,
- position,
- baseWidth,
- baseHeight,
- rotation = 0,
- canvasSize,
- snapThreshold,
- preferredEdges,
-}: {
- proposedScaleX: number;
- proposedScaleY: number;
- position: { x: number; y: number };
- baseWidth: number;
- baseHeight: number;
- rotation?: number;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
- preferredEdges?: ScaleEdgePreference;
-}): { x: AxisSnapResult; y: AxisSnapResult } {
- const canvasLeft = -canvasSize.width / 2;
- const canvasRight = canvasSize.width / 2;
- const canvasTop = -canvasSize.height / 2;
- const canvasBottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const EPSILON = 1e-6;
-
- // Current AABB edges at proposed scales
- const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
- const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
- const currentLeftEdge = position.x - currentAabbHalfW;
- const currentRightEdge = position.x + currentAabbHalfW;
- const currentTopEdge = position.y - currentAabbHalfH;
- const currentBottomEdge = position.y + currentAabbHalfH;
-
- interface Candidate {
- scale: number;
- distance: number;
- line: SnapLine;
- edge: ScaleEdge;
- }
-
- function bestCandidate({
- candidates,
- proposedScale,
- }: {
- candidates: Candidate[];
- proposedScale: number;
- }): AxisSnapResult {
- const best = pickClosestScaleCandidate({
- candidates,
- preferredEdges,
- });
- if (!best) {
- return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
- }
- return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
- }
-
- // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
- // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
- const xCandidates: Candidate[] = [];
- const yContribW = baseHeight * proposedScaleY * sinR;
- const yContribH = baseHeight * proposedScaleY * cosR;
-
- if (cosR > EPSILON) {
- for (const T of [canvasLeft, 0, canvasRight]) {
- const line: SnapLine = { type: "vertical", position: T };
- const distLeft = Math.abs(currentLeftEdge - T);
- if (distLeft <= snapThreshold.x) {
- const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
- }
- const distRight = Math.abs(currentRightEdge - T);
- if (distRight <= snapThreshold.x) {
- const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
- }
- }
- }
-
- if (sinR > EPSILON) {
- for (const T of [canvasTop, 0, canvasBottom]) {
- const line: SnapLine = { type: "horizontal", position: T };
- const distTop = Math.abs(currentTopEdge - T);
- if (distTop <= snapThreshold.y) {
- const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
- }
- const distBottom = Math.abs(currentBottomEdge - T);
- if (distBottom <= snapThreshold.y) {
- const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
- }
- }
- }
-
- // sY candidates: snap via vertical targets — only valid when sinR ≠ 0
- // snap via horizontal targets — only valid when cosR ≠ 0
- const yCandidates: Candidate[] = [];
- const xContribW = baseWidth * proposedScaleX * cosR;
- const xContribH = baseWidth * proposedScaleX * sinR;
-
- if (sinR > EPSILON) {
- for (const T of [canvasLeft, 0, canvasRight]) {
- const line: SnapLine = { type: "vertical", position: T };
- const distLeft = Math.abs(currentLeftEdge - T);
- if (distLeft <= snapThreshold.x) {
- const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
- }
- const distRight = Math.abs(currentRightEdge - T);
- if (distRight <= snapThreshold.x) {
- const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
- }
- }
- }
-
- if (cosR > EPSILON) {
- for (const T of [canvasTop, 0, canvasBottom]) {
- const line: SnapLine = { type: "horizontal", position: T };
- const distTop = Math.abs(currentTopEdge - T);
- if (distTop <= snapThreshold.y) {
- const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
- }
- const distBottom = Math.abs(currentBottomEdge - T);
- if (distBottom <= snapThreshold.y) {
- const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
- }
- }
- }
-
- return {
- x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
- y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
- };
-}
-
-export interface RotationSnapResult {
- snappedRotation: number;
- isSnapped: boolean;
-}
-
-export function snapRotation({
- proposedRotation,
-}: {
- proposedRotation: number;
-}): RotationSnapResult {
- const nearestRotationSnap =
- Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
- ROTATION_SNAP_STEP_DEGREES;
- const distanceToNearestSnap = Math.abs(
- proposedRotation - nearestRotationSnap,
- );
- if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
- return { snappedRotation: nearestRotationSnap, isSnapped: true };
- }
- return { snappedRotation: proposedRotation, isSnapped: false };
-}
diff --git a/apps/web/src/lib/preview/zoom.ts b/apps/web/src/lib/preview/zoom.ts
deleted file mode 100644
index 9e6f5905..00000000
--- a/apps/web/src/lib/preview/zoom.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
-
-export const PREVIEW_ZOOM = {
- min: 0.25,
- max: 16,
- step: 1.25,
-} as const;
diff --git a/apps/web/src/lib/project/types.ts b/apps/web/src/lib/project/types.ts
deleted file mode 100644
index 0a454e07..00000000
--- a/apps/web/src/lib/project/types.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-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: FrameRate;
- canvasSize: TCanvasSize;
- canvasSizeMode?: "preset" | "custom";
- lastCustomCanvasSize?: TCanvasSize | null;
- originalCanvasSize?: TCanvasSize | null;
- background: TBackground;
-}
-
-export interface TTimelineViewState {
- zoomLevel: number;
- scrollLeft: number;
- playheadTime: number;
-}
-
-export interface TProject {
- metadata: TProjectMetadata;
- scenes: TScene[];
- currentSceneId: string;
- settings: TProjectSettings;
- version: number;
- timelineViewState?: TTimelineViewState;
-}
-
-export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
-export type TSortOrder = "asc" | "desc";
-export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
deleted file mode 100644
index 234bf48f..00000000
--- a/apps/web/src/lib/rate-limit.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Ratelimit } from "@upstash/ratelimit";
-import { Redis } from "@upstash/redis";
-import { webEnv } from "@/lib/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const baseRateLimit = new Ratelimit({
- redis,
- limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
- analytics: true,
- prefix: "rate-limit",
-});
-
-export async function checkRateLimit({ request }: { request: Request }) {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- return { success, limited: !success };
-}
diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts
deleted file mode 100644
index f1c92f67..00000000
--- a/apps/web/src/lib/registry.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { ParamDefinition, ParamValues } from "@/lib/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
diff --git a/apps/web/src/lib/rendering.ts b/apps/web/src/lib/rendering.ts
deleted file mode 100644
index c5555371..00000000
--- a/apps/web/src/lib/rendering.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 2fbd6675..00000000
--- a/apps/web/src/lib/retime/__tests__/resolve.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index a00eadac..00000000
--- a/apps/web/src/lib/retime/__tests__/split.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-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
deleted file mode 100644
index 92902dff..00000000
--- a/apps/web/src/lib/retime/audio-stretch.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { PitchShifter } from "soundtouchjs";
-import { clampRetimeRate, shouldMaintainPitch } from "@/lib/retime/rate";
-import type { RetimeConfig } from "@/lib/timeline";
-import { getSourceTimeAtClipTime } from "./resolve";
-
-const RATE_EPSILON = 1e-6;
-
-function sampleLinear({
- channelData,
- position,
-}: {
- channelData: Float32Array;
- position: number;
-}): number {
- if (position <= 0) {
- return channelData[0] ?? 0;
- }
- const lower = Math.floor(position);
- const upper = Math.min(channelData.length - 1, lower + 1);
- if (lower >= channelData.length) {
- return 0;
- }
- const fraction = position - lower;
- return channelData[lower] * (1 - fraction) + channelData[upper] * fraction;
-}
-
-function buildResampledBuffer({
- audioContext,
- sourceBuffer,
- trimStart,
- clipDuration,
- targetSampleRate,
- retime,
-}: {
- audioContext: BaseAudioContext;
- sourceBuffer: AudioBuffer;
- trimStart: number;
- clipDuration: number;
- targetSampleRate: number;
- retime?: RetimeConfig;
-}): AudioBuffer {
- const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate));
- const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels));
- const outputBuffer = audioContext.createBuffer(
- numChannels,
- outputLength,
- targetSampleRate,
- );
-
- for (let channel = 0; channel < numChannels; channel++) {
- const sourceData = sourceBuffer.getChannelData(
- Math.min(channel, sourceBuffer.numberOfChannels - 1),
- );
- const outputData = outputBuffer.getChannelData(channel);
-
- for (let i = 0; i < outputLength; i++) {
- const clipTime = i / targetSampleRate;
- const sourceTime =
- trimStart + getSourceTimeAtClipTime({ clipTime, retime });
- outputData[i] = sampleLinear({
- channelData: sourceData,
- position: sourceTime * sourceBuffer.sampleRate,
- });
- }
- }
-
- return outputBuffer;
-}
-
-async function buildPitchPreservedBuffer({
- sourceBuffer,
- trimStart,
- clipDuration,
- rate,
- targetSampleRate,
-}: {
- sourceBuffer: AudioBuffer;
- trimStart: number;
- clipDuration: number;
- rate: number;
- targetSampleRate: number;
-}): Promise<AudioBuffer> {
- const nativeSampleRate = sourceBuffer.sampleRate;
- const sourceDuration = clipDuration * rate;
- const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate));
- const numSourceSamples = Math.max(
- 1,
- Math.ceil(sourceDuration * nativeSampleRate),
- );
- const available = Math.max(0, sourceBuffer.length - startSample);
- const actualSamples = Math.max(1, Math.min(numSourceSamples, available));
- const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels));
-
- // Resample to targetSampleRate first — soundtouchjs reads raw channel data
- // and does not respect the source buffer's native sample rate.
- const resampledLength = Math.max(
- 1,
- Math.ceil(sourceDuration * targetSampleRate),
- );
- const resampleCtx = new OfflineAudioContext(
- numChannels,
- resampledLength,
- targetSampleRate,
- );
- const nativeBuffer = resampleCtx.createBuffer(
- numChannels,
- actualSamples,
- nativeSampleRate,
- );
-
- for (let ch = 0; ch < numChannels; ch++) {
- const src = sourceBuffer.getChannelData(
- Math.min(ch, sourceBuffer.numberOfChannels - 1),
- );
- nativeBuffer.copyToChannel(
- src.subarray(startSample, startSample + actualSamples),
- ch,
- );
- }
-
- const resampleSourceNode = resampleCtx.createBufferSource();
- resampleSourceNode.buffer = nativeBuffer;
- resampleSourceNode.connect(resampleCtx.destination);
- resampleSourceNode.start(0);
- const resampledBuffer = await resampleCtx.startRendering();
-
- const outputSamples = Math.max(
- 1,
- Math.ceil(clipDuration * targetSampleRate),
- );
- const stretchCtx = new OfflineAudioContext(
- numChannels,
- outputSamples,
- targetSampleRate,
- );
- const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096);
- shifter.tempo = rate;
- shifter.pitch = 1;
- shifter.connect(stretchCtx.destination);
- return stretchCtx.startRendering();
-}
-
-export async function renderRetimedBuffer({
- audioContext,
- sourceBuffer,
- trimStart,
- clipDuration,
- retime,
- maintainPitch = false,
-}: {
- audioContext: BaseAudioContext;
- sourceBuffer: AudioBuffer;
- trimStart: number;
- clipDuration: number;
- retime?: RetimeConfig;
- maintainPitch?: boolean;
-}): Promise<AudioBuffer> {
- const targetSampleRate = audioContext.sampleRate;
- const rate = clampRetimeRate({ rate: retime?.rate ?? 1 });
- const usePitchPreservation =
- shouldMaintainPitch({ rate, maintainPitch }) &&
- Math.abs(rate - 1) > RATE_EPSILON;
-
- if (usePitchPreservation) {
- return buildPitchPreservedBuffer({
- sourceBuffer,
- trimStart,
- clipDuration,
- rate,
- targetSampleRate,
- });
- }
-
- return buildResampledBuffer({
- audioContext,
- sourceBuffer,
- trimStart,
- clipDuration,
- targetSampleRate,
- retime,
- });
-}
diff --git a/apps/web/src/lib/retime/index.ts b/apps/web/src/lib/retime/index.ts
deleted file mode 100644
index 2ab1d10f..00000000
--- a/apps/web/src/lib/retime/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from "./rate";
-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
deleted file mode 100644
index fbd391ff..00000000
--- a/apps/web/src/lib/retime/presets.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { RetimeConfig } from "@/lib/timeline";
-import { clampRetimeRate } from "@/lib/retime/rate";
-
-export function buildConstantRetime({
- rate,
- maintainPitch = false,
-}: {
- rate: number;
- maintainPitch?: boolean;
-}): RetimeConfig {
- return { rate: clampRetimeRate({ rate }), maintainPitch };
-}
diff --git a/apps/web/src/lib/retime/rate.ts b/apps/web/src/lib/retime/rate.ts
deleted file mode 100644
index 75ea530b..00000000
--- a/apps/web/src/lib/retime/rate.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export const DEFAULT_RETIME_RATE = 1;
-export const MIN_RETIME_RATE = 0.01;
-export const MAX_RETIME_RATE = 5;
-
-export function clampRetimeRate({ rate }: { rate: number }): number {
- if (!Number.isFinite(rate) || rate <= 0) {
- return DEFAULT_RETIME_RATE;
- }
-
- return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
-}
-
-export function canMaintainPitch({ rate }: { rate: number }): boolean {
- return Number.isFinite(rate) && rate > 0;
-}
-
-export function shouldMaintainPitch({
- rate,
- maintainPitch,
-}: {
- rate: number;
- maintainPitch?: boolean;
-}): boolean {
- return maintainPitch === true && canMaintainPitch({ rate });
-}
diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts
deleted file mode 100644
index 5e7236d5..00000000
--- a/apps/web/src/lib/retime/resolve.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { RetimeConfig } from "@/lib/timeline";
-import { clampRetimeRate } from "@/lib/retime/rate";
-
-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
deleted file mode 100644
index 2635590e..00000000
--- a/apps/web/src/lib/retime/split.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts
deleted file mode 100644
index f96e4600..00000000
--- a/apps/web/src/lib/ripple/apply.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { SceneTracks, TimelineTrack } from "@/lib/timeline/types";
-import { rippleShiftElements } from "./shift";
-
-export interface RippleAdjustment {
- trackId: string;
- afterTime: number;
- shiftAmount: number;
-}
-
-export function applyRippleAdjustments({
- tracks,
- adjustments,
-}: {
- tracks: SceneTracks;
- adjustments: RippleAdjustment[];
-}): SceneTracks {
- if (adjustments.length === 0) {
- return tracks;
- }
-
- const adjustmentsByTrack = new Map<string, RippleAdjustment[]>();
- for (const adjustment of adjustments) {
- const trackAdjustments = adjustmentsByTrack.get(adjustment.trackId) ?? [];
- trackAdjustments.push(adjustment);
- adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
- }
-
- return {
- overlay: tracks.overlay.map((track) =>
- applyTrackRippleAdjustments({
- track,
- adjustments: adjustmentsByTrack.get(track.id) ?? [],
- }),
- ),
- main: applyTrackRippleAdjustments({
- track: tracks.main,
- adjustments: adjustmentsByTrack.get(tracks.main.id) ?? [],
- }),
- audio: tracks.audio.map((track) =>
- applyTrackRippleAdjustments({
- track,
- adjustments: adjustmentsByTrack.get(track.id) ?? [],
- }),
- ),
- };
-}
-
-function applyTrackRippleAdjustments<
- TElement extends TimelineTrack["elements"][number],
- TTrack extends TimelineTrack & { elements: TElement[] },
->({
- track,
- adjustments,
-}: {
- track: TTrack;
- adjustments: RippleAdjustment[];
-}): TTrack {
- if (adjustments.length === 0) {
- return track;
- }
-
- const sortedAdjustments = [...adjustments].sort(
- (firstAdjustment, secondAdjustment) =>
- secondAdjustment.afterTime - firstAdjustment.afterTime,
- );
-
- let elements: TElement[] = track.elements;
- for (const adjustment of sortedAdjustments) {
- elements = rippleShiftElements({
- elements,
- afterTime: adjustment.afterTime,
- shiftAmount: adjustment.shiftAmount,
- });
- }
-
- return { ...track, elements };
-}
diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts
deleted file mode 100644
index 49238738..00000000
--- a/apps/web/src/lib/ripple/diff.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline/types";
-import type { RippleAdjustment } from "./apply";
-
-interface Interval {
- startTime: number;
- endTime: number;
-}
-
-interface ElementSpan extends Interval {
- id: string;
-}
-
-export function computeRippleAdjustments({
- beforeTracks,
- afterTracks,
-}: {
- beforeTracks: SceneTracks;
- afterTracks: SceneTracks;
-}): RippleAdjustment[] {
- const beforeTrackList = [
- ...beforeTracks.overlay,
- beforeTracks.main,
- ...beforeTracks.audio,
- ];
- const afterTrackList = [
- ...afterTracks.overlay,
- afterTracks.main,
- ...afterTracks.audio,
- ];
- const afterTracksById = new Map(afterTrackList.map((track) => [track.id, track]));
- const allAfterElementIds = new Set(
- afterTrackList.flatMap((track) => track.elements.map((element) => element.id)),
- );
-
- return beforeTrackList.flatMap((beforeTrack): RippleAdjustment[] =>
- computeTrackRippleAdjustments({
- trackId: beforeTrack.id,
- beforeElements: beforeTrack.elements,
- afterElements: afterTracksById.get(beforeTrack.id)?.elements ?? [],
- allAfterElementIds,
- }),
- );
-}
-
-function computeTrackRippleAdjustments({
- trackId,
- beforeElements,
- afterElements,
- allAfterElementIds,
-}: {
- trackId: string;
- beforeElements: TimelineElement[];
- afterElements: TimelineElement[];
- allAfterElementIds: Set<string>;
-}): RippleAdjustment[] {
- const beforeElementsById = buildElementSpanMap({ elements: beforeElements });
- const afterElementsById = buildElementSpanMap({ elements: afterElements });
- const { vacatedIntervals, joinedIntervals } = collectTrackIntervals({
- beforeElementsById,
- afterElementsById,
- allAfterElementIds,
- });
- const freedIntervals = subtractIntervalSets({
- sourceIntervals: vacatedIntervals,
- overlappingIntervals: joinedIntervals,
- });
-
- return buildAdjustments({ trackId, intervals: freedIntervals });
-}
-
-function buildElementSpanMap({
- elements,
-}: {
- elements: TimelineElement[];
-}): Map<string, ElementSpan> {
- return new Map(
- elements.map((element) => [
- element.id,
- {
- id: element.id,
- startTime: element.startTime,
- endTime: element.startTime + element.duration,
- },
- ]),
- );
-}
-
-function collectTrackIntervals({
- beforeElementsById,
- afterElementsById,
- allAfterElementIds,
-}: {
- beforeElementsById: Map<string, ElementSpan>;
- afterElementsById: Map<string, ElementSpan>;
- allAfterElementIds: Set<string>;
-}): {
- vacatedIntervals: Interval[];
- joinedIntervals: Interval[];
-} {
- const vacatedIntervals: Interval[] = [];
- const joinedIntervals: Interval[] = [];
-
- for (const beforeElement of beforeElementsById.values()) {
- const afterElement = afterElementsById.get(beforeElement.id);
- if (!afterElement) {
- const wasMovedToAnotherTrack = allAfterElementIds.has(beforeElement.id);
- if (!wasMovedToAnotherTrack) {
- pushInterval({
- intervals: vacatedIntervals,
- startTime: beforeElement.startTime,
- endTime: beforeElement.endTime,
- });
- }
- continue;
- }
-
- if (beforeElement.endTime > afterElement.endTime) {
- pushInterval({
- intervals: vacatedIntervals,
- startTime: afterElement.endTime,
- endTime: beforeElement.endTime,
- });
- }
- }
-
- for (const afterElement of afterElementsById.values()) {
- if (beforeElementsById.has(afterElement.id)) {
- continue;
- }
-
- pushInterval({
- intervals: joinedIntervals,
- startTime: afterElement.startTime,
- endTime: afterElement.endTime,
- });
- }
-
- return {
- vacatedIntervals: normalizeIntervals({ intervals: vacatedIntervals }),
- joinedIntervals: normalizeIntervals({ intervals: joinedIntervals }),
- };
-}
-
-function buildAdjustments({
- trackId,
- intervals,
-}: {
- trackId: string;
- intervals: Interval[];
-}): RippleAdjustment[] {
- return intervals.flatMap((interval): RippleAdjustment[] => {
- const shiftAmount = interval.endTime - interval.startTime;
- if (shiftAmount <= 0) {
- return [];
- }
-
- return [
- {
- trackId,
- afterTime: interval.endTime,
- shiftAmount,
- },
- ];
- });
-}
-
-function subtractIntervalSets({
- sourceIntervals,
- overlappingIntervals,
-}: {
- sourceIntervals: Interval[];
- overlappingIntervals: Interval[];
-}): Interval[] {
- const normalizedSourceIntervals = normalizeIntervals({
- intervals: sourceIntervals,
- });
- const normalizedOverlappingIntervals = normalizeIntervals({
- intervals: overlappingIntervals,
- });
-
- return normalizedSourceIntervals.flatMap((sourceInterval) =>
- subtractSingleInterval({
- sourceInterval,
- overlappingIntervals: normalizedOverlappingIntervals,
- }),
- );
-}
-
-function normalizeIntervals({
- intervals,
-}: {
- intervals: Interval[];
-}): Interval[] {
- const validIntervals: Interval[] = [];
- for (const interval of intervals) {
- pushInterval({
- intervals: validIntervals,
- startTime: interval.startTime,
- endTime: interval.endTime,
- });
- }
-
- const sortedIntervals = validIntervals.sort(
- (leftInterval, rightInterval) =>
- leftInterval.startTime - rightInterval.startTime,
- );
-
- if (sortedIntervals.length === 0) {
- return [];
- }
-
- const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
- for (const interval of sortedIntervals.slice(1)) {
- const previousInterval = mergedIntervals[mergedIntervals.length - 1];
- if (interval.startTime <= previousInterval.endTime) {
- previousInterval.endTime = Math.max(
- previousInterval.endTime,
- interval.endTime,
- );
- continue;
- }
-
- mergedIntervals.push({ ...interval });
- }
-
- return mergedIntervals;
-}
-
-function subtractSingleInterval({
- sourceInterval,
- overlappingIntervals,
-}: {
- sourceInterval: Interval;
- overlappingIntervals: Interval[];
-}): Interval[] {
- let remainingIntervals: Interval[] = [{ ...sourceInterval }];
-
- for (const overlappingInterval of overlappingIntervals) {
- remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
- if (
- overlappingInterval.endTime <= remainingInterval.startTime ||
- overlappingInterval.startTime >= remainingInterval.endTime
- ) {
- return [remainingInterval];
- }
-
- const nextIntervals: Interval[] = [];
- pushInterval({
- intervals: nextIntervals,
- startTime: remainingInterval.startTime,
- endTime: overlappingInterval.startTime,
- });
- pushInterval({
- intervals: nextIntervals,
- startTime: overlappingInterval.endTime,
- endTime: remainingInterval.endTime,
- });
- return nextIntervals;
- });
-
- if (remainingIntervals.length === 0) {
- return [];
- }
- }
-
- return remainingIntervals;
-}
-
-function pushInterval({
- intervals,
- startTime,
- endTime,
-}: { intervals: Interval[]; startTime: number; endTime: number }): void {
- if (endTime <= startTime) {
- return;
- }
-
- intervals.push({ startTime, endTime });
-}
diff --git a/apps/web/src/lib/ripple/index.ts b/apps/web/src/lib/ripple/index.ts
deleted file mode 100644
index 5a14de64..00000000
--- a/apps/web/src/lib/ripple/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export type { RippleAdjustment } from "./apply";
-export { applyRippleAdjustments } from "./apply";
-export { computeRippleAdjustments } from "./diff";
-export { rippleShiftElements } from "./shift";
diff --git a/apps/web/src/lib/ripple/shift.ts b/apps/web/src/lib/ripple/shift.ts
deleted file mode 100644
index 03ab4a67..00000000
--- a/apps/web/src/lib/ripple/shift.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { TimelineElement } from "@/lib/timeline/types";
-
-export function rippleShiftElements<TElement extends TimelineElement>({
- elements,
- afterTime,
- shiftAmount,
-}: {
- elements: TElement[];
- afterTime: number;
- shiftAmount: number;
-}): TElement[] {
- return elements.map((element) =>
- element.startTime >= afterTime
- ? ({ ...element, startTime: element.startTime - shiftAmount } as TElement)
- : element,
- );
-}
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts
deleted file mode 100644
index 91285275..00000000
--- a/apps/web/src/lib/scenes.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { TScene } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { calculateTotalDuration } from "@/lib/timeline";
-import { MAIN_TRACK_NAME } from "@/lib/timeline/placement/main-track";
-
-export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
- return scenes.find((scene) => scene.isMain) || null;
-}
-
-export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
- const hasMain = scenes.some((scene) => scene.isMain);
- if (!hasMain) {
- const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
- return [mainScene, ...scenes];
- }
- return scenes;
-}
-
-export function buildDefaultScene({
- name,
- isMain,
-}: {
- name: string;
- isMain: boolean;
-}): TScene {
- return {
- id: generateUUID(),
- name,
- isMain,
- tracks: {
- overlay: [],
- main: {
- id: generateUUID(),
- name: MAIN_TRACK_NAME,
- type: "video",
- elements: [],
- muted: false,
- hidden: false,
- },
- audio: [],
- },
- bookmarks: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-}
-
-export function canDeleteScene({ scene }: { scene: TScene }): {
- canDelete: boolean;
- reason?: string;
-} {
- if (scene.isMain) {
- return { canDelete: false, reason: "Cannot delete main scene" };
- }
- return { canDelete: true };
-}
-
-export function getFallbackSceneAfterDelete({
- scenes,
- deletedSceneId,
- currentSceneId,
-}: {
- scenes: TScene[];
- deletedSceneId: string;
- currentSceneId: string | null;
-}): TScene | null {
- if (currentSceneId !== deletedSceneId) {
- return scenes.find((s) => s.id === currentSceneId) || null;
- }
- return getMainScene({ scenes });
-}
-
-export function findCurrentScene({
- scenes,
- currentSceneId,
-}: {
- scenes: TScene[];
- currentSceneId: string;
-}): TScene | null {
- return (
- scenes.find((s) => s.id === currentSceneId) ||
- getMainScene({ scenes }) ||
- scenes[0] ||
- null
- );
-}
-
-export function getProjectDurationFromScenes({
- scenes,
-}: {
- scenes: TScene[];
-}): number {
- const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
- if (!mainScene?.tracks) {
- return 0;
- }
-
- return calculateTotalDuration({ tracks: mainScene.tracks });
-}
-
-export function updateSceneInArray({
- scenes,
- sceneId,
- updates,
-}: {
- scenes: TScene[];
- sceneId: string;
- updates: Partial<TScene>;
-}): TScene[] {
- return scenes.map((scene) =>
- scene.id === sceneId ? { ...scene, ...updates } : scene,
- );
-}
diff --git a/apps/web/src/lib/selection/attributes.ts b/apps/web/src/lib/selection/attributes.ts
deleted file mode 100644
index a1aee842..00000000
--- a/apps/web/src/lib/selection/attributes.ts
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 8e87aa40..00000000
--- a/apps/web/src/lib/selection/context.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import { createContext, useContext } from "react";
-
-interface SelectionContextValue {
- selectedIds: string[];
- anchorId: string | null;
- highlightedId: string | null;
- isBoxSelecting: boolean;
- isSelected: (id: string) => boolean;
- clearSelection: () => void;
- handleItemClick: ({
- event,
- id,
- }: {
- event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>;
- id: string;
- }) => void;
- handleItemMouseDown: ({
- event,
- id,
- }: {
- event: React.MouseEvent<HTMLDivElement>;
- id: string;
- }) => void;
- registerItem: (id: string, element: HTMLElement | null) => void;
-}
-
-export const SelectionContext = createContext<SelectionContextValue | null>(null);
-
-export function useSelectionContext() {
- const context = useContext(SelectionContext);
-
- if (!context) {
- throw new Error("useSelectionContext must be used within SelectableSurface");
- }
-
- return context;
-}
-
-export function useSelection() {
- const { selectedIds, anchorId, highlightedId, isSelected, clearSelection } =
- useSelectionContext();
-
- return {
- selectedIds,
- anchorId,
- highlightedId,
- isSelected,
- clearSelection,
- };
-}
diff --git a/apps/web/src/lib/selection/editor-selection.ts b/apps/web/src/lib/selection/editor-selection.ts
deleted file mode 100644
index e9563b97..00000000
--- a/apps/web/src/lib/selection/editor-selection.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export interface SelectedMaskPointSelection {
- trackId: string;
- elementId: string;
- maskId: string;
- pointIds: string[];
-}
-
-export interface EditorSelectionSnapshot {
- selectedElements: ElementRef[];
- selectedKeyframes: SelectedKeyframeRef[];
- keyframeSelectionAnchor: SelectedKeyframeRef | null;
- selectedMaskPoints: SelectedMaskPointSelection | null;
-}
-
-export interface EditorSelectionPatch {
- selectedElements?: ElementRef[];
- selectedKeyframes?: SelectedKeyframeRef[];
- keyframeSelectionAnchor?: SelectedKeyframeRef | null;
- selectedMaskPoints?: SelectedMaskPointSelection | null;
-}
-
-export type EditorSelectionKind = "mask-points" | "keyframes" | "elements";
diff --git a/apps/web/src/lib/selection/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts
deleted file mode 100644
index d8186238..00000000
--- a/apps/web/src/lib/selection/hit-testing.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-function isIntersecting({
- selectionRect,
- itemRect,
-}: {
- selectionRect: DOMRect;
- itemRect: DOMRect;
-}) {
- return !(
- itemRect.right < selectionRect.left ||
- itemRect.left > selectionRect.right ||
- itemRect.bottom < selectionRect.top ||
- itemRect.top > selectionRect.bottom
- );
-}
-
-function createSelectionRect({
- startPos,
- currentPos,
-}: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
-}) {
- return new DOMRect(
- Math.min(startPos.x, currentPos.x),
- Math.min(startPos.y, currentPos.y),
- Math.abs(currentPos.x - startPos.x),
- Math.abs(currentPos.y - startPos.y),
- );
-}
-
-export function resolveElementIntersections({
- startPos,
- currentPos,
- elements,
-}: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- elements: Map<string, HTMLElement>;
-}) {
- const selectionRect = createSelectionRect({ startPos, currentPos });
-
- return [...elements.entries()]
- .filter(([, element]) =>
- isIntersecting({
- selectionRect,
- itemRect: element.getBoundingClientRect(),
- }),
- )
- .map(([id]) => id);
-}
diff --git a/apps/web/src/lib/selection/hooks/use-box-select.ts b/apps/web/src/lib/selection/hooks/use-box-select.ts
deleted file mode 100644
index ab8464ec..00000000
--- a/apps/web/src/lib/selection/hooks/use-box-select.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import type {
- BoxSelectionSnapshot,
- ResolveIntersections,
-} from "@/lib/selection/types";
-
-interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
- isAdditive: boolean;
-}
-
-export function useBoxSelect<TId>({
- containerRef,
- resolveIntersections,
- selectedIds,
- anchorId,
- onSelectionChange,
- shouldStartSelection,
- getIsAdditiveSelection,
- isEnabled = true,
-}: {
- containerRef: React.RefObject<HTMLElement | null>;
- resolveIntersections: ResolveIntersections<TId>;
- selectedIds: TId[];
- anchorId: TId | null;
- onSelectionChange: (state: {
- intersectedIds: TId[];
- initialSelectedIds: TId[];
- initialAnchorId: TId | null;
- isAdditive: boolean;
- }) => void;
- shouldStartSelection?: (event: React.MouseEvent<Element>) => boolean;
- getIsAdditiveSelection?: (event: React.MouseEvent<Element>) => boolean;
- isEnabled?: boolean;
-}) {
- const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(
- null,
- );
- const justFinishedSelectingRef = useRef(false);
- const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
- const getIsAdditiveSelectionCheck =
- getIsAdditiveSelection ??
- ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
-
- const handleMouseDown = useCallback(
- (event: React.MouseEvent<Element>) => {
- const canStartSelection = shouldStartSelectionCheck(event);
- if (!isEnabled || event.button !== 0 || !canStartSelection) {
- return;
- }
-
- setSelectionBox({
- startPos: { x: event.clientX, y: event.clientY },
- currentPos: { x: event.clientX, y: event.clientY },
- isActive: false,
- isAdditive: getIsAdditiveSelectionCheck(event),
- initialSelectedIds: selectedIds,
- initialAnchorId: anchorId,
- });
- },
- [
- anchorId,
- getIsAdditiveSelectionCheck,
- isEnabled,
- selectedIds,
- shouldStartSelectionCheck,
- ],
- );
-
- const updateSelection = useCallback(
- ({
- startPos,
- currentPos,
- isAdditive,
- initialSelectedIds,
- initialAnchorId,
- }: SelectionBoxState<TId>) => {
- const intersectedIds = resolveIntersections({
- startPos,
- currentPos,
- });
- onSelectionChange({
- intersectedIds,
- initialSelectedIds,
- initialAnchorId,
- isAdditive,
- });
- },
- [onSelectionChange, resolveIntersections],
- );
-
- useEffect(() => {
- if (!selectionBox) {
- return;
- }
-
- const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
- const deltaX = Math.abs(clientX - selectionBox.startPos.x);
- const deltaY = Math.abs(clientY - selectionBox.startPos.y);
- const nextSelectionBox = {
- ...selectionBox,
- currentPos: { x: clientX, y: clientY },
- isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
- };
-
- setSelectionBox(nextSelectionBox);
-
- if (!nextSelectionBox.isActive) {
- return;
- }
-
- updateSelection(nextSelectionBox);
- };
-
- const handleMouseUp = () => {
- if (selectionBox.isActive) {
- justFinishedSelectingRef.current = true;
- requestAnimationFrame(() => {
- justFinishedSelectingRef.current = false;
- });
- }
-
- setSelectionBox(null);
- };
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [selectionBox, updateSelection]);
-
- useEffect(() => {
- if (!selectionBox) {
- return;
- }
-
- const previousBodyUserSelect = document.body.style.userSelect;
- const previousContainerUserSelect = containerRef.current?.style.userSelect ?? "";
-
- document.body.style.userSelect = "none";
- if (containerRef.current) {
- containerRef.current.style.userSelect = "none";
- }
-
- return () => {
- document.body.style.userSelect = previousBodyUserSelect;
- if (containerRef.current) {
- containerRef.current.style.userSelect = previousContainerUserSelect;
- }
- };
- }, [containerRef, selectionBox]);
-
- const shouldIgnoreClick = useCallback(() => {
- return justFinishedSelectingRef.current;
- }, []);
-
- return {
- selectionBox,
- handleMouseDown,
- isSelecting: selectionBox?.isActive ?? false,
- shouldIgnoreClick,
- };
-}
diff --git a/apps/web/src/lib/selection/hooks/use-selection-scope.ts b/apps/web/src/lib/selection/hooks/use-selection-scope.ts
deleted file mode 100644
index a7090c5d..00000000
--- a/apps/web/src/lib/selection/hooks/use-selection-scope.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useEffect, useRef } from "react";
-import { useSelectionContext } from "@/lib/selection/context";
-import { activateScope, type ScopeEntry } from "@/lib/selection/scope";
-
-export function useSelectionScope() {
- const { selectedIds, clearSelection } = useSelectionContext();
- const hasSelectionRef = useRef(selectedIds.length > 0);
- const clearSelectionRef = useRef(clearSelection);
- const hasSelection = selectedIds.length > 0;
-
- hasSelectionRef.current = hasSelection;
- clearSelectionRef.current = clearSelection;
-
- const entryRef = useRef<ScopeEntry>({
- hasSelection: () => hasSelectionRef.current,
- clear: () => {
- clearSelectionRef.current();
- },
- });
-
- useEffect(() => {
- if (!hasSelection) {
- return;
- }
-
- return activateScope({ entry: entryRef.current });
- }, [hasSelection]);
-}
diff --git a/apps/web/src/lib/selection/index.ts b/apps/web/src/lib/selection/index.ts
deleted file mode 100644
index b1ded820..00000000
--- a/apps/web/src/lib/selection/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-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
deleted file mode 100644
index 22c7710c..00000000
--- a/apps/web/src/lib/selection/scope.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export type ScopeEntry = {
- hasSelection: () => boolean;
- clear: () => void;
- clearActive?: () => 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.clearActive ?? activeScope.clear)();
- return true;
-}
diff --git a/apps/web/src/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx
deleted file mode 100644
index 03264753..00000000
--- a/apps/web/src/lib/selection/selectable-item.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-"use client";
-
-import { forwardRef, useCallback } from "react";
-import { useSelectionContext } from "@/lib/selection/context";
-import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/attributes";
-import type { SelectableItemProps } from "@/lib/selection/types";
-import { cn } from "@/utils/ui";
-
-function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
- if (typeof ref === "function") {
- ref(value);
- return;
- }
-
- if (ref) {
- ref.current = value;
- }
-}
-
-export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
- function SelectableItem(
- {
- id,
- children,
- className,
- onClick,
- onKeyDown,
- onMouseDown,
- onContextMenu,
- tabIndex,
- ...rest
- }: SelectableItemProps,
- forwardedRef,
- ) {
- const {
- highlightedId,
- isBoxSelecting,
- isSelected,
- handleItemClick,
- handleItemMouseDown,
- registerItem,
- } = useSelectionContext();
- const isItemSelected = isSelected(id);
- const isItemHighlighted = highlightedId === id;
- const stateClassName = cn(
- "relative",
- isBoxSelecting && "pointer-events-none",
- isItemSelected && "ring-1 ring-primary rounded-sm bg-primary/10",
- isItemHighlighted &&
- (isItemSelected
- ? "rounded-sm shadow-[0_0_0_1px_hsl(var(--primary))]"
- : "ring-1 ring-primary/60 rounded-sm bg-primary/5"),
- );
-
- const handleRef = useCallback(
- (element: HTMLDivElement | null) => {
- registerItem(id, element);
- setForwardedRef(forwardedRef, element);
- },
- [forwardedRef, id, registerItem],
- );
-
- return (
- <div
- ref={handleRef}
- className={cn(stateClassName, className)}
- {...{ [SELECTABLE_ITEM_ATTRIBUTE]: "true" }}
- role="option"
- aria-selected={isItemSelected}
- tabIndex={tabIndex ?? 0}
- onClick={(event) => {
- onClick?.(event);
- if (event.defaultPrevented) {
- return;
- }
-
- handleItemClick({ event, id });
- }}
- onMouseDown={(event) => {
- onMouseDown?.(event);
- if (event.defaultPrevented) {
- return;
- }
-
- handleItemMouseDown({ event, id });
- }}
- onKeyDown={(event) => {
- onKeyDown?.(event);
- if (event.defaultPrevented || event.target !== event.currentTarget) {
- return;
- }
-
- if (event.key !== "Enter" && event.key !== " ") {
- return;
- }
-
- event.preventDefault();
- handleItemClick({ event, id });
- }}
- onContextMenu={onContextMenu}
- {...rest}
- >
- {children}
- </div>
- );
- },
-);
diff --git a/apps/web/src/lib/selection/selectable-surface.tsx b/apps/web/src/lib/selection/selectable-surface.tsx
deleted file mode 100644
index 012bc486..00000000
--- a/apps/web/src/lib/selection/selectable-surface.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"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/attributes";
-import { resolveElementIntersections } from "@/lib/selection/hit-testing";
-import {
- applyBoxSelection,
- clearSelection,
- isSelected as getIsSelected,
- pruneSelection,
- replaceSelection,
- selectRange,
- toggleSelection,
-} from "@/lib/selection/state";
-import type {
- SelectableSurfaceProps,
- SelectionState,
-} from "@/lib/selection/types";
-import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
-import { cn } from "@/utils/ui";
-
-export function SelectableSurface({
- orderedIds,
- children,
- className,
- ariaLabel = "Selectable items",
- revealId = null,
- onRevealComplete,
- onSelectionChange,
-}: SelectableSurfaceProps) {
- const [selectionState, setSelectionState] = useState<SelectionState>(() =>
- clearSelection(),
- );
- const [highlightedId, setHighlightedId] = useState<string | null>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map());
-
- const registerItem = useCallback(
- (id: string, element: HTMLElement | null) => {
- if (element) {
- itemElementsRef.current.set(id, element);
- } else {
- itemElementsRef.current.delete(id);
- }
- },
- [],
- );
-
- const getSelectableElements = useCallback(() => {
- return itemElementsRef.current;
- }, []);
-
- const getItemElement = useCallback((id: string) => {
- return itemElementsRef.current.get(id) ?? null;
- }, []);
- const shouldStartSelection = useCallback(
- (event: React.MouseEvent<Element>) => {
- return !(
- event.target instanceof Element &&
- event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`)
- );
- },
- [],
- );
- const resolveIntersections = useCallback(
- ({
- startPos,
- currentPos,
- }: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- }) => {
- return resolveElementIntersections({
- startPos,
- currentPos,
- elements: getSelectableElements(),
- });
- },
- [getSelectableElements],
- );
-
- const clearSelectionState = useCallback(() => {
- setSelectionState(clearSelection());
- }, []);
-
- const selectedIdSet = useMemo(
- () => new Set(selectionState.selectedIds),
- [selectionState.selectedIds],
- );
-
- const isSelected = useCallback(
- (id: string) => selectedIdSet.has(id),
- [selectedIdSet],
- );
-
- const handleItemClick = useCallback(
- ({
- event,
- id,
- }: {
- event:
- | React.MouseEvent<HTMLDivElement>
- | React.KeyboardEvent<HTMLDivElement>;
- id: string;
- }) => {
- setSelectionState((state) => {
- const isToggleSelection = event.ctrlKey || event.metaKey;
-
- if (event.shiftKey) {
- return selectRange({
- state,
- orderedIds,
- targetId: id,
- isAdditive: isToggleSelection,
- });
- }
-
- if (isToggleSelection) {
- return toggleSelection({
- state,
- id,
- });
- }
-
- return replaceSelection({
- ids: [id],
- anchorId: id,
- });
- });
- },
- [orderedIds],
- );
-
- const selectUnselectedItem = useCallback((id: string) => {
- setSelectionState((state) => {
- if (getIsSelected({ state, id })) {
- return state;
- }
-
- return replaceSelection({ ids: [id], anchorId: id });
- });
- }, []);
-
- const handleItemMouseDown = useCallback(
- ({
- event,
- id,
- }: {
- event: React.MouseEvent<HTMLDivElement>;
- id: string;
- }) => {
- if (event.button !== 2) {
- return;
- }
-
- selectUnselectedItem(id);
- },
- [selectUnselectedItem],
- );
-
- const handleBoxSelectionChange = useCallback(
- (change: Parameters<typeof applyBoxSelection>[0]) => {
- setSelectionState(applyBoxSelection(change));
- },
- [],
- );
-
- const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
- containerRef,
- resolveIntersections,
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- onSelectionChange: handleBoxSelectionChange,
- shouldStartSelection,
- });
-
- const handleBackgroundClick = useCallback(
- (event: React.MouseEvent<HTMLDivElement>) => {
- const clickedSelectableItem =
- event.target instanceof Element &&
- event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`);
- const isIgnoringClick = shouldIgnoreClick();
- if (clickedSelectableItem || isIgnoringClick) {
- return;
- }
-
- clearSelectionState();
- },
- [clearSelectionState, shouldIgnoreClick],
- );
-
- const handleBackgroundKeyDown = useCallback(
- (event: React.KeyboardEvent<HTMLDivElement>) => {
- if (event.target !== event.currentTarget) {
- return;
- }
-
- if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") {
- return;
- }
-
- event.preventDefault();
- clearSelectionState();
- },
- [clearSelectionState],
- );
-
- useEffect(() => {
- setSelectionState((state) =>
- pruneSelection({
- state,
- orderedIds,
- }),
- );
- }, [orderedIds]);
-
- useEffect(() => {
- onSelectionChange?.(selectionState);
- }, [onSelectionChange, selectionState]);
-
- useEffect(() => {
- if (!revealId) {
- return;
- }
-
- setHighlightedId(revealId);
- getItemElement(revealId)?.scrollIntoView({ block: "center" });
-
- const timer = setTimeout(() => {
- setHighlightedId(null);
- onRevealComplete?.();
- }, 1500);
-
- return () => clearTimeout(timer);
- }, [getItemElement, onRevealComplete, revealId]);
-
- const isBoxSelecting = selectionBox?.isActive ?? false;
-
- const contextValue = useMemo(() => {
- return {
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- highlightedId,
- isBoxSelecting,
- isSelected,
- clearSelection: clearSelectionState,
- handleItemClick,
- handleItemMouseDown,
- registerItem,
- };
- }, [
- clearSelectionState,
- handleItemClick,
- handleItemMouseDown,
- highlightedId,
- isBoxSelecting,
- isSelected,
- registerItem,
- selectionState.anchorId,
- selectionState.selectedIds,
- ]);
-
- return (
- <SelectionContext.Provider value={contextValue}>
- <div
- ref={containerRef}
- className={cn("relative min-h-full", className)}
- role="listbox"
- aria-label={ariaLabel}
- aria-multiselectable="true"
- tabIndex={0}
- onMouseDown={handleMouseDown}
- onClick={handleBackgroundClick}
- onKeyDown={handleBackgroundKeyDown}
- >
- {children}
- <SelectionBox
- startPos={selectionBox?.startPos ?? null}
- currentPos={selectionBox?.currentPos ?? null}
- containerRef={containerRef}
- isActive={selectionBox?.isActive ?? false}
- />
- </div>
- </SelectionContext.Provider>
- );
-}
diff --git a/apps/web/src/lib/selection/selection-box.tsx b/apps/web/src/lib/selection/selection-box.tsx
deleted file mode 100644
index e30f574d..00000000
--- a/apps/web/src/lib/selection/selection-box.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-
-interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
-}
-
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
-
- return (
- <div
- style={selectionBoxStyle}
- className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
- />
- );
-}
diff --git a/apps/web/src/lib/selection/state.ts b/apps/web/src/lib/selection/state.ts
deleted file mode 100644
index b0ba3089..00000000
--- a/apps/web/src/lib/selection/state.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-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
deleted file mode 100644
index 31520788..00000000
--- a/apps/web/src/lib/selection/types.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-export interface SelectionState {
- selectedIds: string[];
- anchorId: string | null;
-}
-
-export interface BoxSelectionSnapshot<TId = string> {
- initialSelectedIds: TId[];
- initialAnchorId: TId | null;
-}
-
-export interface BoxSelectionChange<TId = string>
- extends BoxSelectionSnapshot<TId> {
- intersectedIds: TId[];
- isAdditive: boolean;
-}
-
-export type ResolveIntersections<TId = string> = ({
- startPos,
- currentPos,
-}: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
-}) => TId[];
-
-export interface SelectableSurfaceProps {
- orderedIds: string[];
- children: React.ReactNode;
- className?: string;
- ariaLabel?: string;
- revealId?: string | null;
- onRevealComplete?: () => void;
- onSelectionChange?: (state: SelectionState) => void;
-}
-
-export interface SelectableItemProps
- extends React.HTMLAttributes<HTMLDivElement> {
- id: string;
- children: React.ReactNode;
-}
diff --git a/apps/web/src/lib/site/brand.ts b/apps/web/src/lib/site/brand.ts
deleted file mode 100644
index 2701608c..00000000
--- a/apps/web/src/lib/site/brand.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const SITE_URL = "https://opencut.app";
-
-export const SITE_INFO = {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- url: SITE_URL,
- openGraphImage: "/open-graph/default.jpg",
- twitterImage: "/open-graph/default.jpg",
- favicon: "/favicon.ico",
-};
-
-export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
diff --git a/apps/web/src/lib/site/external-tools.ts b/apps/web/src/lib/site/external-tools.ts
deleted file mode 100644
index 610ea158..00000000
--- a/apps/web/src/lib/site/external-tools.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
-
-export type ExternalTool = {
- name: string;
- description: string;
- url: string;
- icon: React.ElementType;
-};
-
-export const EXTERNAL_TOOLS: ExternalTool[] = [
- {
- name: "Marble",
- description:
- "Modern headless CMS for content management and the blog for OpenCut",
- url: "https://marblecms.com?utm_source=opencut",
- icon: OcMarbleIcon,
- },
- {
- name: "Databuddy",
- description: "GDPR compliant analytics and user insights for OpenCut",
- url: "https://databuddy.cc?utm_source=opencut",
- icon: OcDataBuddyIcon,
- },
-];
diff --git a/apps/web/src/lib/site/social.ts b/apps/web/src/lib/site/social.ts
deleted file mode 100644
index 571e45d5..00000000
--- a/apps/web/src/lib/site/social.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const SOCIAL_LINKS = {
- x: "https://x.com/opencutapp",
- github: "https://github.com/OpenCut-app/OpenCut",
- discord: "https://discord.com/invite/Mu3acKZvCp",
-};
diff --git a/apps/web/src/lib/site/sponsors.ts b/apps/web/src/lib/site/sponsors.ts
deleted file mode 100644
index 2bd201b7..00000000
--- a/apps/web/src/lib/site/sponsors.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export type Sponsor = {
- name: string;
- url: string;
- logo: string;
- description: string;
- invertOnDark?: boolean;
-};
-
-export const SPONSORS: Sponsor[] = [
- {
- name: "Fal.ai",
- url: "https://fal.ai?utm_source=opencut",
- logo: "/logos/others/fal.svg",
- description: "Generative image, video, and audio models all in one place.",
- invertOnDark: true,
- },
- {
- name: "Vercel",
- url: "https://vercel.com?utm_source=opencut",
- logo: "/logos/others/vercel.svg",
- description: "Platform where we deploy and host OpenCut.",
- invertOnDark: true,
- },
-];
diff --git a/apps/web/src/lib/sounds/types.ts b/apps/web/src/lib/sounds/types.ts
deleted file mode 100644
index f7526846..00000000
--- a/apps/web/src/lib/sounds/types.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index 26458a7c..00000000
--- a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { buildStickerId, parseStickerId } from "../sticker-id";
-
-describe("sticker-id strict mode", () => {
- test("parses provider-prefixed IDs", () => {
- expect(parseStickerId({ stickerId: "flags:US" })).toEqual({
- providerId: "flags",
- providerValue: "US",
- });
- expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({
- providerId: "shapes",
- providerValue: "circle",
- });
- });
-
- test("throws for IDs without provider prefix", () => {
- expect(() => parseStickerId({ stickerId: "home" })).toThrow();
- });
-
- test("throws for malformed IDs", () => {
- expect(() => parseStickerId({ stickerId: "" })).toThrow();
- expect(() => parseStickerId({ stickerId: "flags:" })).toThrow();
- expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow();
- });
-
- test("builds sticker IDs unchanged", () => {
- expect(
- buildStickerId({
- providerId: "flags",
- providerValue: "US",
- }),
- ).toBe("flags:US");
- });
-});
diff --git a/apps/web/src/lib/stickers/categories.ts b/apps/web/src/lib/stickers/categories.ts
deleted file mode 100644
index e8a8b71b..00000000
--- a/apps/web/src/lib/stickers/categories.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export const STICKER_CATEGORIES = {
- all: "All",
- // v0.4.0
- // logos: "Logos",
- flags: "Flags",
- shapes: "Shapes",
-};
diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts
deleted file mode 100644
index 2d71d097..00000000
--- a/apps/web/src/lib/stickers/index.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import {
- STICKER_CATEGORIES,
-} from "@/lib/stickers/categories";
-import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
-import type { StickerCategory } from "@/lib/stickers/types";
-import { stickersRegistry } from "./registry";
-import { resolveStickerId } from "./resolver";
-import { registerDefaultStickerProviders } from "./providers";
-import { parseStickerId } from "./sticker-id";
-import type {
- StickerBrowseResult,
- StickerItem,
- StickerProvider,
- StickerSearchResult,
-} from "./types";
-
-const DEFAULT_BROWSE_LIMIT = 12;
-const DEFAULT_SEARCH_LIMIT = 100;
-
-function mergeSearchResults({
- results,
-}: {
- results: StickerSearchResult[];
-}): StickerSearchResult {
- const deduplicatedItems = new Map<
- string,
- StickerSearchResult["items"][number]
- >();
- let total = 0;
- let hasMore = false;
-
- for (const result of results) {
- total += result.total;
- hasMore = hasMore || result.hasMore;
- for (const item of result.items) {
- if (!deduplicatedItems.has(item.id)) {
- deduplicatedItems.set(item.id, item);
- }
- }
- }
-
- return {
- items: Array.from(deduplicatedItems.values()),
- total,
- hasMore,
- };
-}
-
-function getProviderByCategory({
- category,
-}: {
- category: StickerCategory;
-}): StickerProvider | null {
- if (category === "all") {
- return null;
- }
-
- try {
- return stickersRegistry.get(category);
- } catch {
- return null;
- }
-}
-
-function getEmptyBrowseResult(): StickerBrowseResult {
- return {
- sections: [],
- };
-}
-
-function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
- const stickerIdParts = stickerId.split(":");
- if (stickerIdParts.length <= 1) {
- return stickerId;
- }
- return (
- stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
- stickerId
- );
-}
-
-function toRecentStickerItem({
- stickerId,
-}: {
- stickerId: string;
-}): StickerItem | null {
- try {
- const { providerId } = parseStickerId({ stickerId });
- return {
- id: stickerId,
- provider: providerId,
- name: getStickerNameFromId({ stickerId }),
- previewUrl: resolveStickerId({
- stickerId,
- options: { width: 64, height: 64 },
- }),
- metadata: {},
- };
- } catch {
- return null;
- }
-}
-
-export async function searchStickers({
- query,
- category,
- limit = DEFAULT_SEARCH_LIMIT,
-}: {
- query: string;
- category: StickerCategory;
- limit?: number;
-}): Promise<StickerSearchResult> {
- registerDefaultStickerProviders({});
-
- const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
- if (effectiveCategory !== "all") {
- const provider = getProviderByCategory({ category: effectiveCategory });
- if (!provider) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
- }
- return provider.search({
- query,
- options: { limit },
- });
- }
-
- const providers = stickersRegistry.getAll();
- if (providers.length === 0) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
- }
-
- const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
- const settledResults = await Promise.allSettled(
- providers.map((provider) =>
- provider.search({
- query,
- options: { limit: perProviderLimit },
- }),
- ),
- );
-
- const fulfilledResults = settledResults
- .filter(
- (result): result is PromiseFulfilledResult<StickerSearchResult> =>
- result.status === "fulfilled",
- )
- .map((result) => result.value);
-
- return mergeSearchResults({
- results: fulfilledResults,
- });
-}
-
-export async function searchAll({
- query,
- limit = DEFAULT_SEARCH_LIMIT,
-}: {
- query: string;
- limit?: number;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const providers = stickersRegistry.getAll();
- if (providers.length === 0) {
- return { sections: [] };
- }
-
- const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
- const settledResults = await Promise.allSettled(
- providers.map(async (provider) => {
- const result = await provider.search({
- query,
- options: { limit: perProviderLimit },
- });
- return { provider, result };
- }),
- );
-
- const sections: StickerBrowseResult["sections"] = [];
- for (const settled of settledResults) {
- if (settled.status !== "fulfilled") {
- continue;
- }
- const { provider, result } = settled.value;
- if (result.items.length === 0) {
- continue;
- }
- const category = provider.id as StickerCategory;
- sections.push({
- id: category,
- title: STICKER_CATEGORIES[category] ?? provider.id,
- items: result.items,
- hasMore: result.hasMore,
- layout: "grid",
- });
- }
-
- return { sections };
-}
-
-export async function browseCategory({
- category,
-}: {
- category: StickerCategory;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
- if (effectiveCategory === "all") {
- return getEmptyBrowseResult();
- }
-
- const provider = getProviderByCategory({ category: effectiveCategory });
- if (!provider) {
- return getEmptyBrowseResult();
- }
-
- return provider.browse({ options: {} });
-}
-
-export async function browseAll({
- recentStickers,
- limit = DEFAULT_BROWSE_LIMIT,
-}: {
- recentStickers: string[];
- limit?: number;
-}): Promise<StickerBrowseResult> {
- registerDefaultStickerProviders({});
-
- const sections: StickerBrowseResult["sections"] = [];
- const recentItems = recentStickers
- .map((stickerId) => toRecentStickerItem({ stickerId }))
- .filter((item): item is StickerItem => item !== null);
-
- if (recentItems.length > 0) {
- sections.push({
- id: "recent",
- title: "Recently used",
- items: recentItems.slice(0, limit),
- hasMore: recentItems.length > limit,
- layout: "row",
- });
- }
-
- const settledResults = await Promise.allSettled(
- stickersRegistry.getAll().map(async (provider) => {
- const browseResult = await provider.browse({
- options: { limit },
- });
- const firstSection = browseResult.sections[0];
-
- if (!firstSection || firstSection.items.length === 0) {
- return null;
- }
-
- const category = provider.id as StickerCategory;
- return {
- ...firstSection,
- id: category,
- title: STICKER_CATEGORIES[category] ?? firstSection.title,
- layout: "row" as const,
- action: {
- type: "see-all" as const,
- category,
- sectionId: firstSection.id,
- },
- };
- }),
- );
-
- for (const result of settledResults) {
- if (result.status === "fulfilled" && result.value) {
- sections.push(result.value);
- }
- }
-
- return { sections };
-}
-
-export async function resolveStickerIntrinsicSize({
- stickerId,
-}: {
- stickerId: string;
-}): Promise<{ width: number; height: number }> {
- const url = resolveStickerId({ stickerId });
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = () =>
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
- img.onerror = () =>
- resolve({
- width: STICKER_INTRINSIC_SIZE_FALLBACK,
- height: STICKER_INTRINSIC_SIZE_FALLBACK,
- });
- img.src = url;
- });
-}
-
-export { resolveStickerId };
-export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
-export type {
- StickerBrowseResult,
- StickerBrowseSection,
- StickerCategory,
- StickerItem,
- StickerProvider,
- StickerResolveOptions,
- StickerSearchResult,
-} from "./types";
diff --git a/apps/web/src/lib/stickers/intrinsic-size.ts b/apps/web/src/lib/stickers/intrinsic-size.ts
deleted file mode 100644
index 43436237..00000000
--- a/apps/web/src/lib/stickers/intrinsic-size.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const STICKER_INTRINSIC_SIZE_FALLBACK = 200;
diff --git a/apps/web/src/lib/stickers/providers/countries-data.ts b/apps/web/src/lib/stickers/providers/countries-data.ts
deleted file mode 100644
index 05551699..00000000
--- a/apps/web/src/lib/stickers/providers/countries-data.ts
+++ /dev/null
@@ -1,1819 +0,0 @@
-export const REGIONS = [
- { id: "Western Europe", aliases: ["west europe"] },
- { id: "Eastern Europe", aliases: ["east europe"] },
- { id: "Northern Europe", aliases: ["north europe"] },
- { id: "Southern Europe", aliases: ["south europe"] },
- { id: "South Asia", aliases: ["southern asia"] },
- { id: "Southeast Asia", aliases: ["south east asia"] },
- { id: "East Asia", aliases: ["eastern asia", "far east"] },
- { id: "Central Asia", aliases: [] },
- { id: "Middle East", aliases: ["west asia"] },
- { id: "North Africa", aliases: ["northern africa"] },
- { id: "Sub-Saharan Africa", aliases: ["subsaharan africa"] },
- { id: "North America", aliases: ["northern america"] },
- { id: "South America", aliases: ["southern america", "latin america"] },
- { id: "Central America", aliases: ["central am"] },
- { id: "Caribbean", aliases: ["caribbean islands"] },
- { id: "Oceania", aliases: ["pacific", "pacific islands"] },
- { id: "Antarctica", aliases: [] },
- { id: "Atlantic Ocean", aliases: ["atlantic"] },
- { id: "North Atlantic", aliases: [] },
-] as const;
-
-export type RegionId = (typeof REGIONS)[number]["id"];
-
-export const REGION_GROUPS: Partial<Record<string, RegionId[]>> = {
- europe: ["Western Europe", "Eastern Europe", "Northern Europe", "Southern Europe"],
- asia: ["South Asia", "Southeast Asia", "East Asia", "Central Asia"],
- africa: ["Sub-Saharan Africa", "North Africa"],
- america: ["North America", "South America", "Central America", "Caribbean"],
-};
-
-export interface CountryRecord {
- name: string;
- code: string;
- languages?: string[];
- flag_colors?: string[];
- region?: RegionId;
-}
-
-export const COUNTRIES: CountryRecord[] = [
- {
- "name": "Andorra",
- "code": "AD",
- "languages": ["catalan"],
- "flag_colors": ["blue", "yellow", "red"],
- "region": "Western Europe"
- },
- {
- "name": "United Arab Emirates",
- "code": "AE",
- "languages": ["arabic"],
- "flag_colors": ["red", "green", "white", "black"],
- "region": "Middle East"
- },
- {
- "name": "Afghanistan",
- "code": "AF",
- "languages": ["dari", "pashto"],
- "flag_colors": ["black", "red", "green"],
- "region": "South Asia"
- },
- {
- "name": "Antigua and Barbuda",
- "code": "AG",
- "languages": ["english"],
- "flag_colors": ["red", "black", "blue", "white", "yellow"],
- "region": "Caribbean"
- },
- {
- "name": "Anguilla",
- "code": "AI",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "orange"],
- "region": "Caribbean"
- },
- {
- "name": "Albania",
- "code": "AL",
- "languages": ["albanian"],
- "flag_colors": ["red", "black"],
- "region": "Southern Europe"
- },
- {
- "name": "Armenia",
- "code": "AM",
- "languages": ["armenian"],
- "flag_colors": ["red", "blue", "orange"],
- "region": "Middle East"
- },
- {
- "name": "Angola",
- "code": "AO",
- "languages": ["portuguese"],
- "flag_colors": ["red", "black", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Antarctica",
- "code": "AQ",
- "languages": [],
- "flag_colors": [],
- "region": "Antarctica"
- },
- {
- "name": "Argentina",
- "code": "AR",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white", "yellow"],
- "region": "South America"
- },
- {
- "name": "American Samoa",
- "code": "AS",
- "languages": ["english", "samoan"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Oceania"
- },
- {
- "name": "Austria",
- "code": "AT",
- "languages": ["german"],
- "flag_colors": ["red", "white"],
- "region": "Western Europe"
- },
- {
- "name": "Australia",
- "code": "AU",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Oceania"
- },
- {
- "name": "Aruba",
- "code": "AW",
- "languages": ["dutch", "papiamento"],
- "flag_colors": ["blue", "yellow", "red", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Aland Islands",
- "code": "AX",
- "languages": ["swedish"],
- "flag_colors": ["blue", "yellow", "red"],
- "region": "Northern Europe"
- },
- {
- "name": "Azerbaijan",
- "code": "AZ",
- "languages": ["azerbaijani"],
- "flag_colors": ["blue", "red", "green", "white"],
- "region": "Middle East"
- },
- {
- "name": "Bosnia and Herzegovina",
- "code": "BA",
- "languages": ["bosnian", "croatian", "serbian"],
- "flag_colors": ["blue", "yellow", "white"],
- "region": "Southern Europe"
- },
- {
- "name": "Barbados",
- "code": "BB",
- "languages": ["english"],
- "flag_colors": ["blue", "yellow", "black"],
- "region": "Caribbean"
- },
- {
- "name": "Bangladesh",
- "code": "BD",
- "languages": ["bengali"],
- "flag_colors": ["green", "red"],
- "region": "South Asia"
- },
- {
- "name": "Belgium",
- "code": "BE",
- "languages": ["french", "dutch", "german"],
- "flag_colors": ["black", "yellow", "red"],
- "region": "Western Europe"
- },
- {
- "name": "Burkina Faso",
- "code": "BF",
- "languages": ["french"],
- "flag_colors": ["red", "green", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Bulgaria",
- "code": "BG",
- "languages": ["bulgarian"],
- "flag_colors": ["white", "green", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Bahrain",
- "code": "BH",
- "languages": ["arabic"],
- "flag_colors": ["red", "white"],
- "region": "Middle East"
- },
- {
- "name": "Burundi",
- "code": "BI",
- "languages": ["kirundi", "french", "english"],
- "flag_colors": ["red", "green", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Benin",
- "code": "BJ",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Saint Barthelemy",
- "code": "BL",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Bermuda",
- "code": "BM",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red"],
- "region": "North Atlantic"
- },
- {
- "name": "Brunei",
- "code": "BN",
- "languages": ["malay"],
- "flag_colors": ["yellow", "white", "black", "red"],
- "region": "Southeast Asia"
- },
- {
- "name": "Bolivia",
- "code": "BO",
- "languages": ["spanish", "quechua", "aymara"],
- "flag_colors": ["red", "yellow", "green"],
- "region": "South America"
- },
- {
- "name": "Caribbean Netherlands",
- "code": "BQ",
- "languages": ["dutch", "papiamento", "english"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Brazil",
- "code": "BR",
- "languages": ["portuguese"],
- "flag_colors": ["green", "yellow", "blue", "white"],
- "region": "South America"
- },
- {
- "name": "Bahamas",
- "code": "BS",
- "languages": ["english"],
- "flag_colors": ["blue", "yellow", "black"],
- "region": "Caribbean"
- },
- {
- "name": "Bhutan",
- "code": "BT",
- "languages": ["dzongkha"],
- "flag_colors": ["yellow", "orange", "white"],
- "region": "South Asia"
- },
- {
- "name": "Bouvet Island",
- "code": "BV",
- "languages": [],
- "flag_colors": ["red", "white", "blue"],
- "region": "Antarctica"
- },
- {
- "name": "Botswana",
- "code": "BW",
- "languages": ["english", "tswana"],
- "flag_colors": ["blue", "black", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Belarus",
- "code": "BY",
- "languages": ["belarusian", "russian"],
- "flag_colors": ["red", "green", "white"],
- "region": "Eastern Europe"
- },
- {
- "name": "Belize",
- "code": "BZ",
- "languages": ["english"],
- "flag_colors": ["blue", "red", "white"],
- "region": "Central America"
- },
- {
- "name": "Canada",
- "code": "CA",
- "languages": ["english", "french"],
- "flag_colors": ["red", "white"],
- "region": "North America"
- },
- {
- "name": "Cocos (Keeling) Islands",
- "code": "CC",
- "languages": ["english"],
- "flag_colors": ["green", "red", "yellow", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Democratic Republic of the Congo",
- "code": "CD",
- "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"],
- "flag_colors": ["blue", "red", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Central African Republic",
- "code": "CF",
- "languages": ["french", "sango"],
- "flag_colors": ["blue", "white", "green", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Republic of the Congo",
- "code": "CG",
- "languages": ["french", "lingala", "kituba"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Switzerland",
- "code": "CH",
- "languages": ["german", "french", "italian"],
- "flag_colors": ["red", "white"],
- "region": "Western Europe"
- },
- {
- "name": "Cote d'Ivoire",
- "code": "CI",
- "languages": ["french"],
- "flag_colors": ["orange", "white", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Cook Islands",
- "code": "CK",
- "languages": ["english", "cook islands maori"],
- "flag_colors": ["blue", "white"],
- "region": "Oceania"
- },
- {
- "name": "Chile",
- "code": "CL",
- "languages": ["spanish"],
- "flag_colors": ["white", "red", "blue"],
- "region": "South America"
- },
- {
- "name": "Cameroon",
- "code": "CM",
- "languages": ["english", "french"],
- "flag_colors": ["green", "red", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "China",
- "code": "CN",
- "languages": ["mandarin"],
- "flag_colors": ["red", "yellow"],
- "region": "East Asia"
- },
- {
- "name": "Colombia",
- "code": "CO",
- "languages": ["spanish"],
- "flag_colors": ["yellow", "blue", "red"],
- "region": "South America"
- },
- {
- "name": "Costa Rica",
- "code": "CR",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Central America"
- },
- {
- "name": "Cuba",
- "code": "CU",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Cape Verde",
- "code": "CV",
- "languages": ["portuguese"],
- "flag_colors": ["blue", "white", "red", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Curacao",
- "code": "CW",
- "languages": ["dutch", "papiamento", "english"],
- "flag_colors": ["blue", "yellow", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Christmas Island",
- "code": "CX",
- "languages": ["english"],
- "flag_colors": ["green", "blue", "yellow", "white"],
- "region": "Oceania"
- },
- {
- "name": "Cyprus",
- "code": "CY",
- "languages": ["greek", "turkish"],
- "flag_colors": ["white", "orange", "green"],
- "region": "Middle East"
- },
- {
- "name": "Czechia",
- "code": "CZ",
- "languages": ["czech"],
- "flag_colors": ["white", "red", "blue"],
- "region": "Eastern Europe"
- },
- {
- "name": "Germany",
- "code": "DE",
- "languages": ["german"],
- "flag_colors": ["black", "red", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "Djibouti",
- "code": "DJ",
- "languages": ["arabic", "french", "afar", "somali"],
- "flag_colors": ["blue", "green", "white", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Denmark",
- "code": "DK",
- "languages": ["danish"],
- "flag_colors": ["red", "white"],
- "region": "Northern Europe"
- },
- {
- "name": "Dominica",
- "code": "DM",
- "languages": ["english"],
- "flag_colors": ["green", "yellow", "black", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Dominican Republic",
- "code": "DO",
- "languages": ["spanish"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Caribbean"
- },
- {
- "name": "Algeria",
- "code": "DZ",
- "languages": ["arabic", "tamazight"],
- "flag_colors": ["green", "white", "red"],
- "region": "North Africa"
- },
- {
- "name": "Ecuador",
- "code": "EC",
- "languages": ["spanish"],
- "flag_colors": ["yellow", "blue", "red"],
- "region": "South America"
- },
- {
- "name": "Estonia",
- "code": "EE",
- "languages": ["estonian"],
- "flag_colors": ["blue", "black", "white"],
- "region": "Northern Europe"
- },
- {
- "name": "Egypt",
- "code": "EG",
- "languages": ["arabic"],
- "flag_colors": ["red", "white", "black", "yellow"],
- "region": "North Africa"
- },
- {
- "name": "Western Sahara",
- "code": "EH",
- "languages": ["arabic", "spanish"],
- "flag_colors": ["black", "white", "green", "red"],
- "region": "North Africa"
- },
- {
- "name": "Eritrea",
- "code": "ER",
- "languages": ["tigrinya", "arabic", "english"],
- "flag_colors": ["red", "green", "blue", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Spain",
- "code": "ES",
- "languages": ["spanish"],
- "flag_colors": ["red", "yellow"],
- "region": "Southern Europe"
- },
- {
- "name": "Ethiopia",
- "code": "ET",
- "languages": ["amharic", "oromo"],
- "flag_colors": ["green", "yellow", "red", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "European Union",
- "code": "EU",
- "languages": ["english", "french", "german"],
- "flag_colors": ["blue", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "Finland",
- "code": "FI",
- "languages": ["finnish", "swedish"],
- "flag_colors": ["white", "blue"],
- "region": "Northern Europe"
- },
- {
- "name": "Fiji",
- "code": "FJ",
- "languages": ["english", "fijian", "hindi"],
- "flag_colors": ["blue", "white", "red", "yellow"],
- "region": "Oceania"
- },
- {
- "name": "Falkland Islands",
- "code": "FK",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red", "yellow"],
- "region": "South America"
- },
- {
- "name": "Micronesia",
- "code": "FM",
- "languages": ["english"],
- "flag_colors": ["blue", "white"],
- "region": "Oceania"
- },
- {
- "name": "Faroe Islands",
- "code": "FO",
- "languages": ["faroese", "danish"],
- "flag_colors": ["white", "red", "blue"],
- "region": "Northern Europe"
- },
- {
- "name": "France",
- "code": "FR",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Western Europe"
- },
- {
- "name": "Gabon",
- "code": "GA",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "England",
- "code": "GB-ENG",
- "languages": ["english"],
- "flag_colors": ["red", "white"],
- "region": "Western Europe"
- },
- {
- "name": "Northern Ireland",
- "code": "GB-NIR",
- "languages": ["english", "irish"],
- "flag_colors": ["white", "red"],
- "region": "Western Europe"
- },
- {
- "name": "Scotland",
- "code": "GB-SCT",
- "languages": ["english", "scottish gaelic"],
- "flag_colors": ["blue", "white"],
- "region": "Western Europe"
- },
- {
- "name": "Wales",
- "code": "GB-WLS",
- "languages": ["english", "welsh"],
- "flag_colors": ["green", "white", "red"],
- "region": "Western Europe"
- },
- {
- "name": "United Kingdom",
- "code": "GB",
- "languages": ["english"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Western Europe"
- },
- {
- "name": "Grenada",
- "code": "GD",
- "languages": ["english"],
- "flag_colors": ["red", "yellow", "green", "black"],
- "region": "Caribbean"
- },
- {
- "name": "Georgia",
- "code": "GE",
- "languages": ["georgian"],
- "flag_colors": ["white", "red"],
- "region": "Middle East"
- },
- {
- "name": "French Guiana",
- "code": "GF",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "South America"
- },
- {
- "name": "Guernsey",
- "code": "GG",
- "languages": ["english", "french"],
- "flag_colors": ["white", "red", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "Ghana",
- "code": "GH",
- "languages": ["english"],
- "flag_colors": ["red", "yellow", "green", "black"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Gibraltar",
- "code": "GI",
- "languages": ["english"],
- "flag_colors": ["red", "white", "yellow"],
- "region": "Southern Europe"
- },
- {
- "name": "Greenland",
- "code": "GL",
- "languages": ["kalaallisut", "danish"],
- "flag_colors": ["red", "white"],
- "region": "North America"
- },
- {
- "name": "Gambia",
- "code": "GM",
- "languages": ["english"],
- "flag_colors": ["red", "blue", "green", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Guinea",
- "code": "GN",
- "languages": ["french"],
- "flag_colors": ["red", "yellow", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Guadeloupe",
- "code": "GP",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Equatorial Guinea",
- "code": "GQ",
- "languages": ["spanish", "french", "portuguese"],
- "flag_colors": ["green", "white", "red", "blue", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Greece",
- "code": "GR",
- "languages": ["greek"],
- "flag_colors": ["blue", "white"],
- "region": "Southern Europe"
- },
- {
- "name": "South Georgia and the South Sandwich Islands",
- "code": "GS",
- "languages": [],
- "flag_colors": ["blue", "white", "yellow"],
- "region": "Atlantic Ocean"
- },
- {
- "name": "Guatemala",
- "code": "GT",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white"],
- "region": "Central America"
- },
- {
- "name": "Guam",
- "code": "GU",
- "languages": ["english", "chamorro"],
- "flag_colors": ["blue", "red"],
- "region": "Oceania"
- },
- {
- "name": "Guinea-Bissau",
- "code": "GW",
- "languages": ["portuguese"],
- "flag_colors": ["red", "yellow", "green", "black"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Guyana",
- "code": "GY",
- "languages": ["english"],
- "flag_colors": ["green", "yellow", "red", "black", "white"],
- "region": "South America"
- },
- {
- "name": "Hong Kong",
- "code": "HK",
- "languages": ["cantonese", "english"],
- "flag_colors": ["red", "white"],
- "region": "East Asia"
- },
- {
- "name": "Heard Island and McDonald Islands",
- "code": "HM",
- "languages": [],
- "flag_colors": ["blue", "white", "red"],
- "region": "Oceania"
- },
- {
- "name": "Honduras",
- "code": "HN",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white"],
- "region": "Central America"
- },
- {
- "name": "Croatia",
- "code": "HR",
- "languages": ["croatian"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Southern Europe"
- },
- {
- "name": "Haiti",
- "code": "HT",
- "languages": ["haitian creole", "french"],
- "flag_colors": ["blue", "red", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Hungary",
- "code": "HU",
- "languages": ["hungarian"],
- "flag_colors": ["red", "white", "green"],
- "region": "Eastern Europe"
- },
- {
- "name": "Indonesia",
- "code": "ID",
- "languages": ["indonesian"],
- "flag_colors": ["red", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Ireland",
- "code": "IE",
- "languages": ["english", "irish"],
- "flag_colors": ["green", "white", "orange"],
- "region": "Western Europe"
- },
- {
- "name": "Israel",
- "code": "IL",
- "languages": ["hebrew", "arabic"],
- "flag_colors": ["blue", "white"],
- "region": "Middle East"
- },
- {
- "name": "Isle of Man",
- "code": "IM",
- "languages": ["english", "manx"],
- "flag_colors": ["red", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "India",
- "code": "IN",
- "languages": ["hindi", "english"],
- "flag_colors": ["orange", "white", "green", "blue"],
- "region": "South Asia"
- },
- {
- "name": "British Indian Ocean Territory",
- "code": "IO",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red", "yellow"],
- "region": "South Asia"
- },
- {
- "name": "Iraq",
- "code": "IQ",
- "languages": ["arabic", "kurdish"],
- "flag_colors": ["red", "white", "black", "green"],
- "region": "Middle East"
- },
- {
- "name": "Iran",
- "code": "IR",
- "languages": ["persian"],
- "flag_colors": ["green", "white", "red"],
- "region": "Middle East"
- },
- {
- "name": "Iceland",
- "code": "IS",
- "languages": ["icelandic"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Northern Europe"
- },
- {
- "name": "Italy",
- "code": "IT",
- "languages": ["italian"],
- "flag_colors": ["green", "white", "red"],
- "region": "Southern Europe"
- },
- {
- "name": "Jersey",
- "code": "JE",
- "languages": ["english", "french"],
- "flag_colors": ["white", "red", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "Jamaica",
- "code": "JM",
- "languages": ["english"],
- "flag_colors": ["green", "yellow", "black"],
- "region": "Caribbean"
- },
- {
- "name": "Jordan",
- "code": "JO",
- "languages": ["arabic"],
- "flag_colors": ["black", "white", "green", "red"],
- "region": "Middle East"
- },
- {
- "name": "Japan",
- "code": "JP",
- "languages": ["japanese"],
- "flag_colors": ["red", "white"],
- "region": "East Asia"
- },
- {
- "name": "Kenya",
- "code": "KE",
- "languages": ["english", "swahili"],
- "flag_colors": ["black", "red", "green", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Kyrgyzstan",
- "code": "KG",
- "languages": ["kyrgyz", "russian"],
- "flag_colors": ["red", "yellow"],
- "region": "Central Asia"
- },
- {
- "name": "Cambodia",
- "code": "KH",
- "languages": ["khmer"],
- "flag_colors": ["blue", "red", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Kiribati",
- "code": "KI",
- "languages": ["english", "kiribati"],
- "flag_colors": ["red", "blue", "white", "yellow"],
- "region": "Oceania"
- },
- {
- "name": "Comoros",
- "code": "KM",
- "languages": ["comorian", "arabic", "french"],
- "flag_colors": ["green", "yellow", "white", "red", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Saint Kitts and Nevis",
- "code": "KN",
- "languages": ["english"],
- "flag_colors": ["green", "red", "black", "yellow", "white"],
- "region": "Caribbean"
- },
- {
- "name": "North Korea",
- "code": "KP",
- "languages": ["korean"],
- "flag_colors": ["red", "blue", "white"],
- "region": "East Asia"
- },
- {
- "name": "South Korea",
- "code": "KR",
- "languages": ["korean"],
- "flag_colors": ["white", "red", "blue", "black"],
- "region": "East Asia"
- },
- {
- "name": "Kuwait",
- "code": "KW",
- "languages": ["arabic"],
- "flag_colors": ["green", "white", "red", "black"],
- "region": "Middle East"
- },
- {
- "name": "Cayman Islands",
- "code": "KY",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Kazakhstan",
- "code": "KZ",
- "languages": ["kazakh", "russian"],
- "flag_colors": ["blue", "yellow"],
- "region": "Central Asia"
- },
- {
- "name": "Laos",
- "code": "LA",
- "languages": ["lao"],
- "flag_colors": ["red", "blue", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Lebanon",
- "code": "LB",
- "languages": ["arabic"],
- "flag_colors": ["red", "white", "green"],
- "region": "Middle East"
- },
- {
- "name": "Saint Lucia",
- "code": "LC",
- "languages": ["english"],
- "flag_colors": ["blue", "yellow", "black", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Liechtenstein",
- "code": "LI",
- "languages": ["german"],
- "flag_colors": ["blue", "red", "yellow"],
- "region": "Western Europe"
- },
- {
- "name": "Sri Lanka",
- "code": "LK",
- "languages": ["sinhala", "tamil"],
- "flag_colors": ["yellow", "green", "orange", "red"],
- "region": "South Asia"
- },
- {
- "name": "Liberia",
- "code": "LR",
- "languages": ["english"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Lesotho",
- "code": "LS",
- "languages": ["sesotho", "english"],
- "flag_colors": ["blue", "white", "green", "black"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Lithuania",
- "code": "LT",
- "languages": ["lithuanian"],
- "flag_colors": ["yellow", "green", "red"],
- "region": "Northern Europe"
- },
- {
- "name": "Luxembourg",
- "code": "LU",
- "languages": ["luxembourgish", "french", "german"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Western Europe"
- },
- {
- "name": "Latvia",
- "code": "LV",
- "languages": ["latvian"],
- "flag_colors": ["red", "white"],
- "region": "Northern Europe"
- },
- {
- "name": "Libya",
- "code": "LY",
- "languages": ["arabic"],
- "flag_colors": ["red", "black", "green", "white"],
- "region": "North Africa"
- },
- {
- "name": "Morocco",
- "code": "MA",
- "languages": ["arabic", "tamazight"],
- "flag_colors": ["red", "green"],
- "region": "North Africa"
- },
- {
- "name": "Monaco",
- "code": "MC",
- "languages": ["french"],
- "flag_colors": ["red", "white"],
- "region": "Western Europe"
- },
- {
- "name": "Moldova",
- "code": "MD",
- "languages": ["romanian"],
- "flag_colors": ["blue", "yellow", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Montenegro",
- "code": "ME",
- "languages": ["montenegrin"],
- "flag_colors": ["red", "yellow"],
- "region": "Southern Europe"
- },
- {
- "name": "Saint Martin",
- "code": "MF",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Madagascar",
- "code": "MG",
- "languages": ["malagasy", "french"],
- "flag_colors": ["white", "red", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Marshall Islands",
- "code": "MH",
- "languages": ["marshallese", "english"],
- "flag_colors": ["blue", "white", "orange"],
- "region": "Oceania"
- },
- {
- "name": "North Macedonia",
- "code": "MK",
- "languages": ["macedonian", "albanian"],
- "flag_colors": ["red", "yellow"],
- "region": "Southern Europe"
- },
- {
- "name": "Mali",
- "code": "ML",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Myanmar",
- "code": "MM",
- "languages": ["burmese"],
- "flag_colors": ["yellow", "green", "red", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Mongolia",
- "code": "MN",
- "languages": ["mongolian"],
- "flag_colors": ["red", "blue", "yellow"],
- "region": "East Asia"
- },
- {
- "name": "Macao",
- "code": "MO",
- "languages": ["cantonese", "portuguese"],
- "flag_colors": ["green", "white", "yellow"],
- "region": "East Asia"
- },
- {
- "name": "Northern Mariana Islands",
- "code": "MP",
- "languages": ["english", "chamorro", "carolinian"],
- "flag_colors": ["blue", "white"],
- "region": "Oceania"
- },
- {
- "name": "Martinique",
- "code": "MQ",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Mauritania",
- "code": "MR",
- "languages": ["arabic"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "North Africa"
- },
- {
- "name": "Montserrat",
- "code": "MS",
- "languages": ["english"],
- "flag_colors": ["blue", "green", "white", "yellow", "red"],
- "region": "Caribbean"
- },
- {
- "name": "Malta",
- "code": "MT",
- "languages": ["maltese", "english"],
- "flag_colors": ["white", "red"],
- "region": "Southern Europe"
- },
- {
- "name": "Mauritius",
- "code": "MU",
- "languages": ["english", "french", "mauritian creole"],
- "flag_colors": ["red", "blue", "yellow", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Maldives",
- "code": "MV",
- "languages": ["divehi"],
- "flag_colors": ["red", "green", "white"],
- "region": "South Asia"
- },
- {
- "name": "Malawi",
- "code": "MW",
- "languages": ["english", "chichewa"],
- "flag_colors": ["black", "red", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Mexico",
- "code": "MX",
- "languages": ["spanish"],
- "flag_colors": ["green", "white", "red"],
- "region": "North America"
- },
- {
- "name": "Malaysia",
- "code": "MY",
- "languages": ["malay"],
- "flag_colors": ["red", "white", "blue", "yellow"],
- "region": "Southeast Asia"
- },
- {
- "name": "Mozambique",
- "code": "MZ",
- "languages": ["portuguese"],
- "flag_colors": ["green", "black", "yellow", "white", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Namibia",
- "code": "NA",
- "languages": ["english"],
- "flag_colors": ["blue", "red", "green", "white", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "New Caledonia",
- "code": "NC",
- "languages": ["french"],
- "flag_colors": ["blue", "red", "green", "yellow", "black"],
- "region": "Oceania"
- },
- {
- "name": "Niger",
- "code": "NE",
- "languages": ["french"],
- "flag_colors": ["orange", "white", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Norfolk Island",
- "code": "NF",
- "languages": ["english", "norfuk"],
- "flag_colors": ["green", "white"],
- "region": "Oceania"
- },
- {
- "name": "Nigeria",
- "code": "NG",
- "languages": ["english"],
- "flag_colors": ["green", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Nicaragua",
- "code": "NI",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white"],
- "region": "Central America"
- },
- {
- "name": "Netherlands",
- "code": "NL",
- "languages": ["dutch"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Western Europe"
- },
- {
- "name": "Norway",
- "code": "NO",
- "languages": ["norwegian"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Northern Europe"
- },
- {
- "name": "Nepal",
- "code": "NP",
- "languages": ["nepali"],
- "flag_colors": ["red", "blue", "white"],
- "region": "South Asia"
- },
- {
- "name": "Nauru",
- "code": "NR",
- "languages": ["nauruan", "english"],
- "flag_colors": ["blue", "yellow", "white"],
- "region": "Oceania"
- },
- {
- "name": "Niue",
- "code": "NU",
- "languages": ["niuean", "english"],
- "flag_colors": ["yellow", "blue", "white", "red"],
- "region": "Oceania"
- },
- {
- "name": "New Zealand",
- "code": "NZ",
- "languages": ["english", "maori"],
- "flag_colors": ["blue", "red", "white"],
- "region": "Oceania"
- },
- {
- "name": "Oman",
- "code": "OM",
- "languages": ["arabic"],
- "flag_colors": ["red", "white", "green"],
- "region": "Middle East"
- },
- {
- "name": "Panama",
- "code": "PA",
- "languages": ["spanish"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Central America"
- },
- {
- "name": "Peru",
- "code": "PE",
- "languages": ["spanish", "quechua", "aymara"],
- "flag_colors": ["red", "white"],
- "region": "South America"
- },
- {
- "name": "French Polynesia",
- "code": "PF",
- "languages": ["french", "tahitian"],
- "flag_colors": ["red", "white"],
- "region": "Oceania"
- },
- {
- "name": "Papua New Guinea",
- "code": "PG",
- "languages": ["english", "tok pisin", "hiri motu"],
- "flag_colors": ["red", "black", "yellow", "white"],
- "region": "Oceania"
- },
- {
- "name": "Philippines",
- "code": "PH",
- "languages": ["filipino", "english"],
- "flag_colors": ["blue", "red", "white", "yellow"],
- "region": "Southeast Asia"
- },
- {
- "name": "Pakistan",
- "code": "PK",
- "languages": ["urdu", "english"],
- "flag_colors": ["green", "white"],
- "region": "South Asia"
- },
- {
- "name": "Poland",
- "code": "PL",
- "languages": ["polish"],
- "flag_colors": ["white", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Saint Pierre and Miquelon",
- "code": "PM",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red", "yellow", "black"],
- "region": "North America"
- },
- {
- "name": "Pitcairn Islands",
- "code": "PN",
- "languages": ["english", "pitkern"],
- "flag_colors": ["blue", "green", "yellow", "red", "white"],
- "region": "Oceania"
- },
- {
- "name": "Puerto Rico",
- "code": "PR",
- "languages": ["spanish", "english"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Caribbean"
- },
- {
- "name": "Palestine",
- "code": "PS",
- "languages": ["arabic"],
- "flag_colors": ["red", "green", "white", "black"],
- "region": "Middle East"
- },
- {
- "name": "Portugal",
- "code": "PT",
- "languages": ["portuguese"],
- "flag_colors": ["green", "red", "yellow"],
- "region": "Southern Europe"
- },
- {
- "name": "Palau",
- "code": "PW",
- "languages": ["palauan", "english"],
- "flag_colors": ["blue", "yellow"],
- "region": "Oceania"
- },
- {
- "name": "Paraguay",
- "code": "PY",
- "languages": ["spanish", "guarani"],
- "flag_colors": ["red", "white", "blue", "yellow"],
- "region": "South America"
- },
- {
- "name": "Qatar",
- "code": "QA",
- "languages": ["arabic"],
- "flag_colors": ["red", "white"],
- "region": "Middle East"
- },
- {
- "name": "Reunion",
- "code": "RE",
- "languages": ["french", "reunion creole"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Romania",
- "code": "RO",
- "languages": ["romanian"],
- "flag_colors": ["blue", "yellow", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Serbia",
- "code": "RS",
- "languages": ["serbian"],
- "flag_colors": ["red", "blue", "white", "yellow"],
- "region": "Eastern Europe"
- },
- {
- "name": "Russia",
- "code": "RU",
- "languages": ["russian"],
- "flag_colors": ["white", "blue", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Rwanda",
- "code": "RW",
- "languages": ["kinyarwanda", "french", "english"],
- "flag_colors": ["blue", "yellow", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Saudi Arabia",
- "code": "SA",
- "languages": ["arabic"],
- "flag_colors": ["green", "white"],
- "region": "Middle East"
- },
- {
- "name": "Solomon Islands",
- "code": "SB",
- "languages": ["english"],
- "flag_colors": ["blue", "green", "yellow", "white"],
- "region": "Oceania"
- },
- {
- "name": "Seychelles",
- "code": "SC",
- "languages": ["seychellois creole", "english", "french"],
- "flag_colors": ["blue", "yellow", "red", "white", "green"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Sudan",
- "code": "SD",
- "languages": ["arabic", "english"],
- "flag_colors": ["red", "white", "black", "green"],
- "region": "North Africa"
- },
- {
- "name": "Sweden",
- "code": "SE",
- "languages": ["swedish"],
- "flag_colors": ["blue", "yellow"],
- "region": "Northern Europe"
- },
- {
- "name": "Singapore",
- "code": "SG",
- "languages": ["english", "malay", "mandarin", "tamil"],
- "flag_colors": ["red", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Saint Helena",
- "code": "SH",
- "languages": ["english"],
- "flag_colors": ["blue", "red", "white", "yellow", "green"],
- "region": "Atlantic Ocean"
- },
- {
- "name": "Slovenia",
- "code": "SI",
- "languages": ["slovenian"],
- "flag_colors": ["white", "blue", "red"],
- "region": "Southern Europe"
- },
- {
- "name": "Svalbard and Jan Mayen",
- "code": "SJ",
- "languages": ["norwegian"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Northern Europe"
- },
- {
- "name": "Slovakia",
- "code": "SK",
- "languages": ["slovak"],
- "flag_colors": ["white", "blue", "red"],
- "region": "Eastern Europe"
- },
- {
- "name": "Sierra Leone",
- "code": "SL",
- "languages": ["english"],
- "flag_colors": ["green", "white", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "San Marino",
- "code": "SM",
- "languages": ["italian"],
- "flag_colors": ["white", "blue"],
- "region": "Southern Europe"
- },
- {
- "name": "Senegal",
- "code": "SN",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Somalia",
- "code": "SO",
- "languages": ["somali", "arabic"],
- "flag_colors": ["blue", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Suriname",
- "code": "SR",
- "languages": ["dutch"],
- "flag_colors": ["green", "white", "red", "yellow"],
- "region": "South America"
- },
- {
- "name": "South Sudan",
- "code": "SS",
- "languages": ["english"],
- "flag_colors": ["black", "red", "green", "blue", "white", "yellow"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Sao Tome and Principe",
- "code": "ST",
- "languages": ["portuguese"],
- "flag_colors": ["green", "yellow", "red", "black"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "El Salvador",
- "code": "SV",
- "languages": ["spanish"],
- "flag_colors": ["blue", "white"],
- "region": "Central America"
- },
- {
- "name": "Sint Maarten",
- "code": "SX",
- "languages": ["dutch", "english"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Caribbean"
- },
- {
- "name": "Syria",
- "code": "SY",
- "languages": ["arabic"],
- "flag_colors": ["red", "white", "black", "green"],
- "region": "Middle East"
- },
- {
- "name": "Eswatini",
- "code": "SZ",
- "languages": ["swazi", "english"],
- "flag_colors": ["blue", "red", "yellow", "black", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Turks and Caicos Islands",
- "code": "TC",
- "languages": ["english"],
- "flag_colors": ["blue", "red", "white", "yellow", "green"],
- "region": "Caribbean"
- },
- {
- "name": "Chad",
- "code": "TD",
- "languages": ["french", "arabic"],
- "flag_colors": ["blue", "yellow", "red"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "French Southern Territories",
- "code": "TF",
- "languages": ["french"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Oceania"
- },
- {
- "name": "Togo",
- "code": "TG",
- "languages": ["french"],
- "flag_colors": ["green", "yellow", "red", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Thailand",
- "code": "TH",
- "languages": ["thai"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Southeast Asia"
- },
- {
- "name": "Tajikistan",
- "code": "TJ",
- "languages": ["tajik"],
- "flag_colors": ["red", "white", "green", "yellow"],
- "region": "Central Asia"
- },
- {
- "name": "Tokelau",
- "code": "TK",
- "languages": ["tokelauan", "english"],
- "flag_colors": ["blue", "yellow", "white"],
- "region": "Oceania"
- },
- {
- "name": "Timor-Leste",
- "code": "TL",
- "languages": ["tetum", "portuguese"],
- "flag_colors": ["red", "yellow", "black", "white"],
- "region": "Southeast Asia"
- },
- {
- "name": "Turkmenistan",
- "code": "TM",
- "languages": ["turkmen"],
- "flag_colors": ["green", "red", "white"],
- "region": "Central Asia"
- },
- {
- "name": "Tunisia",
- "code": "TN",
- "languages": ["arabic"],
- "flag_colors": ["red", "white"],
- "region": "North Africa"
- },
- {
- "name": "Tonga",
- "code": "TO",
- "languages": ["tongan", "english"],
- "flag_colors": ["red", "white"],
- "region": "Oceania"
- },
- {
- "name": "Turkey",
- "code": "TR",
- "languages": ["turkish"],
- "flag_colors": ["red", "white"],
- "region": "Middle East"
- },
- {
- "name": "Trinidad and Tobago",
- "code": "TT",
- "languages": ["english"],
- "flag_colors": ["red", "black", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Tuvalu",
- "code": "TV",
- "languages": ["tuvaluan", "english"],
- "flag_colors": ["blue", "yellow", "red", "white"],
- "region": "Oceania"
- },
- {
- "name": "Taiwan",
- "code": "TW",
- "languages": ["mandarin"],
- "flag_colors": ["red", "blue", "white"],
- "region": "East Asia"
- },
- {
- "name": "Tanzania",
- "code": "TZ",
- "languages": ["swahili", "english"],
- "flag_colors": ["green", "yellow", "black", "blue"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Ukraine",
- "code": "UA",
- "languages": ["ukrainian"],
- "flag_colors": ["blue", "yellow"],
- "region": "Eastern Europe"
- },
- {
- "name": "Uganda",
- "code": "UG",
- "languages": ["english", "swahili"],
- "flag_colors": ["black", "yellow", "red", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "United States",
- "code": "US",
- "languages": ["english"],
- "flag_colors": ["red", "white", "blue"],
- "region": "North America"
- },
- {
- "name": "Uruguay",
- "code": "UY",
- "languages": ["spanish"],
- "flag_colors": ["white", "blue", "yellow"],
- "region": "South America"
- },
- {
- "name": "Uzbekistan",
- "code": "UZ",
- "languages": ["uzbek"],
- "flag_colors": ["blue", "white", "green", "red"],
- "region": "Central Asia"
- },
- {
- "name": "Vatican City",
- "code": "VA",
- "languages": ["italian", "latin"],
- "flag_colors": ["yellow", "white"],
- "region": "Southern Europe"
- },
- {
- "name": "Saint Vincent and the Grenadines",
- "code": "VC",
- "languages": ["english"],
- "flag_colors": ["blue", "yellow", "green", "white"],
- "region": "Caribbean"
- },
- {
- "name": "Venezuela",
- "code": "VE",
- "languages": ["spanish"],
- "flag_colors": ["yellow", "blue", "red", "white"],
- "region": "South America"
- },
- {
- "name": "British Virgin Islands",
- "code": "VG",
- "languages": ["english"],
- "flag_colors": ["blue", "white", "red"],
- "region": "Caribbean"
- },
- {
- "name": "U.S. Virgin Islands",
- "code": "VI",
- "languages": ["english"],
- "flag_colors": ["red", "white", "blue", "yellow"],
- "region": "Caribbean"
- },
- {
- "name": "Vietnam",
- "code": "VN",
- "languages": ["vietnamese"],
- "flag_colors": ["red", "yellow"],
- "region": "Southeast Asia"
- },
- {
- "name": "Vanuatu",
- "code": "VU",
- "languages": ["bislama", "english", "french"],
- "flag_colors": ["red", "green", "black", "yellow"],
- "region": "Oceania"
- },
- {
- "name": "Wallis and Futuna",
- "code": "WF",
- "languages": ["french", "wallisian", "futunan"],
- "flag_colors": ["red", "white", "blue"],
- "region": "Oceania"
- },
- {
- "name": "Samoa",
- "code": "WS",
- "languages": ["samoan", "english"],
- "flag_colors": ["red", "blue", "white"],
- "region": "Oceania"
- },
- {
- "name": "Kosovo",
- "code": "XK",
- "languages": ["albanian", "serbian"],
- "flag_colors": ["blue", "yellow", "white"],
- "region": "Eastern Europe"
- },
- {
- "name": "Yemen",
- "code": "YE",
- "languages": ["arabic"],
- "flag_colors": ["red", "white", "black"],
- "region": "Middle East"
- },
- {
- "name": "Mayotte",
- "code": "YT",
- "languages": ["french", "shimaore", "kibushi"],
- "flag_colors": ["white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "South Africa",
- "code": "ZA",
- "languages": ["zulu", "xhosa", "afrikaans", "english"],
- "flag_colors": ["red", "blue", "green", "yellow", "black", "white"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Zambia",
- "code": "ZM",
- "languages": ["english"],
- "flag_colors": ["green", "red", "black", "orange"],
- "region": "Sub-Saharan Africa"
- },
- {
- "name": "Zimbabwe",
- "code": "ZW",
- "languages": ["english", "shona", "ndebele"],
- "flag_colors": ["green", "yellow", "red", "black", "white"],
- "region": "Sub-Saharan Africa"
- }
-];
diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts
deleted file mode 100644
index 0cca292f..00000000
--- a/apps/web/src/lib/stickers/providers/flags.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-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 DEFAULT_SEARCH_LIMIT = 100;
-const DEFAULT_FLAGS_BASE_URL = "/flags";
-
-let countriesPromise: Promise<CountryRecord[]> | null = null;
-
-function getFlagsBaseUrl(): string {
- return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, "");
-}
-
-function buildFlagUrl({ code }: { code: string }): string {
- const normalizedCode = code.toLowerCase();
- return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`;
-}
-
-async function loadCountries(): Promise<CountryRecord[]> {
- if (countriesPromise) {
- return countriesPromise;
- }
-
- countriesPromise = import("./countries-data")
- .then((m) => m.COUNTRIES)
- .catch((error) => {
- console.error("Failed to load countries dataset:", error);
- return [];
- });
-
- return countriesPromise;
-}
-
-function toStickerItem({ country }: { country: CountryRecord }): StickerItem {
- const normalizedCode = country.code.toUpperCase();
- return {
- id: buildStickerId({
- providerId: FLAGS_PROVIDER_ID,
- providerValue: normalizedCode,
- }),
- provider: FLAGS_PROVIDER_ID,
- name: country.name,
- previewUrl: buildFlagUrl({ code: normalizedCode }),
- metadata: {
- code: normalizedCode,
- region: country.region ?? null,
- languages: country.languages ?? [],
- flagColors: country.flag_colors ?? [],
- },
- };
-}
-
-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<RegionId> | 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,
-}: {
- countries: CountryRecord[];
- query: string;
-}): CountryRecord[] {
- if (!query) {
- 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();
- return normalizedName.includes(query) || normalizedCode.includes(query);
- });
-}
-
-function paginateCountries({
- countries,
- options,
-}: {
- countries: CountryRecord[];
- options?: { page?: number; limit?: number };
-}): { items: CountryRecord[]; hasMore: boolean; total: number } {
- 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;
- return {
- items: countries.slice(startIndex, endIndex),
- hasMore: endIndex < countries.length,
- total: countries.length,
- };
-}
-
-export const flagsProvider: StickerProvider = {
- id: FLAGS_PROVIDER_ID,
- async search({
- query,
- options,
- }: {
- query: string;
- options?: { limit?: number };
- }): Promise<StickerSearchResult> {
- const countries = await loadCountries();
- const normalizedQuery = normalizeQuery({ query });
- const filteredCountries = filterCountriesByQuery({
- countries,
- query: normalizedQuery,
- });
- const paged = paginateCountries({
- countries: filteredCountries,
- options: {
- page: 1,
- limit: options?.limit ?? DEFAULT_SEARCH_LIMIT,
- },
- });
- return {
- items: paged.items.map((country) => toStickerItem({ country })),
- total: paged.total,
- hasMore: paged.hasMore,
- };
- },
- async browse({
- options,
- }: {
- options?: { page?: number; limit?: number };
- }): Promise<StickerBrowseResult> {
- const countries = await loadCountries();
- const paged = paginateCountries({ countries, options });
- return {
- sections: [
- {
- id: "all",
- items: paged.items.map((country) => toStickerItem({ country })),
- hasMore: paged.hasMore,
- layout: "grid",
- },
- ],
- };
- },
- resolveUrl({
- stickerId,
- }: {
- stickerId: string;
- options?: { width?: number; height?: number };
- }): string {
- const { providerValue } = parseStickerId({ stickerId });
- return buildFlagUrl({ code: providerValue });
- },
-};
diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts
deleted file mode 100644
index 830a1129..00000000
--- a/apps/web/src/lib/stickers/providers/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { stickersRegistry } from "../registry";
-import type { StickerProvider } from "@/lib/stickers/types";
-import { flagsProvider } from "./flags";
-import { logosProvider } from "./logos";
-import { shapesProvider } from "./shapes";
-
-const defaultProviders: StickerProvider[] = [
- logosProvider,
- flagsProvider,
- shapesProvider,
-];
-
-export function registerDefaultStickerProviders({
- providersToRegister = defaultProviders,
-}: {
- providersToRegister?: StickerProvider[];
-} = {}): void {
- for (const provider of providersToRegister) {
- if (stickersRegistry.has(provider.id)) {
- continue;
- }
- stickersRegistry.register(provider.id, provider);
- }
-}
diff --git a/apps/web/src/lib/stickers/providers/logos.ts b/apps/web/src/lib/stickers/providers/logos.ts
deleted file mode 100644
index 474c64da..00000000
--- a/apps/web/src/lib/stickers/providers/logos.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type {
- StickerBrowseResult,
- StickerItem,
- StickerProvider,
- StickerSearchResult,
-} from "../types";
-
-const LOGOS_PROVIDER_ID = "logos";
-
-const EMPTY_SEARCH_RESULT: StickerSearchResult = {
- items: [] as StickerItem[],
- total: 0,
- hasMore: false,
-};
-
-const EMPTY_BROWSE_RESULT: StickerBrowseResult = {
- sections: [],
-};
-
-export const logosProvider: StickerProvider = {
- id: LOGOS_PROVIDER_ID,
- async search(): Promise<StickerSearchResult> {
- return EMPTY_SEARCH_RESULT;
- },
- async browse(): Promise<StickerBrowseResult> {
- return EMPTY_BROWSE_RESULT;
- },
- resolveUrl({ stickerId }: { stickerId: string }): string {
- return stickerId;
- },
-};
diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts
deleted file mode 100644
index a23f14a7..00000000
--- a/apps/web/src/lib/stickers/providers/shapes.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-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,
-} from "../types";
-
-const SHAPES_PROVIDER_ID = "shapes";
-
-type ShapeGraphicPreset = {
- shapeKey: string;
- name: string;
- definitionId: string;
- params?: ParamValues;
-};
-
-const LEGACY_SHAPE_PRESETS: Record<string, ShapeGraphicPreset> = {
- 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 {
- const preset = getShapePreset({ shapeKey });
- if (!preset) {
- return buildGraphicPreviewUrl({ definitionId: "rectangle" });
- }
- return buildGraphicPreviewUrl({
- definitionId: preset.definitionId,
- params: getShapeParams({ preset }),
- });
-}
-
-function toStickerItem({
- preset,
-}: {
- preset: ShapeGraphicPreset;
-}): StickerItem {
- return {
- id: buildStickerId({
- providerId: SHAPES_PROVIDER_ID,
- providerValue: preset.shapeKey,
- }),
- provider: SHAPES_PROVIDER_ID,
- name: preset.name,
- previewUrl: buildShapeUrl({ shapeKey: preset.shapeKey }),
- metadata: {
- definitionId: preset.definitionId,
- params: preset.params ?? {},
- },
- };
-}
-
-function filterShapesByQuery({ query }: { query: string }): ShapeGraphicPreset[] {
- const normalizedQuery = query.trim().toLowerCase();
- const presets = getShapePresets();
- if (!normalizedQuery) {
- return presets;
- }
-
- 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: ShapeGraphicPreset[];
- options?: { page?: number; limit?: number };
-}): { items: ShapeGraphicPreset[]; hasMore: boolean; total: number } {
- const page = Math.max(1, options?.page ?? 1);
- const limit = Math.max(1, options?.limit ?? getShapePresets().length);
- const startIndex = (page - 1) * limit;
- const endIndex = startIndex + limit;
- const pagedItems = shapes.slice(startIndex, endIndex);
- return {
- items: pagedItems,
- hasMore: endIndex < shapes.length,
- total: shapes.length,
- };
-}
-
-export const shapesProvider: StickerProvider = {
- id: SHAPES_PROVIDER_ID,
- async search({
- query,
- options,
- }: {
- query: string;
- options?: { limit?: number };
- }): Promise<StickerSearchResult> {
- const filteredShapes = filterShapesByQuery({ query });
- const paged = paginateShapes({
- shapes: filteredShapes,
- options: { page: 1, limit: options?.limit ?? getShapePresets().length },
- });
- return {
- items: paged.items.map((preset) => toStickerItem({ preset })),
- total: paged.total,
- hasMore: paged.hasMore,
- };
- },
- async browse({
- options,
- }: {
- options?: { page?: number; limit?: number };
- }): Promise<StickerBrowseResult> {
- const paged = paginateShapes({
- shapes: getShapePresets(),
- options,
- });
- return {
- sections: [
- {
- id: "all",
- items: paged.items.map((preset) => toStickerItem({ preset })),
- hasMore: paged.hasMore,
- layout: "grid",
- },
- ],
- };
- },
- resolveUrl({
- stickerId,
- }: {
- stickerId: string;
- options?: { width?: number; height?: number };
- }): string {
- 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
deleted file mode 100644
index 7148c09d..00000000
--- a/apps/web/src/lib/stickers/registry.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { StickerProvider } from "@/lib/stickers/types";
-import { DefinitionRegistry } from "@/lib/registry";
-
-export class StickersRegistry extends DefinitionRegistry<string, StickerProvider> {
- constructor() {
- super("sticker provider");
- }
-}
-
-export const stickersRegistry = new StickersRegistry();
diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts
deleted file mode 100644
index 0002e716..00000000
--- a/apps/web/src/lib/stickers/resolver.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { stickersRegistry } from "./registry";
-import { parseStickerId } from "./sticker-id";
-import { registerDefaultStickerProviders } from "./providers";
-import type { StickerResolveOptions } from "@/lib/stickers/types";
-
-export function resolveStickerId({
- stickerId,
- options,
-}: {
- stickerId: string;
- options?: StickerResolveOptions;
-}): string {
- registerDefaultStickerProviders();
-
- const parsedStickerId = parseStickerId({ stickerId });
- return stickersRegistry.get(parsedStickerId.providerId).resolveUrl({
- stickerId,
- options,
- });
-}
diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts
deleted file mode 100644
index cb56db4c..00000000
--- a/apps/web/src/lib/stickers/sticker-id.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export function parseStickerId({ stickerId }: { stickerId: string }): {
- providerId: string;
- providerValue: string;
-} {
- const normalizedStickerId = stickerId.trim();
- if (!normalizedStickerId) {
- throw new Error("Sticker ID must be a non-empty string");
- }
-
- const separatorIndex = normalizedStickerId.indexOf(":");
- if (
- separatorIndex <= 0 ||
- separatorIndex === normalizedStickerId.length - 1
- ) {
- throw new Error(
- `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`,
- );
- }
-
- const providerId = normalizedStickerId.slice(0, separatorIndex).trim();
- const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim();
-
- return { providerId, providerValue };
-}
-
-export function buildStickerId({
- providerId,
- providerValue,
-}: {
- providerId: string;
- providerValue: string;
-}): string {
- return `${providerId}:${providerValue}`;
-}
diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts
deleted file mode 100644
index bc7dd490..00000000
--- a/apps/web/src/lib/stickers/types.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { STICKER_CATEGORIES } from "@/lib/stickers/categories";
-
-export type StickerCategory = keyof typeof STICKER_CATEGORIES;
-
-export interface StickerItem {
- id: string;
- provider: string;
- name: string;
- previewUrl: string;
- metadata: Record<string, unknown>;
-}
-
-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<StickerSearchResult>;
- browse({
- options,
- }: {
- options?: StickerProviderBrowseOptions;
- }): Promise<StickerBrowseResult>;
- resolveUrl({
- stickerId,
- options,
- }: {
- stickerId: string;
- options?: StickerResolveOptions;
- }): string;
-}
diff --git a/apps/web/src/lib/subtitles/ass.ts b/apps/web/src/lib/subtitles/ass.ts
deleted file mode 100644
index 030c5b36..00000000
--- a/apps/web/src/lib/subtitles/ass.ts
+++ /dev/null
@@ -1,514 +0,0 @@
-import type {
- ParseSubtitleResult,
- SubtitleCue,
- SubtitleStyleOverrides,
-} from "./types";
-
-const ASS_DEFAULT_PLAY_RES_X = 384;
-const ASS_DEFAULT_PLAY_RES_Y = 288;
-const STYLE_SECTION_NAMES = new Set(["v4 styles", "v4+ styles"]);
-const ALIGNMENT_MAP: Record<
- number,
- {
- textAlign: NonNullable<SubtitleStyleOverrides["textAlign"]>;
- verticalAlign: NonNullable<
- NonNullable<SubtitleStyleOverrides["placement"]>["verticalAlign"]
- >;
- }
-> = {
- 1: { textAlign: "left", verticalAlign: "bottom" },
- 2: { textAlign: "center", verticalAlign: "bottom" },
- 3: { textAlign: "right", verticalAlign: "bottom" },
- 4: { textAlign: "left", verticalAlign: "middle" },
- 5: { textAlign: "center", verticalAlign: "middle" },
- 6: { textAlign: "right", verticalAlign: "middle" },
- 7: { textAlign: "left", verticalAlign: "top" },
- 8: { textAlign: "center", verticalAlign: "top" },
- 9: { textAlign: "right", verticalAlign: "top" },
-};
-
-interface AssScriptInfo {
- playResX: number;
- playResY: number;
-}
-
-interface AssStyleRecord {
- name: string;
- fontname?: string;
- fontsize?: string;
- primarycolour?: string;
- secondarycolour?: string;
- outlinecolour?: string;
- backcolour?: string;
- bold?: string;
- italic?: string;
- underline?: string;
- strikeout?: string;
- scalex?: string;
- scaley?: string;
- spacing?: string;
- angle?: string;
- borderstyle?: string;
- outline?: string;
- shadow?: string;
- alignment?: string;
- marginl?: string;
- marginr?: string;
- marginv?: string;
-}
-
-export function parseAss({ input }: { input: string }): ParseSubtitleResult {
- const normalized = input.replace(/\r\n?/g, "\n").trim();
- if (!normalized) {
- return {
- captions: [],
- skippedCueCount: 0,
- warnings: [],
- };
- }
-
- const scriptInfo: AssScriptInfo = {
- playResX: ASS_DEFAULT_PLAY_RES_X,
- playResY: ASS_DEFAULT_PLAY_RES_Y,
- };
- const warnings = new Set<string>();
- const styles = new Map<string, SubtitleStyleOverrides>();
- const captions: SubtitleCue[] = [];
-
- let currentSection = "";
- let styleFormat: string[] | null = null;
- let eventFormat: string[] | null = null;
- let skippedCueCount = 0;
- let strippedInlineTagCueCount = 0;
- let skippedNonDialogueEventCount = 0;
- let ignoredEffectCount = 0;
- let missingStyleCueCount = 0;
- let usesHeavilyUnsupportedStyles = false;
-
- for (const rawLine of normalized.split("\n")) {
- const line = rawLine.trim();
- if (!line || line.startsWith(";")) {
- continue;
- }
-
- const sectionMatch = line.match(/^\[(.+)\]$/);
- if (sectionMatch) {
- currentSection = sectionMatch[1].trim().toLowerCase();
- continue;
- }
-
- if (currentSection === "script info") {
- const [rawKey, ...rest] = line.split(":");
- if (!rawKey || rest.length === 0) continue;
-
- const key = rawKey.trim().toLowerCase();
- const value = rest.join(":").trim();
- const parsedValue = parseFloat(value);
-
- if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
- continue;
- }
-
- if (key === "playresx") {
- scriptInfo.playResX = parsedValue;
- }
- if (key === "playresy") {
- scriptInfo.playResY = parsedValue;
- }
- continue;
- }
-
- if (STYLE_SECTION_NAMES.has(currentSection)) {
- if (line.startsWith("Format:")) {
- styleFormat = parseAssFormat({ line });
- continue;
- }
-
- if (line.startsWith("Style:") && styleFormat) {
- const values = splitAssFields({
- value: line.slice("Style:".length).trim(),
- expectedFieldCount: styleFormat.length,
- });
- if (values.length !== styleFormat.length) {
- continue;
- }
-
- const record = mapFieldsToRecord<AssStyleRecord>({
- fields: styleFormat,
- values,
- });
- if (!record.name) {
- continue;
- }
-
- const mappedStyle = mapAssStyleToSubtitleStyle({
- style: record,
- scriptInfo,
- });
- usesHeavilyUnsupportedStyles ||= mappedStyle.hasUnsupportedFeatures;
- styles.set(record.name.toLowerCase(), mappedStyle.style);
- }
- continue;
- }
-
- if (currentSection !== "events") {
- continue;
- }
-
- if (line.startsWith("Format:")) {
- eventFormat = parseAssFormat({ line });
- continue;
- }
-
- if (!eventFormat) {
- continue;
- }
-
- if (line.startsWith("Dialogue:")) {
- const values = splitAssFields({
- value: line.slice("Dialogue:".length).trim(),
- expectedFieldCount: eventFormat.length,
- });
- if (values.length !== eventFormat.length) {
- skippedCueCount += 1;
- continue;
- }
-
- const record = mapFieldsToRecord<Record<string, string>>({
- fields: eventFormat,
- values,
- });
- const startTime = parseAssTimestamp({ input: record.start });
- const endTime = parseAssTimestamp({ input: record.end });
- const duration = endTime - startTime;
- if (
- !Number.isFinite(startTime) ||
- !Number.isFinite(endTime) ||
- duration <= 0
- ) {
- skippedCueCount += 1;
- continue;
- }
-
- const strippedText = stripAssText({
- input: record.text ?? "",
- });
- strippedInlineTagCueCount += strippedText.hadInlineTags ? 1 : 0;
- if (!strippedText.text) {
- skippedCueCount += 1;
- continue;
- }
-
- if (record.effect?.trim()) {
- ignoredEffectCount += 1;
- }
-
- const referencedStyleName = record.style?.trim().toLowerCase();
- const referencedStyle = referencedStyleName
- ? styles.get(referencedStyleName)
- : undefined;
- if (referencedStyleName && !referencedStyle) {
- missingStyleCueCount += 1;
- }
- const style = referencedStyle ?? styles.get("default");
-
- captions.push({
- text: strippedText.text,
- startTime,
- duration,
- style,
- });
- continue;
- }
-
- if (line.includes(":")) {
- skippedNonDialogueEventCount += 1;
- }
- }
-
- if (strippedInlineTagCueCount > 0) {
- warnings.add(
- `Stripped unsupported ASS inline override tags from ${strippedInlineTagCueCount} subtitle cue(s).`,
- );
- }
-
- if (ignoredEffectCount > 0) {
- warnings.add(
- `Ignored ASS event effects in ${ignoredEffectCount} subtitle cue(s).`,
- );
- }
-
- if (missingStyleCueCount > 0) {
- warnings.add(
- `Fell back to default subtitle styling for ${missingStyleCueCount} cue(s) that referenced missing ASS styles.`,
- );
- }
-
- if (skippedNonDialogueEventCount > 0) {
- warnings.add(
- `Ignored ${skippedNonDialogueEventCount} non-dialogue ASS event(s).`,
- );
- }
-
- if (usesHeavilyUnsupportedStyles) {
- warnings.add(
- "Ignored unsupported ASS style features such as outline, shadow, rotation, or scaling.",
- );
- }
-
- return {
- captions,
- skippedCueCount,
- warnings: [...warnings],
- };
-}
-
-function parseAssFormat({ line }: { line: string }): string[] {
- return line
- .slice(line.indexOf(":") + 1)
- .split(",")
- .map((field) => field.trim().toLowerCase());
-}
-
-function splitAssFields({
- value,
- expectedFieldCount,
-}: {
- value: string;
- expectedFieldCount: number;
-}): string[] {
- if (expectedFieldCount <= 1) {
- return [value];
- }
-
- const result: string[] = [];
- let current = "";
-
- for (const character of value) {
- if (character === "," && result.length < expectedFieldCount - 1) {
- result.push(current.trim());
- current = "";
- continue;
- }
-
- current += character;
- }
-
- result.push(current.trim());
- return result;
-}
-
-function mapFieldsToRecord<T extends object>({
- fields,
- values,
-}: {
- fields: string[];
- values: string[];
-}): T {
- const record = {} as Record<string, string | undefined>;
-
- for (let index = 0; index < fields.length; index++) {
- record[fields[index]] = values[index];
- }
-
- return record as T;
-}
-
-function parseAssTimestamp({ input }: { input: string | undefined }): number {
- if (!input) {
- return Number.NaN;
- }
-
- const match = input.trim().match(/^(\d+):(\d{2}):(\d{2})[.](\d{1,2}|\d{3})$/);
- if (!match) {
- return Number.NaN;
- }
-
- const [, hours, minutes, seconds, fraction] = match;
- const parsedHours = Number.parseInt(hours, 10);
- const parsedMinutes = Number.parseInt(minutes, 10);
- const parsedSeconds = Number.parseInt(seconds, 10);
- const parsedMilliseconds = Number.parseInt(fraction.padEnd(3, "0"), 10);
-
- return (
- parsedHours * 3600 +
- parsedMinutes * 60 +
- parsedSeconds +
- parsedMilliseconds / 1000
- );
-}
-
-function stripAssText({ input }: { input: string }): {
- text: string;
- hadInlineTags: boolean;
-} {
- const hadInlineTags = /\{[^}]*\}/.test(input);
- const text = input
- .replace(/\{[^}]*\}/g, "")
- .replace(/\\N/gi, "\n")
- .replace(/\\h/g, " ")
- .replace(/\\n/g, "\n")
- .trim();
-
- return {
- text,
- hadInlineTags,
- };
-}
-
-function mapAssStyleToSubtitleStyle({
- style,
- scriptInfo,
-}: {
- style: AssStyleRecord;
- scriptInfo: AssScriptInfo;
-}): {
- style: SubtitleStyleOverrides;
- hasUnsupportedFeatures: boolean;
-} {
- const fontSize = parseFloat(style.fontsize ?? "");
- const primaryColor = parseAssColor({ input: style.primarycolour });
- const backColor = parseAssColor({ input: style.backcolour });
- const bold = parseAssBoolean({ input: style.bold });
- const italic = parseAssBoolean({ input: style.italic });
- const underline = parseAssBoolean({ input: style.underline });
- const strikeOut = parseAssBoolean({ input: style.strikeout });
- const borderStyle = parseFloat(style.borderstyle ?? "");
- const spacing = parseFloat(style.spacing ?? "");
- const alignment = parseFloat(style.alignment ?? "");
- const marginLeft = parseFloat(style.marginl ?? "");
- const marginRight = parseFloat(style.marginr ?? "");
- const marginVertical = parseFloat(style.marginv ?? "");
- // Store as a ratio of playResY so the builder can convert to app units
- // without the parser needing to know the app's coordinate system.
- const fontSizeRatioOfPlayHeight = Number.isFinite(fontSize)
- ? Math.round((fontSize / scriptInfo.playResY) * 1000) / 1000
- : null;
-
- const mappedAlignment =
- ALIGNMENT_MAP[Math.round(alignment)] ?? ALIGNMENT_MAP[2];
- const placement =
- Number.isFinite(marginLeft) ||
- Number.isFinite(marginRight) ||
- Number.isFinite(marginVertical) ||
- mappedAlignment.verticalAlign !== "bottom"
- ? {
- verticalAlign: mappedAlignment.verticalAlign,
- marginLeftRatio: Number.isFinite(marginLeft)
- ? marginLeft / scriptInfo.playResX
- : undefined,
- marginRightRatio: Number.isFinite(marginRight)
- ? marginRight / scriptInfo.playResX
- : undefined,
- marginVerticalRatio: Number.isFinite(marginVertical)
- ? marginVertical / scriptInfo.playResY
- : undefined,
- }
- : undefined;
-
- const styleOverrides: SubtitleStyleOverrides = {
- ...(style.fontname ? { fontFamily: style.fontname.trim() } : {}),
- ...(fontSizeRatioOfPlayHeight !== null && fontSizeRatioOfPlayHeight > 0
- ? { fontSizeRatioOfPlayHeight }
- : {}),
- ...(primaryColor?.cssColor ? { color: primaryColor.cssColor } : {}),
- ...(bold !== null ? { fontWeight: bold ? "bold" : "normal" } : {}),
- ...(italic !== null ? { fontStyle: italic ? "italic" : "normal" } : {}),
- ...(underline || strikeOut
- ? {
- textDecoration: underline
- ? "underline"
- : strikeOut
- ? "line-through"
- : "none",
- }
- : {}),
- ...(Number.isFinite(spacing) ? { letterSpacing: spacing } : {}),
- textAlign: mappedAlignment.textAlign,
- ...(placement ? { placement } : {}),
- ...(backColor?.cssColor && Math.round(borderStyle) === 3
- ? {
- background: {
- enabled: backColor.alpha > 0,
- color: backColor.alpha > 0 ? backColor.cssColor : "transparent",
- },
- }
- : {}),
- };
-
- const hasUnsupportedFeatures =
- Math.round(borderStyle) !== 1 && Math.round(borderStyle) !== 3
- ? true
- : (parseFloat(style.outline ?? "") || 0) > 0 ||
- (parseFloat(style.shadow ?? "") || 0) > 0 ||
- (parseFloat(style.angle ?? "") || 0) !== 0 ||
- (Number.isFinite(parseFloat(style.scalex ?? "")) &&
- parseFloat(style.scalex ?? "") !== 100) ||
- (Number.isFinite(parseFloat(style.scaley ?? "")) &&
- parseFloat(style.scaley ?? "") !== 100) ||
- Boolean(underline && strikeOut);
-
- return {
- style: styleOverrides,
- hasUnsupportedFeatures,
- };
-}
-
-function parseAssBoolean({
- input,
-}: {
- input: string | undefined;
-}): boolean | null {
- if (input === undefined) {
- return null;
- }
-
- const trimmed = input.trim();
- if (!trimmed) {
- return null;
- }
-
- const parsed = Number.parseInt(trimmed, 10);
- if (!Number.isFinite(parsed)) {
- return null;
- }
-
- return parsed !== 0;
-}
-
-function parseAssColor({
- input,
-}: {
- input: string | undefined;
-}): { cssColor: string; alpha: number } | null {
- if (!input) {
- return null;
- }
-
- const normalized = input.trim().replace(/^&?H/i, "").padStart(8, "0");
- if (!/^[0-9a-fA-F]{8}$/.test(normalized)) {
- return null;
- }
-
- const alphaHex = normalized.slice(0, 2);
- const blueHex = normalized.slice(2, 4);
- const greenHex = normalized.slice(4, 6);
- const redHex = normalized.slice(6, 8);
-
- const red = Number.parseInt(redHex, 16);
- const green = Number.parseInt(greenHex, 16);
- const blue = Number.parseInt(blueHex, 16);
- const alpha = 1 - Number.parseInt(alphaHex, 16) / 255;
-
- if (alpha >= 1) {
- return {
- cssColor: `#${redHex}${greenHex}${blueHex}`.toLowerCase(),
- alpha,
- };
- }
-
- return {
- cssColor: `rgba(${red}, ${green}, ${blue}, ${Math.round(alpha * 1000) / 1000})`,
- alpha,
- };
-}
diff --git a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts
deleted file mode 100644
index 698310a6..00000000
--- a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts
+++ /dev/null
@@ -1,333 +0,0 @@
-import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography";
-import {
- getTextVisualRect,
- measureTextBlock,
- setCanvasLetterSpacing,
-} from "@/lib/text/layout";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import type { CreateTextElement } from "@/lib/timeline";
-import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
-
-const SUBTITLE_MAX_WIDTH_RATIO = 0.8;
-const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05;
-const SUBTITLE_FONT_SIZE = 5;
-const MEASUREMENT_CANVAS_SIZE = 4096;
-
-function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
- return `"${fontFamily.replace(/"/g, '\\"')}"`;
-}
-
-function createMeasurementContext(): CanvasRenderingContext2D | null {
- const canvas = document.createElement("canvas");
- canvas.width = MEASUREMENT_CANVAS_SIZE;
- canvas.height = MEASUREMENT_CANVAS_SIZE;
- return canvas.getContext("2d");
-}
-
-function measureLineWidth({
- ctx,
- text,
-}: {
- ctx: CanvasRenderingContext2D;
- text: string;
-}): number {
- return ctx.measureText(text).width;
-}
-
-function wrapSubtitleText({
- ctx,
- text,
- maxWidth,
-}: {
- ctx: CanvasRenderingContext2D;
- text: string;
- maxWidth: number;
-}): string {
- const normalized = text.trim().replace(/\r\n/g, "\n");
- const paragraphs = normalized.split("\n");
- const wrappedParagraphs: string[] = [];
-
- for (const paragraph of paragraphs) {
- const trimmedParagraph = paragraph.trim();
- if (!trimmedParagraph) {
- wrappedParagraphs.push("");
- continue;
- }
-
- const words = trimmedParagraph.split(/\s+/);
- let currentLine = words[0] ?? "";
- const lines: string[] = [];
-
- for (let i = 1; i < words.length; i++) {
- const nextLine = `${currentLine} ${words[i]}`;
- if (measureLineWidth({ ctx, text: nextLine }) <= maxWidth) {
- currentLine = nextLine;
- continue;
- }
-
- lines.push(currentLine);
- currentLine = words[i];
- }
-
- lines.push(currentLine);
- wrappedParagraphs.push(lines.join("\n"));
- }
-
- return wrappedParagraphs.join("\n");
-}
-
-function measureWrappedTextBlock({
- ctx,
- content,
- canvasHeight,
- textAlign,
- background,
- fontSize,
- lineHeight,
-}: {
- ctx: CanvasRenderingContext2D;
- content: string;
- canvasHeight: number;
- textAlign: CreateTextElement["textAlign"];
- background: CreateTextElement["background"];
- fontSize: number;
- lineHeight: number;
-}) {
- const scaledFontSize = fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const lineHeightPx = lineHeight * scaledFontSize;
- const lines = content.split("\n");
- const lineMetrics = lines.map((line) => ctx.measureText(line));
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- });
- const visualRect = getTextVisualRect({
- textAlign,
- block,
- background,
- fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize,
- });
-
- return {
- block,
- visualRect,
- };
-}
-
-function resolveSubtitleStyle({
- style,
-}: {
- style: SubtitleStyleOverrides | undefined;
-}): {
- fontFamily: string;
- fontSize: number;
- color: string;
- textAlign: CreateTextElement["textAlign"];
- fontWeight: CreateTextElement["fontWeight"];
- fontStyle: CreateTextElement["fontStyle"];
- textDecoration: CreateTextElement["textDecoration"];
- letterSpacing: number;
- lineHeight: number;
- background: CreateTextElement["background"];
- placement: NonNullable<SubtitleStyleOverrides["placement"]>;
-} {
- const fontSize =
- style?.fontSizeRatioOfPlayHeight != null
- ? style.fontSizeRatioOfPlayHeight * FONT_SIZE_SCALE_REFERENCE
- : (style?.fontSize ?? SUBTITLE_FONT_SIZE);
-
- return {
- fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily,
- fontSize,
- color: style?.color ?? DEFAULTS.text.element.color,
- textAlign: style?.textAlign ?? "center",
- fontWeight: style?.fontWeight ?? "bold",
- fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration:
- style?.textDecoration ?? DEFAULTS.text.element.textDecoration,
- letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing,
- lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight,
- background: {
- ...DEFAULTS.text.element.background,
- enabled: false,
- ...(style?.background ?? {}),
- },
- placement: {
- verticalAlign: style?.placement?.verticalAlign ?? "bottom",
- marginLeftRatio: style?.placement?.marginLeftRatio,
- marginRightRatio: style?.placement?.marginRightRatio,
- marginVerticalRatio: style?.placement?.marginVerticalRatio,
- },
- };
-}
-
-function resolveTargetWidth({
- canvasWidth,
- placement,
-}: {
- canvasWidth: number;
- placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
-}): number {
- const leftRatio = placement.marginLeftRatio ?? 0;
- const rightRatio = placement.marginRightRatio ?? 0;
- const hasExplicitMargins = leftRatio > 0 || rightRatio > 0;
- if (!hasExplicitMargins) {
- return canvasWidth * SUBTITLE_MAX_WIDTH_RATIO;
- }
-
- const availableWidth = canvasWidth * (1 - leftRatio - rightRatio);
- return Math.max(0, availableWidth);
-}
-
-function resolvePositionX({
- canvasWidth,
- textAlign,
- placement,
- visualRect,
-}: {
- canvasWidth: number;
- textAlign: CreateTextElement["textAlign"];
- placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
- visualRect: { left: number; width: number };
-}): number {
- const leftMargin = canvasWidth * (placement.marginLeftRatio ?? 0);
- const rightMargin = canvasWidth * (placement.marginRightRatio ?? 0);
- const canvasCenterX = canvasWidth / 2;
-
- if (textAlign === "left") {
- return leftMargin - visualRect.left - canvasCenterX;
- }
-
- if (textAlign === "right") {
- return (
- canvasWidth -
- rightMargin -
- (visualRect.left + visualRect.width) -
- canvasCenterX
- );
- }
-
- const availableWidth = canvasWidth - leftMargin - rightMargin;
- const targetCenterX = leftMargin + availableWidth / 2;
- return (
- targetCenterX - (visualRect.left + visualRect.width / 2) - canvasCenterX
- );
-}
-
-function resolvePositionY({
- canvasHeight,
- placement,
- visualRect,
-}: {
- canvasHeight: number;
- placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
- visualRect: { top: number; height: number };
-}): number {
- const margin =
- canvasHeight *
- (placement.marginVerticalRatio ?? SUBTITLE_BOTTOM_MARGIN_RATIO);
- const canvasCenterY = canvasHeight / 2;
-
- if (placement.verticalAlign === "top") {
- return margin - visualRect.top - canvasCenterY;
- }
-
- if (placement.verticalAlign === "middle") {
- const targetCenterY = canvasHeight / 2;
- return (
- targetCenterY - (visualRect.top + visualRect.height / 2) - canvasCenterY
- );
- }
-
- return (
- canvasHeight - margin - (visualRect.top + visualRect.height) - canvasCenterY
- );
-}
-
-export function buildSubtitleTextElement({
- index,
- caption,
- canvasSize,
-}: {
- index: number;
- caption: SubtitleCue;
- canvasSize: { width: number; height: number };
-}): CreateTextElement {
- const ctx = createMeasurementContext();
- const style = resolveSubtitleStyle({
- style: caption.style,
- });
- const fontFamily = quoteFontFamily({
- fontFamily: style.fontFamily,
- });
- const fontWeight = style.fontWeight;
- const fontStyle = style.fontStyle === "italic" ? "italic" : "normal";
- const scaledFontSize =
- style.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const maxWidth = resolveTargetWidth({
- canvasWidth: canvasSize.width,
- placement: style.placement,
- });
-
- let content = caption.text;
- let positionX = 0;
- let positionY = 0;
-
- if (ctx) {
- ctx.font = fontString;
- setCanvasLetterSpacing({ ctx, letterSpacingPx: style.letterSpacing });
- content = wrapSubtitleText({
- ctx,
- text: caption.text,
- maxWidth,
- });
- const measurement = measureWrappedTextBlock({
- ctx,
- content,
- canvasHeight: canvasSize.height,
- textAlign: style.textAlign,
- background: style.background,
- fontSize: style.fontSize,
- lineHeight: style.lineHeight,
- });
- positionX = resolvePositionX({
- canvasWidth: canvasSize.width,
- textAlign: style.textAlign,
- placement: style.placement,
- visualRect: measurement.visualRect,
- });
- positionY = resolvePositionY({
- canvasHeight: canvasSize.height,
- placement: style.placement,
- visualRect: measurement.visualRect,
- });
- }
-
- return {
- ...DEFAULTS.text.element,
- name: `Caption ${index + 1}`,
- content,
- duration: Math.round(caption.duration * TICKS_PER_SECOND),
- startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
- fontSize: style.fontSize,
- fontFamily: style.fontFamily,
- color: style.color,
- textAlign: style.textAlign,
- fontWeight: style.fontWeight,
- fontStyle: style.fontStyle,
- textDecoration: style.textDecoration,
- letterSpacing: style.letterSpacing,
- lineHeight: style.lineHeight,
- background: style.background,
- transform: {
- ...DEFAULTS.text.element.transform,
- position: {
- x: positionX,
- y: positionY,
- },
- },
- };
-}
diff --git a/apps/web/src/lib/subtitles/insert.ts b/apps/web/src/lib/subtitles/insert.ts
deleted file mode 100644
index 1bb04fc8..00000000
--- a/apps/web/src/lib/subtitles/insert.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { EditorCore } from "@/core";
-import {
- AddTrackCommand,
- BatchCommand,
- InsertElementCommand,
-} from "@/lib/commands";
-import { buildSubtitleTextElement } from "./build-subtitle-text-element";
-import type { SubtitleCue } from "./types";
-
-export function insertCaptionChunksAsTextTrack({
- editor,
- captions,
-}: {
- editor: EditorCore;
- captions: SubtitleCue[];
-}): string | null {
- if (captions.length === 0) {
- return null;
- }
-
- const addTrackCommand = new AddTrackCommand("text", 0);
- const trackId = addTrackCommand.getTrackId();
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const insertCommands = captions.map(
- (caption, index) =>
- new InsertElementCommand({
- placement: { mode: "explicit", trackId },
- element: buildSubtitleTextElement({
- index,
- caption,
- canvasSize,
- }),
- }),
- );
- editor.command.execute({
- command: new BatchCommand([addTrackCommand, ...insertCommands]),
- });
-
- return trackId;
-}
diff --git a/apps/web/src/lib/subtitles/parse.ts b/apps/web/src/lib/subtitles/parse.ts
deleted file mode 100644
index 7a2d2342..00000000
--- a/apps/web/src/lib/subtitles/parse.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { parseAss } from "./ass";
-import { parseSrt } from "./srt";
-import type { ParseSubtitleResult } from "./types";
-export type { ParseSubtitleResult, SubtitleCue } from "./types";
-
-export function parseSubtitleFile({
- fileName,
- input,
-}: {
- fileName: string;
- input: string;
-}): ParseSubtitleResult {
- const extension = getFileExtension({ fileName });
-
- switch (extension) {
- case "srt":
- return parseSrt({ input });
- case "ass":
- return parseAss({ input });
- default:
- throw new Error("Unsupported subtitle format");
- }
-}
-
-function getFileExtension({ fileName }: { fileName: string }): string {
- const extension = fileName.split(".").pop();
- return extension?.toLowerCase() ?? "";
-}
diff --git a/apps/web/src/lib/subtitles/srt.ts b/apps/web/src/lib/subtitles/srt.ts
deleted file mode 100644
index 197e96f9..00000000
--- a/apps/web/src/lib/subtitles/srt.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { ParseSubtitleResult, SubtitleCue } from "./types";
-
-const TIMESTAMP_SEPARATOR = /\s*-->\s*/;
-const TIMESTAMP_PATTERN =
- /^(\d{2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{1,3})/;
-
-export function parseSrt({ input }: { input: string }): ParseSubtitleResult {
- const normalized = input.replace(/\r\n?/g, "\n").trim();
- if (!normalized) {
- return {
- captions: [],
- skippedCueCount: 0,
- warnings: [],
- };
- }
-
- const blocks = normalized.split(/\n{2,}/);
- const cues: SubtitleCue[] = [];
- let skippedCueCount = 0;
-
- for (const block of blocks) {
- const lines = block
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.length > 0);
-
- if (lines.length < 2) {
- skippedCueCount += 1;
- continue;
- }
-
- const timestampIndex = TIMESTAMP_SEPARATOR.test(lines[0]) ? 0 : 1;
- const timestampLine = lines[timestampIndex];
- if (!timestampLine || !TIMESTAMP_PATTERN.test(timestampLine)) {
- skippedCueCount += 1;
- continue;
- }
-
- const textLines = lines.slice(timestampIndex + 1);
- const text = textLines.join("\n").trim();
- if (!text) {
- skippedCueCount += 1;
- continue;
- }
-
- const [rawStart, rawEnd] = timestampLine.split(TIMESTAMP_SEPARATOR);
- if (!rawStart || !rawEnd) {
- skippedCueCount += 1;
- continue;
- }
-
- const startTime = parseSrtTimestamp({ input: rawStart });
- const endTime = parseSrtTimestamp({ input: rawEnd });
- const duration = endTime - startTime;
-
- if (
- !Number.isFinite(startTime) ||
- !Number.isFinite(endTime) ||
- duration <= 0
- ) {
- skippedCueCount += 1;
- continue;
- }
-
- cues.push({
- text,
- startTime,
- duration,
- });
- }
-
- return {
- captions: cues,
- skippedCueCount,
- warnings: [],
- };
-}
-
-function parseSrtTimestamp({ input }: { input: string }): number {
- const normalized = input.trim().replace(",", ".");
- const match = normalized.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{1,3})$/);
- if (!match) {
- return Number.NaN;
- }
-
- const [, hours, minutes, seconds, milliseconds] = match;
- const parsedHours = Number.parseInt(hours, 10);
- const parsedMinutes = Number.parseInt(minutes, 10);
- const parsedSeconds = Number.parseInt(seconds, 10);
- const parsedMilliseconds = Number.parseInt(milliseconds.padEnd(3, "0"), 10);
-
- return (
- parsedHours * 3600 +
- parsedMinutes * 60 +
- parsedSeconds +
- parsedMilliseconds / 1000
- );
-}
diff --git a/apps/web/src/lib/subtitles/types.ts b/apps/web/src/lib/subtitles/types.ts
deleted file mode 100644
index 86f6525d..00000000
--- a/apps/web/src/lib/subtitles/types.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import type { CaptionChunk } from "@/lib/transcription/types";
-
-export interface SubtitlePlacementStyle {
- verticalAlign?: "top" | "middle" | "bottom";
- marginLeftRatio?: number;
- marginRightRatio?: number;
- marginVerticalRatio?: number;
-}
-
-export interface SubtitleStyleOverrides {
- /**
- * Font size in app units (same coordinate space as TextElement.fontSize).
- * Use fontSizeRatioOfPlayHeight when the source coordinate space is unknown
- * (e.g. ASS files, where font size is relative to the script's play resolution).
- */
- fontSize?: number;
- /**
- * Font size expressed as a fraction of the reference canvas height.
- * Set by the ASS parser so the builder can convert to app units without
- * the parser needing to know about the app's coordinate system.
- * Takes precedence over fontSize when both are present.
- */
- fontSizeRatioOfPlayHeight?: number;
- fontFamily?: string;
- color?: string;
- background?: Pick<TextBackground, "enabled" | "color"> &
- Partial<Omit<TextBackground, "enabled" | "color">>;
- textAlign?: TextElement["textAlign"];
- fontWeight?: TextElement["fontWeight"];
- fontStyle?: TextElement["fontStyle"];
- textDecoration?: TextElement["textDecoration"];
- letterSpacing?: number;
- lineHeight?: number;
- placement?: SubtitlePlacementStyle;
-}
-
-export interface SubtitleCue extends CaptionChunk {
- style?: SubtitleStyleOverrides;
-}
-
-export interface ParseSubtitleResult {
- captions: SubtitleCue[];
- skippedCueCount: number;
- warnings: string[];
-}
diff --git a/apps/web/src/lib/text/background.ts b/apps/web/src/lib/text/background.ts
deleted file mode 100644
index be5ab6f5..00000000
--- a/apps/web/src/lib/text/background.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const CORNER_RADIUS_MIN = 0;
-export const CORNER_RADIUS_MAX = 100;
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
deleted file mode 100644
index a3badfdc..00000000
--- a/apps/web/src/lib/text/layout.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-
-type TextRect = {
- left: number;
- top: number;
- width: number;
- height: number;
-};
-
-export interface TextBlockMeasurement {
- visualCenterOffset: number;
- height: number;
- maxWidth: number;
-}
-
-export type TextCanvasContext =
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D;
-
-const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
-const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
-
-export function setCanvasLetterSpacing({
- ctx,
- letterSpacingPx,
-}: {
- ctx: TextCanvasContext;
- letterSpacingPx: number;
-}): void {
- if ("letterSpacing" in ctx) {
- (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
- `${letterSpacingPx}px`;
- }
-}
-
-export function getMetricAscent({
- metrics,
- fallbackFontSize,
-}: {
- metrics: TextMetrics;
- fallbackFontSize: number;
-}): number {
- return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
-}
-
-export function getMetricDescent({
- metrics,
- fallbackFontSize,
-}: {
- metrics: TextMetrics;
- fallbackFontSize: number;
-}): number {
- return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
-}
-
-export function measureTextBlock({
- lineMetrics,
- lineHeightPx,
-}: {
- lineMetrics: TextMetrics[];
- lineHeightPx: number;
-}): TextBlockMeasurement {
- let maxWidth = 0;
-
- for (const metrics of lineMetrics) {
- maxWidth = Math.max(maxWidth, metrics.width);
- }
-
- const lineCount = lineMetrics.length;
- const height = lineCount * lineHeightPx;
- const visualCenterOffset = ((lineCount - 1) * lineHeightPx) / 2;
-
- return { visualCenterOffset, height, maxWidth };
-}
-
-function getTextRect({
- textAlign,
- block,
-}: {
- textAlign: TextElement["textAlign"];
- block: TextBlockMeasurement;
-}): TextRect {
- const textAlignToLeft: Record<typeof textAlign, number> = {
- left: 0,
- right: -block.maxWidth,
- center: -block.maxWidth / 2,
- };
- const left = textAlignToLeft[textAlign];
-
- return {
- left,
- top: -block.height / 2,
- width: block.maxWidth,
- height: block.height,
- };
-}
-
-function isTextBackgroundVisible({
- background,
-}: {
- background: TextBackground;
-}): boolean {
- return (
- background.enabled &&
- Boolean(background.color) &&
- background.color !== "transparent"
- );
-}
-
-export function getTextBackgroundRect({
- textAlign,
- block,
- background,
- fontSizeRatio = 1,
-}: {
- textAlign: TextElement["textAlign"];
- block: TextBlockMeasurement;
- background: TextBackground;
- fontSizeRatio?: number;
-}): TextRect | null {
- if (!isTextBackgroundVisible({ background })) {
- return null;
- }
-
- const textRect = getTextRect({ textAlign, block });
- const paddingX =
- (background.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio;
- const paddingY =
- (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,
- top: textRect.top - paddingY + offsetY,
- width: textRect.width + paddingX * 2,
- height: textRect.height + paddingY * 2,
- };
-}
-
-export function getTextVisualRect({
- textAlign,
- block,
- background,
- fontSizeRatio = 1,
-}: {
- textAlign: TextElement["textAlign"];
- block: TextBlockMeasurement;
- background: TextBackground;
- fontSizeRatio?: number;
-}): TextRect {
- const textRect = getTextRect({ textAlign, block });
- const backgroundRect = getTextBackgroundRect({
- textAlign,
- block,
- background,
- fontSizeRatio,
- });
-
- if (!backgroundRect) {
- return textRect;
- }
-
- const left = Math.min(textRect.left, backgroundRect.left);
- const top = Math.min(textRect.top, backgroundRect.top);
- const right = Math.max(
- textRect.left + textRect.width,
- backgroundRect.left + backgroundRect.width,
- );
- const bottom = Math.max(
- textRect.top + textRect.height,
- backgroundRect.top + backgroundRect.height,
- );
-
- return {
- left,
- top,
- width: right - left,
- height: bottom - top,
- };
-}
-
-export function drawTextDecoration({
- ctx,
- textDecoration,
- lineWidth,
- lineY,
- metrics,
- scaledFontSize,
- textAlign,
-}: {
- ctx: TextCanvasContext;
- textDecoration: string;
- lineWidth: number;
- lineY: number;
- metrics: TextMetrics;
- scaledFontSize: number;
- textAlign: CanvasTextAlign;
-}): void {
- if (textDecoration === "none" || !textDecoration) return;
-
- const thickness = Math.max(
- 1,
- scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO,
- );
- const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
- const descent = getMetricDescent({
- metrics,
- fallbackFontSize: scaledFontSize,
- });
-
- let xStart = -lineWidth / 2;
- if (textAlign === "left") xStart = 0;
- if (textAlign === "right") xStart = -lineWidth;
-
- if (textDecoration === "underline") {
- const underlineY = lineY + descent + thickness;
- ctx.fillRect(xStart, underlineY, lineWidth, thickness);
- }
-
- if (textDecoration === "line-through") {
- const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
- ctx.fillRect(xStart, strikeY, lineWidth, thickness);
- }
-}
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts
deleted file mode 100644
index 0b107587..00000000
--- a/apps/web/src/lib/text/measure-element.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { CORNER_RADIUS_MIN } from "@/lib/text/background";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import {
- getTextVisualRect,
-} from "./layout";
-import {
- measureTextLayout,
- type MeasuredTextLayout,
-} from "./primitives";
-
-export interface ResolvedTextBackground extends TextBackground {
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export interface MeasuredTextElement extends MeasuredTextLayout {
- resolvedBackground: ResolvedTextBackground;
- visualRect: { left: number; top: number; width: number; height: number };
-}
-
-let textMeasurementContext:
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null = null;
-
-export function getTextMeasurementContext():
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D {
- if (textMeasurementContext) {
- return textMeasurementContext;
- }
-
- if (typeof OffscreenCanvas !== "undefined") {
- const canvas = new OffscreenCanvas(1, 1);
- const context = canvas.getContext("2d");
- if (context) {
- textMeasurementContext = context;
- return context;
- }
- }
-
- if (typeof document !== "undefined") {
- const canvas = document.createElement("canvas");
- const context = canvas.getContext("2d");
- if (context) {
- textMeasurementContext = context;
- return context;
- }
- }
-
- throw new Error("Failed to create text measurement context");
-}
-
-export function measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
-}: {
- element: TextElement;
- canvasHeight: number;
- localTime: number;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
-}): MeasuredTextElement {
- const measuredLayout = measureTextLayout({
- text: {
- content: element.content,
- fontSize: element.fontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight,
- fontStyle: element.fontStyle,
- textAlign: element.textAlign,
- textDecoration: element.textDecoration,
- letterSpacing: element.letterSpacing,
- lineHeight: element.lineHeight,
- },
- canvasHeight,
- ctx,
- });
-
- 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: measuredLayout.block,
- background: resolvedBackground,
- fontSizeRatio: measuredLayout.fontSizeRatio,
- });
-
- return {
- ...measuredLayout,
- resolvedBackground,
- visualRect,
- };
-}
diff --git a/apps/web/src/lib/text/primitives.ts b/apps/web/src/lib/text/primitives.ts
deleted file mode 100644
index 20674c3c..00000000
--- a/apps/web/src/lib/text/primitives.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-import type { TextCanvasContext, TextBlockMeasurement } from "@/lib/text/layout";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { clamp } from "@/utils/math";
-import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
-import {
- drawTextDecoration,
- getTextBackgroundRect,
- measureTextBlock,
- setCanvasLetterSpacing,
-} from "./layout";
-import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
-
-export type TextAlign = "left" | "center" | "right";
-export type TextFontWeight = "normal" | "bold";
-export type TextFontStyle = "normal" | "italic";
-export type TextDecoration = "none" | "underline" | "line-through";
-
-export interface TextLayoutParams {
- content: string;
- fontSize: number;
- fontFamily: string;
- fontWeight: TextFontWeight;
- fontStyle: TextFontStyle;
- textAlign: TextAlign;
- textDecoration?: TextDecoration;
- letterSpacing?: number;
- lineHeight?: number;
-}
-
-export interface ResolvedTextLayout {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- fontSizeRatio: number;
- textAlign: TextAlign;
- textDecoration: TextDecoration;
-}
-
-export interface MeasuredTextLayout extends ResolvedTextLayout {
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
-}
-
-export interface ResolvedTextBackgroundLike {
- enabled: boolean;
- color: string;
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
- return `"${fontFamily.replace(/"/g, '\\"')}"`;
-}
-
-export function buildTextFontString({
- fontFamily,
- fontWeight,
- fontStyle,
- scaledFontSize,
-}: {
- fontFamily: string;
- fontWeight: TextFontWeight;
- fontStyle: TextFontStyle;
- scaledFontSize: number;
-}): string {
- return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
-}
-
-export function resolveTextLayout({
- text,
- canvasHeight,
-}: {
- text: TextLayoutParams;
- canvasHeight: number;
-}): ResolvedTextLayout {
- const scaledFontSize =
- text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
- const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
- const lineHeightPx =
- scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
-
- return {
- scaledFontSize,
- fontString: buildTextFontString({
- fontFamily: text.fontFamily,
- fontWeight,
- fontStyle,
- scaledFontSize,
- }),
- letterSpacing,
- lineHeightPx,
- fontSizeRatio,
- textAlign: text.textAlign,
- textDecoration: text.textDecoration ?? "none",
- };
-}
-
-export function measureTextLayout({
- text,
- canvasHeight,
- ctx,
-}: {
- text: TextLayoutParams;
- canvasHeight: number;
- ctx: TextCanvasContext;
-}): MeasuredTextLayout {
- const resolvedLayout = resolveTextLayout({ text, canvasHeight });
- const lines = text.content.split("\n");
-
- ctx.save();
- ctx.font = resolvedLayout.fontString;
- ctx.textBaseline = "middle";
- setCanvasLetterSpacing({
- ctx,
- letterSpacingPx: resolvedLayout.letterSpacing,
- });
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- ctx.restore();
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx: resolvedLayout.lineHeightPx,
- });
-
- return {
- ...resolvedLayout,
- lines,
- lineMetrics,
- block,
- };
-}
-
-export function drawMeasuredTextLayout({
- ctx,
- layout,
- textColor,
- background,
- backgroundColor,
- textBaseline = "middle",
-}: {
- ctx: TextCanvasContext;
- layout: MeasuredTextLayout;
- textColor: string;
- background?: ResolvedTextBackgroundLike | null;
- backgroundColor?: string;
- textBaseline?: CanvasTextBaseline;
-}): void {
- ctx.font = layout.fontString;
- ctx.textAlign = layout.textAlign;
- ctx.textBaseline = textBaseline;
- ctx.fillStyle = textColor;
- setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
-
- if (
- background?.enabled &&
- backgroundColor &&
- backgroundColor !== "transparent" &&
- layout.lines.length > 0
- ) {
- const backgroundRect = getTextBackgroundRect({
- textAlign: layout.textAlign,
- block: layout.block,
- background: {
- ...background,
- color: backgroundColor,
- },
- fontSizeRatio: layout.fontSizeRatio,
- });
- if (backgroundRect) {
- const p =
- clamp({
- value: background.cornerRadius,
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- }) / 100;
- const radius =
- (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
- ctx.fillStyle = backgroundColor;
- ctx.beginPath();
- ctx.roundRect(
- backgroundRect.left,
- backgroundRect.top,
- backgroundRect.width,
- backgroundRect.height,
- radius,
- );
- ctx.fill();
- ctx.fillStyle = textColor;
- }
- }
-
- for (let index = 0; index < layout.lines.length; index++) {
- const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
- ctx.fillText(layout.lines[index], 0, lineY);
- drawTextDecoration({
- ctx,
- textDecoration: layout.textDecoration,
- lineWidth: layout.lineMetrics[index].width,
- lineY,
- metrics: layout.lineMetrics[index],
- scaledFontSize: layout.scaledFontSize,
- textAlign: layout.textAlign,
- });
- }
-}
-
-export function strokeMeasuredTextLayout({
- ctx,
- layout,
- strokeColor,
- strokeWidth,
- textBaseline = "middle",
-}: {
- ctx: TextCanvasContext;
- layout: MeasuredTextLayout;
- strokeColor: string;
- strokeWidth: number;
- textBaseline?: CanvasTextBaseline;
-}): void {
- ctx.font = layout.fontString;
- ctx.textAlign = layout.textAlign;
- ctx.textBaseline = textBaseline;
- ctx.strokeStyle = strokeColor;
- ctx.lineWidth = strokeWidth;
- ctx.lineJoin = "round";
- ctx.lineCap = "round";
- setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
-
- for (let index = 0; index < layout.lines.length; index++) {
- const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
- ctx.strokeText(layout.lines[index], 0, lineY);
- }
-}
diff --git a/apps/web/src/lib/text/typography.ts b/apps/web/src/lib/text/typography.ts
deleted file mode 100644
index 837236ab..00000000
--- a/apps/web/src/lib/text/typography.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export const MIN_FONT_SIZE = 5;
-export const MAX_FONT_SIZE = 300;
-export const DEFAULT_TEXT_COLOR = "#000000";
-
-/**
- * higher value: smaller font size
- * lower value: larger font size
- */
-export const FONT_SIZE_SCALE_REFERENCE = 90;
diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts
deleted file mode 100644
index f5bb8a3e..00000000
--- a/apps/web/src/lib/timeline/audio-constants.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const VOLUME_DB_MIN = -60;
-export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/lib/timeline/audio-display.ts b/apps/web/src/lib/timeline/audio-display.ts
deleted file mode 100644
index 9958c472..00000000
--- a/apps/web/src/lib/timeline/audio-display.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { clampDb } from "./audio-state";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
-
-const SLIDER_CURVE_EXPONENT = 2;
-const MIN_DISPLAY_DB = -40;
-const WAVEFORM_BAR_EXPONENT = 1.5;
-const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
-const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
-const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
-
-function getNormalizedGainFromDb({ db }: { db: number }): number {
- const clampedDb = clampDb(db);
- const linearGain = 10 ** (clampedDb / 20);
- return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
-}
-
-/**
- * Maps the clip's volume setting to the line position. The curve is defined in
- * linear gain space so dragging near 0 dB is precise while mute compresses into
- * the bottom of the clip.
- */
-export function getLinePosFromDb({ db }: { db: number }): number {
- const normalizedGain = Math.max(
- 0,
- Math.min(1, getNormalizedGainFromDb({ db })),
- );
- const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
- return (1 - progress) * 100;
-}
-
-/**
- * Inverse of getLinePosFromDb. Converts a drag position back into the clip's
- * volume setting without depending on the underlying audio content.
- */
-export function getDbFromLinePos({ percent }: { percent: number }): number {
- const clampedPercent = Math.max(0, Math.min(100, percent));
- const progress = 1 - clampedPercent / 100;
- const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
- const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
- return clampDb(20 * Math.log10(linearGain));
-}
-
-/**
- * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
- * height fraction using a dB scale. Agnostic to whether the input is peak or
- * RMS — the caller decides which measure feeds this function; the mapping
- * curve is the same either way.
- *
- * The log scale keeps quiet content visible while reserving the top of the
- * element for amplitudes that approach 0 dBFS.
- */
-export function getBarFractionFromOutputAmplitude({
- outputAmplitude,
-}: {
- outputAmplitude: number;
-}): number {
- if (outputAmplitude <= 0) return 0;
- const db = 20 * Math.log10(outputAmplitude);
- if (db <= MIN_DISPLAY_DB) return 0;
- return Math.min(
- 1,
- ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
- );
-}
diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts
deleted file mode 100644
index 849dbda3..00000000
--- a/apps/web/src/lib/timeline/audio-separation/index.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { cloneAnimations } from "@/lib/animation";
-import type { ElementAnimations } from "@/lib/animation/types";
-import type { MediaAsset } from "@/lib/media/types";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type {
- CreateUploadAudioElement,
- TimelineElement,
- AudioElement,
- VideoElement,
-} from "../types";
-
-type MediaAudioState = Pick<MediaAsset, "hasAudio">;
-
-export function isSourceAudioEnabled({
- element,
-}: {
- element: VideoElement;
-}): boolean {
- return element.isSourceAudioEnabled !== false;
-}
-
-export function isSourceAudioSeparated({
- element,
-}: {
- element: VideoElement;
-}): boolean {
- return !isSourceAudioEnabled({ element });
-}
-
-export function canExtractSourceAudio(
- element: TimelineElement,
- mediaAsset: MediaAudioState | null | undefined,
-): element is VideoElement {
- return (
- element.type === "video" &&
- isSourceAudioEnabled({ element }) &&
- !!mediaAsset &&
- mediaAsset.hasAudio !== false
- );
-}
-
-export function canRecoverSourceAudio(
- element: TimelineElement,
-): element is VideoElement {
- return element.type === "video" && isSourceAudioSeparated({ element });
-}
-
-export function canToggleSourceAudio(
- element: TimelineElement,
- mediaAsset: MediaAudioState | null | undefined,
-): element is VideoElement {
- return (
- canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset)
- );
-}
-
-export function doesElementHaveEnabledAudio({
- element,
- mediaAsset,
-}: {
- element: AudioElement | VideoElement;
- mediaAsset?: MediaAudioState | null;
-}): boolean {
- if (element.type === "audio") {
- return true;
- }
-
- return (
- !!mediaAsset &&
- mediaAsset.hasAudio !== false &&
- isSourceAudioEnabled({ element })
- );
-}
-
-export function buildSeparatedAudioElement({
- sourceElement,
-}: {
- sourceElement: VideoElement;
-}): CreateUploadAudioElement {
- return {
- type: "audio",
- sourceType: "upload",
- mediaId: sourceElement.mediaId,
- name: sourceElement.name,
- duration: sourceElement.duration,
- startTime: sourceElement.startTime,
- trimStart: sourceElement.trimStart,
- trimEnd: sourceElement.trimEnd,
- sourceDuration: sourceElement.sourceDuration,
- volume: sourceElement.volume ?? DEFAULTS.element.volume,
- muted: sourceElement.muted ?? false,
- retime: sourceElement.retime
- ? {
- rate: sourceElement.retime.rate,
- maintainPitch: sourceElement.retime.maintainPitch,
- }
- : undefined,
- animations: cloneVolumeAnimations({
- animations: sourceElement.animations,
- }),
- };
-}
-
-export function getSourceAudioActionLabel({
- element,
-}: {
- element: VideoElement;
-}): "Extract audio" | "Recover audio" {
- return isSourceAudioSeparated({ element })
- ? "Recover audio"
- : "Extract audio";
-}
-
-function cloneVolumeAnimations({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementAnimations | undefined {
- const volumeBinding = animations?.bindings.volume;
- if (!volumeBinding) {
- return undefined;
- }
-
- const subsetChannels = Object.fromEntries(
- volumeBinding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
- return channel ? [[component.channelId, channel] as const] : [];
- }),
- );
- if (Object.keys(subsetChannels).length === 0) {
- return undefined;
- }
-
- return cloneAnimations({
- animations: {
- bindings: {
- volume: volumeBinding,
- },
- channels: subsetChannels,
- },
- shouldRegenerateKeyframeIds: true,
- });
-}
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts
deleted file mode 100644
index 165ece94..00000000
--- a/apps/web/src/lib/timeline/audio-state.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
-import { resolveNumberAtTime } from "@/lib/animation/resolve";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
-import type { TimelineElement } from "./types";
-const DEFAULT_STEP_SECONDS = 1 / 60;
-
-export type AudioCapableElement = Extract<
- TimelineElement,
- { type: "audio" | "video" }
->;
-
-export function clampDb(value: number): number {
- if (!Number.isFinite(value)) {
- return 0;
- }
-
- return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
-}
-
-export function dBToLinear(db: number): number {
- return 10 ** (clampDb(db) / 20);
-}
-
-export function hasAnimatedVolume({
- element,
-}: {
- element: AudioCapableElement;
-}): boolean {
- return hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "volume",
- });
-}
-
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-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: Math.round(localTime * TICKS_PER_SECOND),
- });
-
- return dBToLinear(resolvedDb);
-}
-
-export function buildWaveformGainSamples({
- element,
- count,
-}: {
- element: AudioCapableElement;
- count: number;
-}): number[] {
- const durationSeconds = element.duration / TICKS_PER_SECOND;
- return Array.from({ length: count }, (_, i) => {
- const localTime = ((i + 0.5) / count) * durationSeconds;
- return resolveEffectiveAudioGain({ element, localTime });
- });
-}
-
-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
deleted file mode 100644
index 1fbf17bf..00000000
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { Bookmark } from "@/lib/timeline";
-import type { FrameRate } from "opencut-wasm";
-import { roundToFrame } from "opencut-wasm";
-
-function bookmarkTimeEqual({
- bookmarkTime,
- frameTime,
-}: {
- bookmarkTime: number;
- frameTime: number;
-}): boolean {
- return bookmarkTime === frameTime;
-}
-
-export function findBookmarkIndex({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): number {
- return bookmarks.findIndex((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function isBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): boolean {
- return bookmarks.some((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function toggleBookmarkInArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
-
- if (bookmarkIndex !== -1) {
- return bookmarks.filter((_, index) => index !== bookmarkIndex);
- }
-
- const newBookmarks = [...bookmarks, { time: frameTime }];
- return newBookmarks.slice().sort((a, b) => a.time - b.time);
-}
-
-export function removeBookmarkFromArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- return bookmarks.filter(
- (bookmark) =>
- !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function updateBookmarkInArray({
- bookmarks,
- frameTime,
- updates,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
- updates: Partial<Omit<Bookmark, "time">>;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], ...updates };
- const result = [...bookmarks];
- result[index] = updated;
- return result;
-}
-
-export function moveBookmarkInArray({
- bookmarks,
- fromTime,
- toTime,
-}: {
- bookmarks: Bookmark[];
- fromTime: number;
- toTime: number;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], time: toTime };
- const result = [...bookmarks];
- result[index] = updated;
- return result.slice().sort((a, b) => a.time - b.time);
-}
-
-export function getFrameTime({
- time,
- fps,
-}: {
- time: number;
- fps: FrameRate;
-}): number {
- return roundToFrame({ time, rate: fps }) ?? time;
-}
-
-export function getBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark | null {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- return index === -1 ? null : bookmarks[index];
-}
-
-export function getBookmarksActiveAtTime({
- bookmarks,
- time,
-}: {
- bookmarks: Bookmark[];
- time: number;
-}): Bookmark[] {
- return bookmarks.filter((bookmark) => {
- const start = bookmark.time;
- const end =
- bookmark.duration != null && bookmark.duration > 0
- ? start + bookmark.duration
- : start;
- return time >= start && time <= end;
- });
-}
diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts
deleted file mode 100644
index 19f46115..00000000
--- a/apps/web/src/lib/timeline/creation.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
-
-export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts
deleted file mode 100644
index cf452800..00000000
--- a/apps/web/src/lib/timeline/defaults.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import type { TTimelineViewState } from "@/lib/project/types";
-import type { BlendMode, Transform } from "@/lib/rendering";
-import type { TextElement } from "./types";
-
-const defaultTransform: Transform = {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
-};
-
-const defaultOpacity = 1;
-const defaultBlendMode: BlendMode = "normal";
-const defaultVolume = 0;
-
-const defaultTextLetterSpacing = 0;
-const defaultTextLineHeight = 1.2;
-
-const defaultTextBackground = {
- enabled: false,
- color: "#000000",
- cornerRadius: 0,
- paddingX: 30,
- paddingY: 42,
- offsetX: 0,
- offsetY: 0,
-};
-
-const defaultTextElement: Omit<TextElement, "id"> = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: { ...defaultTextBackground },
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
- duration: DEFAULT_NEW_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...defaultTransform,
- position: { ...defaultTransform.position },
- },
- opacity: defaultOpacity,
-};
-
-const defaultTimelineViewState: TTimelineViewState = {
- zoomLevel: 1,
- scrollLeft: 0,
- playheadTime: 0,
-};
-
-export const DEFAULTS = {
- element: {
- transform: defaultTransform,
- opacity: defaultOpacity,
- blendMode: defaultBlendMode,
- volume: defaultVolume,
- },
- text: {
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
- background: defaultTextBackground,
- element: defaultTextElement,
- },
- timeline: {
- viewState: defaultTimelineViewState,
- },
-};
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts
deleted file mode 100644
index 35d00613..00000000
--- a/apps/web/src/lib/timeline/drag-utils.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-export function getMouseTimeFromClientX({
- clientX,
- containerRect,
- zoomLevel,
- scrollLeft,
-}: {
- clientX: number;
- containerRect: DOMRect;
- zoomLevel: number;
- scrollLeft: number;
-}): number {
- const mouseX = clientX - containerRect.left + scrollLeft;
- const seconds = Math.max(
- 0,
- mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- );
- return Math.round(seconds * TICKS_PER_SECOND);
-}
diff --git a/apps/web/src/lib/timeline/drag.ts b/apps/web/src/lib/timeline/drag.ts
deleted file mode 100644
index 6e74e17c..00000000
--- a/apps/web/src/lib/timeline/drag.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { MaskableElement, VisualElement } from "./types";
-import type { ParamValues } from "@/lib/params";
-
-interface BaseDragData {
- id: string;
- name: string;
-}
-
-export interface MediaDragData extends BaseDragData {
- type: "media";
- mediaType: "image" | "video" | "audio";
- targetElementTypes?: MaskableElement["type"][];
-}
-
-export interface TextDragData extends BaseDragData {
- type: "text";
- content: string;
-}
-
-export interface StickerDragData extends BaseDragData {
- type: "sticker";
- stickerId: string;
-}
-
-export interface GraphicDragData extends BaseDragData {
- type: "graphic";
- definitionId: string;
- params: Partial<ParamValues>;
-}
-
-export interface EffectDragData extends BaseDragData {
- type: "effect";
- effectType: string;
- targetElementTypes: VisualElement["type"][];
-}
-
-export type TimelineDragData =
- | MediaDragData
- | TextDragData
- | StickerDragData
- | GraphicDragData
- | EffectDragData;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
deleted file mode 100644
index e7ec4065..00000000
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ /dev/null
@@ -1,426 +0,0 @@
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import {
- MASKABLE_ELEMENT_TYPES,
- RETIMABLE_ELEMENT_TYPES,
- VISUAL_ELEMENT_TYPES,
- type CreateEffectElement,
- type CreateGraphicElement,
- type CreateTimelineElement,
- type CreateVideoElement,
- type CreateImageElement,
- type CreateStickerElement,
- type CreateUploadAudioElement,
- type CreateLibraryAudioElement,
- type TextBackground,
- type TextElement,
- type SceneTracks,
- type TimelineElement,
- type AudioElement,
- type VideoElement,
- type ImageElement,
- type MaskableElement,
- type RetimableElement,
- type VisualElement,
- type UploadAudioElement,
-} from "@/lib/timeline";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { MediaType } from "@/lib/media/types";
-import { buildDefaultEffectInstance } from "@/lib/effects";
-import { buildDefaultGraphicInstance } from "@/lib/graphics";
-import type { ParamValues } from "@/lib/params";
-import { capitalizeFirstLetter } from "@/utils/string";
-
-export function canElementHaveAudio(
- element: TimelineElement,
-): element is AudioElement | VideoElement {
- return element.type === "audio" || element.type === "video";
-}
-
-export function isVisualElement(
- element: TimelineElement,
-): element is VisualElement {
- return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function isMaskableElement(
- element: TimelineElement,
-): element is MaskableElement {
- return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function isRetimableElement(
- element: TimelineElement,
-): element is RetimableElement {
- return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function canElementBeHidden(
- element: TimelineElement,
-): element is VisualElement {
- return isVisualElement(element);
-}
-
-export function hasElementEffects({
- element,
-}: {
- element: TimelineElement;
-}): boolean {
- return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
-}
-
-export function hasMediaId(
- element: TimelineElement,
-): element is UploadAudioElement | VideoElement | ImageElement {
- return "mediaId" in element;
-}
-
-export function requiresMediaId({
- element,
-}: {
- element: CreateTimelineElement;
-}): boolean {
- return (
- element.type === "video" ||
- element.type === "image" ||
- (element.type === "audio" && element.sourceType === "upload")
- );
-}
-
-function buildTextBackground(
- raw: Partial<TextBackground> | undefined,
-): TextBackground {
- const color = raw?.color ?? DEFAULTS.text.element.background.color;
- const enabled = raw?.enabled ?? color !== "transparent";
- return {
- enabled,
- color,
- cornerRadius: raw?.cornerRadius,
- paddingX: raw?.paddingX,
- paddingY: raw?.paddingY,
- offsetX: raw?.offsetX,
- offsetY: raw?.offsetY,
- };
-}
-
-export function buildTextElement({
- raw,
- startTime,
-}: {
- raw: Partial<Omit<TextElement, "type" | "id">>;
- startTime: number;
-}): CreateTimelineElement {
- const t = raw as Partial<TextElement>;
-
- return {
- type: "text",
- name: t.name ?? DEFAULTS.text.element.name,
- content: t.content ?? DEFAULTS.text.element.content,
- duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
- fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
- color: t.color ?? DEFAULTS.text.element.color,
- background: buildTextBackground(t.background),
- textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
- fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
- fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
- letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
- lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
- transform: t.transform ?? DEFAULTS.text.element.transform,
- opacity: t.opacity ?? DEFAULTS.text.element.opacity,
- blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
- };
-}
-
-export function buildEffectElement({
- effectType,
- startTime,
- duration,
-}: {
- effectType: string;
- startTime: number;
- duration?: number;
-}): CreateEffectElement {
- const instance = buildDefaultEffectInstance({ effectType });
- return {
- type: "effect",
- name: capitalizeFirstLetter({ string: instance.type }),
- effectType,
- params: instance.params,
- duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- };
-}
-
-export function buildStickerElement({
- stickerId,
- name,
- startTime,
- intrinsicWidth,
- intrinsicHeight,
-}: {
- stickerId: string;
- name?: string;
- startTime: number;
- intrinsicWidth?: number;
- intrinsicHeight?: number;
-}): CreateStickerElement {
- const stickerNameFromId =
- stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
- return {
- type: "sticker",
- name: name ?? stickerNameFromId,
- stickerId,
- intrinsicWidth,
- intrinsicHeight,
- duration: DEFAULT_NEW_ELEMENT_DURATION,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-export function buildGraphicElement({
- definitionId,
- name,
- startTime,
- params,
-}: {
- definitionId: string;
- name?: string;
- startTime: number;
- params?: Partial<ParamValues>;
-}): CreateGraphicElement {
- const instance = buildDefaultGraphicInstance({ definitionId });
- return {
- type: "graphic",
- name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
- definitionId: instance.definitionId,
- params: { ...instance.params, ...(params ?? {}) } as ParamValues,
- duration: DEFAULT_NEW_ELEMENT_DURATION,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-function buildVideoElement({
- mediaId,
- name,
- duration,
- startTime,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
-}): CreateVideoElement {
- return {
- type: "video",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- muted: false,
- isSourceAudioEnabled: true,
- hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- volume: DEFAULTS.element.volume,
- };
-}
-
-function buildImageElement({
- mediaId,
- name,
- duration,
- startTime,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
-}): CreateImageElement {
- return {
- type: "image",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-function buildUploadAudioElement({
- mediaId,
- name,
- duration,
- startTime,
- buffer,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateUploadAudioElement {
- const element: CreateUploadAudioElement = {
- type: "audio",
- sourceType: "upload",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
- };
- if (buffer) {
- element.buffer = buffer;
- }
- return element;
-}
-
-export function buildElementFromMedia({
- mediaId,
- mediaType,
- name,
- duration,
- startTime,
- buffer,
-}: {
- mediaId: string;
- mediaType: MediaType;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateTimelineElement {
- switch (mediaType) {
- case "audio":
- return buildUploadAudioElement({
- mediaId,
- name,
- duration,
- startTime,
- buffer,
- });
- case "video":
- return buildVideoElement({ mediaId, name, duration, startTime });
- case "image":
- return buildImageElement({ mediaId, name, duration, startTime });
- }
-}
-
-export function buildLibraryAudioElement({
- sourceUrl,
- name,
- duration,
- startTime,
- buffer,
-}: {
- sourceUrl: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateLibraryAudioElement {
- const element: CreateLibraryAudioElement = {
- type: "audio",
- sourceType: "library",
- sourceUrl,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
- };
- if (buffer) {
- element.buffer = buffer;
- }
- return element;
-}
-
-export function getElementsAtTime({
- tracks,
- time,
-}: {
- tracks: SceneTracks;
- time: number;
-}): { trackId: string; elementId: string }[] {
- const result: { trackId: string; elementId: string }[] = [];
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
-
- for (const track of orderedTracks) {
- for (const element of track.elements) {
- const elementStart = element.startTime;
- const elementEnd = element.startTime + element.duration;
-
- if (time > elementStart && time < elementEnd) {
- result.push({ trackId: track.id, elementId: element.id });
- }
- }
- }
-
- return result;
-}
-
-export function getElementFontFamilies({
- tracks,
-}: {
- tracks: SceneTracks;
-}): string[] {
- const families = new Set<string>();
- for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
- for (const element of track.elements) {
- if (element.type === "text" && element.fontFamily) {
- families.add(element.fontFamily);
- }
- if ("masks" in element) {
- for (const mask of element.masks ?? []) {
- if (mask.type === "text" && mask.params.fontFamily) {
- families.add(mask.params.fontFamily);
- }
- }
- }
- }
- }
- return [...families];
-}
diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts
deleted file mode 100644
index 16d720eb..00000000
--- a/apps/web/src/lib/timeline/group-move/build-group.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import type { ElementRef, SceneTracks } from "@/lib/timeline";
-import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
-import type { GroupMember, MoveGroup } from "./types";
-import { getTrackPlacementById } from "./track-placement";
-
-export function buildMoveGroup({
- anchorRef,
- selectedElements,
- tracks,
-}: {
- anchorRef: ElementRef;
- selectedElements: ElementRef[];
- tracks: SceneTracks;
-}): MoveGroup | null {
- const anchorTrack = findTrackInSceneTracks({
- tracks,
- trackId: anchorRef.trackId,
- });
- const anchorElement = anchorTrack?.elements.find(
- (element) => element.id === anchorRef.elementId,
- );
- const anchorPlacement = getTrackPlacementById({
- tracks,
- trackId: anchorRef.trackId,
- });
- if (!anchorTrack || !anchorElement || !anchorPlacement) {
- return null;
- }
-
- const seen = new Set<string>();
- const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => {
- if (seen.has(elementRef.elementId)) {
- return false;
- }
-
- seen.add(elementRef.elementId);
- return true;
- });
-
- const members = orderedRefs.flatMap((elementRef): GroupMember[] => {
- const track = findTrackInSceneTracks({
- tracks,
- trackId: elementRef.trackId,
- });
- const element = track?.elements.find(
- (trackElement) => trackElement.id === elementRef.elementId,
- );
- const placement = getTrackPlacementById({
- tracks,
- trackId: elementRef.trackId,
- });
- if (!track || !element || !placement) {
- return [];
- }
-
- return [
- {
- trackId: track.id,
- elementId: element.id,
- elementType: element.type,
- duration: element.duration,
- timeOffset: element.startTime - anchorElement.startTime,
- trackSection: placement.section,
- sectionIndex: placement.sectionIndex,
- displayIndex: placement.displayIndex,
- },
- ];
- });
-
- if (members.length === 0) {
- return null;
- }
-
- const anchor = members.find(
- (member) =>
- member.trackId === anchorRef.trackId &&
- member.elementId === anchorRef.elementId,
- );
- if (!anchor) {
- return null;
- }
-
- return {
- anchor,
- members,
- };
-}
diff --git a/apps/web/src/lib/timeline/group-move/index.ts b/apps/web/src/lib/timeline/group-move/index.ts
deleted file mode 100644
index 82bd3895..00000000
--- a/apps/web/src/lib/timeline/group-move/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { buildMoveGroup } from "./build-group";
-export { resolveGroupMove } from "./resolve-move";
-export { snapGroupEdges } from "./snap";
-
-export type {
- GroupMember,
- GroupMoveResult,
- GroupTrackSection,
- MoveGroup,
- PlannedElementMove,
- PlannedTrackCreation,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/group-move/resolve-move.ts b/apps/web/src/lib/timeline/group-move/resolve-move.ts
deleted file mode 100644
index fbe4cbda..00000000
--- a/apps/web/src/lib/timeline/group-move/resolve-move.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-import type { SceneTracks } from "@/lib/timeline";
-import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
-import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap";
-import type {
- GroupMoveResult,
- MoveGroup,
- PlannedElementMove,
- PlannedTrackCreation,
-} from "./types";
-import {
- getDisplayTracks,
- getTrackPlacementByDisplayIndex,
- getTrackPlacementById,
-} from "./track-placement";
-
-type GroupMoveTarget =
- | {
- kind: "existingTrack";
- anchorTargetTrackId: string;
- }
- | {
- kind: "newTracks";
- anchorInsertIndex: number;
- newTrackIds: string[];
- };
-
-export function resolveGroupMove({
- group,
- tracks,
- anchorStartTime,
- target,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- target: GroupMoveTarget;
-}): GroupMoveResult | null {
- if (target.kind === "newTracks") {
- return resolveNewTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorInsertIndex: target.anchorInsertIndex,
- newTrackIds: target.newTrackIds,
- });
- }
-
- return resolveExistingTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorTargetTrackId: target.anchorTargetTrackId,
- });
-}
-
-function resolveExistingTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorTargetTrackId,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- anchorTargetTrackId: string;
-}): GroupMoveResult | null {
- const anchorTargetPlacement = getTrackPlacementById({
- tracks,
- trackId: anchorTargetTrackId,
- });
- if (!anchorTargetPlacement) {
- return null;
- }
-
- const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({
- group,
- tracks,
- anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex,
- });
- if (!targetTrackIdsByElementId) {
- return null;
- }
-
- const clampedAnchorStartTime = clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId,
- });
-
- const moves = group.members.map((member) => ({
- sourceTrackId: member.trackId,
- targetTrackId:
- targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
- elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
- }));
-
- if (!canApplyMovesToExistingTracks({ tracks, moves })) {
- return null;
- }
-
- return {
- moves,
- createTracks: [],
- targetSelection: moves.map(({ elementId, targetTrackId }) => ({
- trackId: targetTrackId,
- elementId,
- })),
- };
-}
-
-function resolveNewTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorInsertIndex,
- newTrackIds,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- anchorInsertIndex: number;
- newTrackIds: string[];
-}): GroupMoveResult | null {
- const sortedMembers = [...group.members].sort(
- (leftMember, rightMember) =>
- leftMember.displayIndex - rightMember.displayIndex,
- );
- const anchorMemberIndex = sortedMembers.findIndex(
- (member) => member.elementId === group.anchor.elementId,
- );
- if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) {
- return null;
- }
-
- const hasAudioMember = sortedMembers.some(
- (member) => member.trackSection === "audio",
- );
- const hasNonAudioMember = sortedMembers.some(
- (member) => member.trackSection !== "audio",
- );
- if (hasAudioMember && hasNonAudioMember) {
- return null;
- }
-
- const clampedAnchorStartTime = clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId: new Map(),
- });
- const blockStartIndex = hasAudioMember
- ? clampAudioInsertIndex({
- tracks,
- insertIndex: anchorInsertIndex - anchorMemberIndex,
- })
- : Math.max(
- 0,
- Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length),
- );
-
- const createTracks: PlannedTrackCreation[] = sortedMembers.map(
- (member, memberIndex) => ({
- id: newTrackIds[memberIndex],
- type: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- index: blockStartIndex + memberIndex,
- }),
- );
- const moves = sortedMembers.map((member, memberIndex) => ({
- sourceTrackId: member.trackId,
- targetTrackId: newTrackIds[memberIndex],
- elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
- }));
-
- return {
- moves,
- createTracks,
- targetSelection: moves.map(({ elementId, targetTrackId }) => ({
- trackId: targetTrackId,
- elementId,
- })),
- };
-}
-
-function clampAudioInsertIndex({
- tracks,
- insertIndex,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
-}): number {
- const minimumAudioInsertIndex = tracks.overlay.length + 1;
- return Math.max(
- minimumAudioInsertIndex,
- Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length),
- );
-}
-
-function resolveExistingTrackIdsByElementId({
- group,
- tracks,
- anchorTargetDisplayIndex,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorTargetDisplayIndex: number;
-}): Map<string, string> | null {
- const sortedMembers = [...group.members].sort(
- (leftMember, rightMember) =>
- leftMember.displayIndex - rightMember.displayIndex,
- );
- const anchorMemberIndex = sortedMembers.findIndex(
- (member) => member.elementId === group.anchor.elementId,
- );
- if (anchorMemberIndex < 0) {
- return null;
- }
-
- const targetTrackIdsByElementId = new Map<string, string>();
- const usedTrackIds = new Set<string>();
- const anchorPlacement = getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex: anchorTargetDisplayIndex,
- });
- if (!anchorPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(
- group.anchor.elementId,
- anchorPlacement.trackId,
- );
- usedTrackIds.add(anchorPlacement.trackId);
-
- let upperBoundaryIndex = anchorTargetDisplayIndex;
- for (
- let memberIndex = anchorMemberIndex - 1;
- memberIndex >= 0;
- memberIndex -= 1
- ) {
- const member = sortedMembers[memberIndex];
- const targetPlacement = findCompatibleTrackPlacement({
- tracks,
- requiredTrackType: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- startDisplayIndex: upperBoundaryIndex - 1,
- step: -1,
- usedTrackIds,
- });
- if (!targetPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
- usedTrackIds.add(targetPlacement.trackId);
- upperBoundaryIndex = targetPlacement.displayIndex;
- }
-
- let lowerBoundaryIndex = anchorTargetDisplayIndex;
- for (
- let memberIndex = anchorMemberIndex + 1;
- memberIndex < sortedMembers.length;
- memberIndex += 1
- ) {
- const member = sortedMembers[memberIndex];
- const targetPlacement = findCompatibleTrackPlacement({
- tracks,
- requiredTrackType: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- startDisplayIndex: lowerBoundaryIndex + 1,
- step: 1,
- usedTrackIds,
- });
- if (!targetPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
- usedTrackIds.add(targetPlacement.trackId);
- lowerBoundaryIndex = targetPlacement.displayIndex;
- }
-
- return targetTrackIdsByElementId;
-}
-
-function findCompatibleTrackPlacement({
- tracks,
- requiredTrackType,
- startDisplayIndex,
- step,
- usedTrackIds,
-}: {
- tracks: SceneTracks;
- requiredTrackType: ReturnType<typeof getTrackTypeForElementType>;
- startDisplayIndex: number;
- step: -1 | 1;
- usedTrackIds: Set<string>;
-}) {
- for (
- let displayIndex = startDisplayIndex;
- displayIndex >= 0 &&
- displayIndex < tracks.overlay.length + 1 + tracks.audio.length;
- displayIndex += step
- ) {
- const placement = getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex,
- });
- if (!placement) {
- continue;
- }
-
- if (
- placement.trackType === requiredTrackType &&
- !usedTrackIds.has(placement.trackId)
- ) {
- return placement;
- }
- }
-
- return null;
-}
-
-function clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- targetTrackIdsByElementId: Map<string, string>;
-}): number {
- const minimumAnchorStartTime = Math.max(
- 0,
- ...group.members.map((member) => -member.timeOffset),
- );
- let clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- anchorStartTime,
- );
-
- const memberOnMainTrack = group.members.find(
- (member) =>
- targetTrackIdsByElementId.get(member.elementId) === tracks.main.id,
- );
- if (!memberOnMainTrack) {
- return clampedAnchorStartTime;
- }
-
- const movingElementIds = new Set(
- group.members.map((member) => member.elementId),
- );
- const requestedMainStartTime =
- clampedAnchorStartTime + memberOnMainTrack.timeOffset;
- const earliestStationaryMainStartTime = tracks.main.elements
- .filter((element) => !movingElementIds.has(element.id))
- .reduce<number | null>((earliestStartTime, element) => {
- if (earliestStartTime == null || element.startTime < earliestStartTime) {
- return element.startTime;
- }
-
- return earliestStartTime;
- }, null);
- if (
- earliestStationaryMainStartTime == null ||
- requestedMainStartTime <= earliestStationaryMainStartTime
- ) {
- clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- -memberOnMainTrack.timeOffset,
- );
- }
-
- return clampedAnchorStartTime;
-}
-
-function canApplyMovesToExistingTracks({
- tracks,
- moves,
-}: {
- tracks: SceneTracks;
- moves: PlannedElementMove[];
-}): boolean {
- const movingElementIds = new Set(moves.map((move) => move.elementId));
- const sourceElements = new Map(
- getDisplayTracks({ tracks }).flatMap((track) =>
- track.elements.map((element) => [element.id, element] as const),
- ),
- );
- const movesByTargetTrackId = new Map<string, PlannedElementMove[]>();
- for (const move of moves) {
- const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? [];
- targetMoves.push(move);
- movesByTargetTrackId.set(move.targetTrackId, targetMoves);
- }
-
- for (const [targetTrackId, targetMoves] of movesByTargetTrackId) {
- const targetPlacement = getTrackPlacementById({
- tracks,
- trackId: targetTrackId,
- });
- if (!targetPlacement) {
- return false;
- }
-
- const targetTrack = getDisplayTracks({ tracks })[
- targetPlacement.displayIndex
- ];
- if (!targetTrack) {
- return false;
- }
-
- const timeSpans = targetMoves.map((move) => {
- const sourceElement = sourceElements.get(move.elementId);
- return {
- startTime: move.newStartTime,
- duration: sourceElement?.duration ?? 0,
- };
- });
- if (hasOverlappingTimeSpans({ timeSpans })) {
- return false;
- }
-
- if (
- !canPlaceTimeSpansOnTrack({
- track: {
- elements: targetTrack.elements.filter(
- (element) => !movingElementIds.has(element.id),
- ),
- },
- timeSpans,
- })
- ) {
- return false;
- }
- }
-
- return true;
-}
-
-function hasOverlappingTimeSpans({
- timeSpans,
-}: {
- timeSpans: Array<{ startTime: number; duration: number }>;
-}): boolean {
- const sortedSpans = [...timeSpans].sort(
- (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime,
- );
-
- for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) {
- const previousSpan = sortedSpans[spanIndex - 1];
- const currentSpan = sortedSpans[spanIndex];
- if (
- previousSpan.startTime + previousSpan.duration >
- currentSpan.startTime
- ) {
- return true;
- }
- }
-
- return false;
-}
diff --git a/apps/web/src/lib/timeline/group-move/snap.ts b/apps/web/src/lib/timeline/group-move/snap.ts
deleted file mode 100644
index 3502ae30..00000000
--- a/apps/web/src/lib/timeline/group-move/snap.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { SceneTracks } from "@/lib/timeline";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type { MoveGroup } from "./types";
-
-export function snapGroupEdges({
- group,
- anchorStartTime,
- tracks,
- playheadTime,
- zoomLevel,
-}: {
- group: MoveGroup;
- anchorStartTime: number;
- tracks: SceneTracks;
- playheadTime: number;
- zoomLevel: number;
-}): {
- snappedAnchorStartTime: number;
- snapPoint: SnapPoint | null;
-} {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementIds: new Set(group.members.map((member) => member.elementId)),
- });
-
- let closestSnapDistance = Infinity;
- let snappedAnchorStartTime = anchorStartTime;
- let snapPoint: SnapPoint | null = null;
-
- for (const member of group.members) {
- const memberStartTime = anchorStartTime + member.timeOffset;
- const memberStartSnap = snapToNearestPoint({
- targetTime: memberStartTime,
- snapPoints,
- zoomLevel,
- });
- if (
- memberStartSnap.snapPoint &&
- memberStartSnap.snapDistance < closestSnapDistance
- ) {
- closestSnapDistance = memberStartSnap.snapDistance;
- snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
- snapPoint = memberStartSnap.snapPoint;
- }
-
- const memberEndSnap = snapToNearestPoint({
- targetTime: memberStartTime + member.duration,
- snapPoints,
- zoomLevel,
- });
- if (
- memberEndSnap.snapPoint &&
- memberEndSnap.snapDistance < closestSnapDistance
- ) {
- closestSnapDistance = memberEndSnap.snapDistance;
- snappedAnchorStartTime =
- memberEndSnap.snappedTime - member.duration - member.timeOffset;
- snapPoint = memberEndSnap.snapPoint;
- }
- }
-
- return {
- snappedAnchorStartTime,
- snapPoint,
- };
-}
diff --git a/apps/web/src/lib/timeline/group-move/track-placement.ts b/apps/web/src/lib/timeline/group-move/track-placement.ts
deleted file mode 100644
index d301e5ef..00000000
--- a/apps/web/src/lib/timeline/group-move/track-placement.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
-import type { GroupTrackSection } from "./types";
-
-export interface TrackPlacement {
- trackId: string;
- trackType: TimelineTrack["type"];
- section: GroupTrackSection;
- sectionIndex: number;
- displayIndex: number;
-}
-
-export function getDisplayTracks({
- tracks,
-}: {
- tracks: SceneTracks;
-}): TimelineTrack[] {
- return [...tracks.overlay, tracks.main, ...tracks.audio];
-}
-
-export function getTrackPlacementById({
- tracks,
- trackId,
-}: {
- tracks: SceneTracks;
- trackId: string;
-}): TrackPlacement | null {
- if (tracks.main.id === trackId) {
- return {
- trackId,
- trackType: tracks.main.type,
- section: "main",
- sectionIndex: -1,
- displayIndex: tracks.overlay.length,
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex(
- (track) => track.id === trackId,
- );
- if (overlayTrackIndex >= 0) {
- return {
- trackId,
- trackType: tracks.overlay[overlayTrackIndex].type,
- section: "overlay",
- sectionIndex: overlayTrackIndex,
- displayIndex: overlayTrackIndex,
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex(
- (track) => track.id === trackId,
- );
- if (audioTrackIndex >= 0) {
- return {
- trackId,
- trackType: tracks.audio[audioTrackIndex].type,
- section: "audio",
- sectionIndex: audioTrackIndex,
- displayIndex: tracks.overlay.length + 1 + audioTrackIndex,
- };
- }
-
- return null;
-}
-
-export function getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex,
-}: {
- tracks: SceneTracks;
- displayIndex: number;
-}): TrackPlacement | null {
- const displayTracks = getDisplayTracks({ tracks });
- const track = displayTracks[displayIndex];
- if (!track) {
- return null;
- }
-
- return getTrackPlacementById({
- tracks,
- trackId: track.id,
- });
-}
diff --git a/apps/web/src/lib/timeline/group-move/types.ts b/apps/web/src/lib/timeline/group-move/types.ts
deleted file mode 100644
index 5ac1e207..00000000
--- a/apps/web/src/lib/timeline/group-move/types.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { ElementRef, ElementType, TrackType } from "@/lib/timeline";
-
-export type GroupTrackSection = "overlay" | "main" | "audio";
-
-export interface GroupMember extends ElementRef {
- elementType: ElementType;
- duration: number;
- timeOffset: number;
- trackSection: GroupTrackSection;
- sectionIndex: number;
- displayIndex: number;
-}
-
-export interface MoveGroup {
- anchor: GroupMember;
- members: GroupMember[];
-}
-
-export interface PlannedTrackCreation {
- id: string;
- type: TrackType;
- index: number;
-}
-
-export interface PlannedElementMove {
- sourceTrackId: string;
- targetTrackId: string;
- elementId: string;
- newStartTime: number;
-}
-
-export interface GroupMoveResult {
- moves: PlannedElementMove[];
- createTracks: PlannedTrackCreation[];
- targetSelection: ElementRef[];
-}
diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
deleted file mode 100644
index 5730b117..00000000
--- a/apps/web/src/lib/timeline/group-resize/compute-resize.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { roundToFrame } from "opencut-wasm";
-import {
- getSourceSpanAtClipTime,
- getTimelineDurationForSourceSpan,
-} from "@/lib/retime";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import type {
- ComputeGroupResizeArgs,
- GroupResizeMember,
- GroupResizeResult,
- GroupResizeUpdate,
- ResizeSide,
-} from "./types";
-
-export function computeGroupResize({
- members,
- side,
- deltaTime,
- fps,
-}: ComputeGroupResizeArgs): GroupResizeResult {
- const minDuration = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- const minimumDeltaTime = Math.max(
- ...members.map((member) =>
- getMinimumAllowedDeltaTime({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const maximumDeltaTime = Math.min(
- ...members.map((member) =>
- getMaximumAllowedDeltaTime({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const clampedDeltaTime =
- minimumDeltaTime > maximumDeltaTime
- ? minimumDeltaTime
- : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
-
- // Snap the drag delta to a frame exactly once, then derive every patch
- // field from that single snapped value. This keeps the invariant
- // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
- // delta is added on one side of the element and removed from the other,
- // so the rounding cancels by construction. Per-field rounding (the old
- // approach) couldn't preserve this because the individual rounds don't
- // compose when `sourceDuration` isn't frame-aligned.
- const snappedDeltaTime =
- roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
- // Re-clamp after rounding. Bounds derived from other elements are
- // frame-aligned, so this is normally a no-op; at the source-extent limit
- // the bound may not be frame-aligned, and honouring the bound takes
- // precedence over frame alignment (you can't extend past real content).
- const finalDeltaTime =
- minimumDeltaTime > maximumDeltaTime
- ? minimumDeltaTime
- : Math.min(
- maximumDeltaTime,
- Math.max(minimumDeltaTime, snappedDeltaTime),
- );
-
- return {
- deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
- updates: members.map((member) =>
- buildResizeUpdate({
- member,
- side,
- deltaTime: finalDeltaTime,
- }),
- ),
- };
-}
-
-function buildResizeUpdate({
- member,
- side,
- deltaTime,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- deltaTime: number;
-}): GroupResizeUpdate {
- const sourceDelta = getSourceDeltaForClipDelta({
- member,
- clipDelta: deltaTime,
- });
-
- if (side === "left") {
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: Math.max(0, member.trimStart + sourceDelta),
- trimEnd: member.trimEnd,
- startTime: member.startTime + deltaTime,
- duration: member.duration - deltaTime,
- },
- };
- }
-
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: member.trimStart,
- trimEnd: Math.max(0, member.trimEnd - sourceDelta),
- startTime: member.startTime,
- duration: member.duration + deltaTime,
- },
- };
-}
-
-function getMinimumAllowedDeltaTime({
- member,
- side,
- minDuration,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- minDuration: number;
-}): number {
- if (side === "right") {
- return minDuration - member.duration;
- }
-
- const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
- ? member.leftNeighborBound - member.startTime
- : -member.startTime;
- if (member.sourceDuration == null) {
- return leftNeighborFloor;
- }
-
- const maximumSourceExtension =
- getDurationForVisibleSourceSpan({
- member,
- sourceSpan:
- getVisibleSourceSpanForDuration({
- member,
- duration: member.duration,
- }) + member.trimStart,
- }) - member.duration;
- return Math.max(leftNeighborFloor, -maximumSourceExtension);
-}
-
-function getMaximumAllowedDeltaTime({
- member,
- side,
- minDuration,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- minDuration: number;
-}): number {
- if (side === "left") {
- return member.duration - minDuration;
- }
-
- const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
- ? member.rightNeighborBound - (member.startTime + member.duration)
- : Infinity;
- if (member.sourceDuration == null) {
- return rightNeighborCeiling;
- }
-
- const maximumVisibleSourceSpan =
- getSourceDuration({ member }) - member.trimStart;
- const maximumDuration = getDurationForVisibleSourceSpan({
- member,
- sourceSpan: maximumVisibleSourceSpan,
- });
- return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
-}
-
-function getSourceDeltaForClipDelta({
- member,
- clipDelta,
-}: {
- member: GroupResizeMember;
- clipDelta: number;
-}): number {
- if (!member.retime) {
- return clipDelta;
- }
-
- return clipDelta >= 0
- ? getSourceSpanAtClipTime({
- clipTime: clipDelta,
- retime: member.retime,
- })
- : -getSourceSpanAtClipTime({
- clipTime: Math.abs(clipDelta),
- retime: member.retime,
- });
-}
-
-function getVisibleSourceSpanForDuration({
- member,
- duration,
-}: {
- member: GroupResizeMember;
- duration: number;
-}): number {
- if (!member.retime) {
- return duration;
- }
-
- return getSourceSpanAtClipTime({
- clipTime: duration,
- retime: member.retime,
- });
-}
-
-function getDurationForVisibleSourceSpan({
- member,
- sourceSpan,
-}: {
- member: GroupResizeMember;
- sourceSpan: number;
-}): number {
- if (!member.retime) {
- return sourceSpan;
- }
-
- return getTimelineDurationForSourceSpan({
- sourceSpan,
- retime: member.retime,
- });
-}
-
-function getSourceDuration({ member }: { member: GroupResizeMember }): number {
- if (typeof member.sourceDuration === "number") {
- return member.sourceDuration;
- }
-
- return (
- member.trimStart +
- getVisibleSourceSpanForDuration({
- member,
- duration: member.duration,
- }) +
- member.trimEnd
- );
-}
diff --git a/apps/web/src/lib/timeline/group-resize/index.ts b/apps/web/src/lib/timeline/group-resize/index.ts
deleted file mode 100644
index bc0a7b35..00000000
--- a/apps/web/src/lib/timeline/group-resize/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { computeGroupResize } from "./compute-resize";
-
-export type {
- ComputeGroupResizeArgs,
- GroupResizeMember,
- GroupResizeResult,
- GroupResizeUpdate,
- ResizeSide,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/group-resize/types.ts b/apps/web/src/lib/timeline/group-resize/types.ts
deleted file mode 100644
index d5f00c1b..00000000
--- a/apps/web/src/lib/timeline/group-resize/types.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-import type { ElementRef, RetimeConfig } from "@/lib/timeline/types";
-
-export type ResizeSide = "left" | "right";
-
-export interface GroupResizeMember extends ElementRef {
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
- retime?: RetimeConfig;
- leftNeighborBound: number;
- rightNeighborBound: number;
-}
-
-export interface GroupResizeUpdate extends ElementRef {
- patch: {
- trimStart: number;
- trimEnd: number;
- startTime: number;
- duration: number;
- };
-}
-
-export interface GroupResizeResult {
- deltaTime: number;
- updates: GroupResizeUpdate[];
-}
-
-export interface ComputeGroupResizeArgs {
- members: GroupResizeMember[];
- side: ResizeSide;
- deltaTime: number;
- fps: FrameRate;
-}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
deleted file mode 100644
index 5ee079b1..00000000
--- a/apps/web/src/lib/timeline/index.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
deleted file mode 100644
index 10f258aa..00000000
--- a/apps/web/src/lib/timeline/pixel-utils.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
-
-function getDevicePixelRatio({
- devicePixelRatio,
-}: {
- devicePixelRatio?: number;
-}): number {
- if (
- typeof devicePixelRatio === "number" &&
- Number.isFinite(devicePixelRatio) &&
- devicePixelRatio > 0
- ) {
- return devicePixelRatio;
- }
-
- if (typeof window === "undefined") {
- return 1;
- }
-
- if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
- return window.devicePixelRatio;
- }
-
- return 1;
-}
-
-export function getTimelinePixelsPerSecond({
- zoomLevel,
-}: {
- zoomLevel: number;
-}): number {
- return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-}
-
-export function timelineTimeToPixels({
- time,
- zoomLevel,
-}: {
- time: number;
- zoomLevel: number;
-}): number {
- return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel });
-}
-
-export function snapPixelToDeviceGrid({
- pixel,
- devicePixelRatio,
-}: {
- pixel: number;
- devicePixelRatio?: number;
-}): number {
- const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
- return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
-}
-
-export function timelineTimeToSnappedPixels({
- time,
- zoomLevel,
- devicePixelRatio,
-}: {
- time: number;
- zoomLevel: number;
- devicePixelRatio?: number;
-}): number {
- const rawPixel = timelineTimeToPixels({ time, zoomLevel });
- return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
-}
-
-export function getCenteredLineLeft({
- centerPixel,
- lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
-}: {
- centerPixel: number;
- lineWidthPx?: number;
-}): number {
- return centerPixel - lineWidthPx / 2;
-}
diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
deleted file mode 100644
index 7cadb9b7..00000000
--- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
+++ /dev/null
@@ -1,661 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type {
- AudioElement,
- AudioTrack,
- GraphicElement,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextElement,
- TextTrack,
- TimelineTrack,
- VideoElement,
- VideoTrack,
-} from "@/lib/timeline";
-import type { Transform } from "@/lib/rendering";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-
-function buildTransform(): Transform {
- return {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
- };
-}
-
-type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
-
-function buildElement(params: {
- id: string;
- type: "audio";
- startTime: number;
- duration: number;
-}): AudioElement;
-function buildElement(params: {
- id: string;
- type: "graphic";
- startTime: number;
- duration: number;
-}): GraphicElement;
-function buildElement(params: {
- id: string;
- type: "text";
- startTime: number;
- duration: number;
-}): TextElement;
-function buildElement(params: {
- id: string;
- type: "video";
- startTime: number;
- duration: number;
-}): VideoElement;
-function buildElement({
- id,
- type,
- startTime,
- duration,
-}: {
- id: string;
- type: TestElement["type"];
- startTime: number;
- duration: number;
-}): TestElement {
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- volume: 1,
- sourceType: "upload",
- mediaId: `media-${id}`,
- } satisfies AudioElement;
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- definitionId: `graphic-${id}`,
- params: {},
- transform: buildTransform(),
- opacity: 1,
- } satisfies GraphicElement;
- case "text":
- return {
- id,
- type: "text",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- content: id,
- fontSize: 32,
- fontFamily: "sans-serif",
- color: "#ffffff",
- background: {
- enabled: false,
- color: "#000000",
- },
- textAlign: "left",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- transform: buildTransform(),
- opacity: 1,
- } satisfies TextElement;
- case "video":
- return {
- id,
- type: "video",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- mediaId: `media-${id}`,
- transform: buildTransform(),
- opacity: 1,
- } satisfies VideoElement;
- }
-
- throw new Error(`Unsupported test element type: ${type}`);
-}
-
-type BuildTrackParams =
- | {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
- }
- | {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
- }
- | {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
- }
- | {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
- };
-
-function buildTrack(params: {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
-}): AudioTrack;
-function buildTrack(params: {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
-}): GraphicTrack;
-function buildTrack(params: {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
-}): TextTrack;
-function buildTrack(params: {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
-}): VideoTrack;
-function buildTrack(params: BuildTrackParams): TimelineTrack {
- const { id, type } = params;
-
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- };
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "text":
- return {
- id,
- type: "text",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "video":
- return {
- id,
- type: "video",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- hidden: false,
- };
- }
-
- throw new Error(`Unsupported test track type: ${type}`);
-}
-
-function buildTimeSpan({
- startTime,
- duration,
- excludeElementId,
-}: {
- startTime: number;
- duration: number;
- excludeElementId?: string;
-}) {
- return { startTime, duration, excludeElementId };
-}
-
-function buildSceneTracks({
- overlay = [],
- main,
- audio = [],
-}: {
- overlay?: Array<OverlayTrack>;
- main?: VideoTrack;
- audio?: Array<AudioTrack>;
-}): SceneTracks {
- return {
- overlay,
- main:
- main ??
- buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio,
- };
-}
-
-describe("resolveTrackPlacement", () => {
- test("explicit returns the requested compatible track", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
- strategy: { type: "explicit", trackId: "text-1" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-1",
- trackIndex: 0,
- trackType: "text",
- });
- });
-
- test("explicit rejects missing and incompatible tracks", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-1", type: "video" }),
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: { type: "explicit", trackId: "missing" },
- }),
- ).toBeNull();
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: { type: "explicit", trackId: "video-1" },
- }),
- ).toBeNull();
- });
-
- test("firstAvailable picks the first compatible track without overlap", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "text-1",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({ id: "text-2", type: "text" }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-2",
- trackIndex: 1,
- trackType: "text",
- });
- });
-
- test("firstAvailable creates a new track when all compatible tracks are full", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "graphic-1",
- type: "graphic",
- elements: [
- buildElement({
- id: "a",
- type: "graphic",
- startTime: 0,
- duration: 5,
- }),
- ],
- }),
- ],
- main: buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "graphic",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "graphic",
- insertIndex: 0,
- insertPosition: null,
- });
- });
-
- test("preferIndex uses the preferred track when it fits", () => {
- const tracks = buildSceneTracks({
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: "below",
- });
- });
-
- test("preferIndex creates a new overlay track above the main track", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "graphic",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 1,
- hoverDirection: "below",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "graphic",
- insertIndex: 0,
- insertPosition: "above",
- });
- });
-
- test("preferIndex keeps audio tracks below the main track", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "above",
- createNewTrackOnly: true,
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: "below",
- });
- });
-
- test("aboveSource tries the track above source, then any compatible track", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({ id: "text-top", type: "text" }),
- buildTrack({
- id: "text-middle",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({ id: "text-source", type: "text" }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "aboveSource", sourceTrackIndex: 2 },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-top",
- trackIndex: 0,
- trackType: "text",
- });
- });
-
- test("aboveSource creates a new overlay track in the overlay zone when none fit", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "text-top",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({
- id: "text-source",
- type: "text",
- elements: [
- buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "aboveSource", sourceTrackIndex: 1 },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "text",
- insertIndex: 0,
- insertPosition: null,
- });
- });
-
- test("alwaysNew honors highest and default insertion rules", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "highest" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: null,
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "default" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: null,
- });
- });
-
- test("batch time spans reject tracks when any span overlaps", () => {
- const tracks = buildSceneTracks({
- audio: [
- buildTrack({
- id: "audio-1",
- type: "audio",
- elements: [
- buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
- buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
- ],
- }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [
- buildTimeSpan({ startTime: 2.5, duration: 1 }),
- buildTimeSpan({ startTime: 5.5, duration: 1 }),
- ],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: null,
- });
- });
-
- test("handles main-only timelines, single tracks, and track-type derivation", () => {
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({}),
- elementType: "video",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- createNewTrackOnly: true,
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "video",
- insertIndex: 0,
- insertPosition: "above",
- });
-
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- }),
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "default" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: null,
- });
- });
-
- test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({
- id: "video-main",
- type: "video",
- elements: [
- buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
- ],
- }),
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "video",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
- strategy: { type: "explicit", trackId: "video-main" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "video-main",
- trackIndex: 0,
- trackType: "video",
- adjustedStartTime: 0,
- });
- });
-
- test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "above",
- verticalDragDirection: "down",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: "below",
- });
- });
-});
diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts
deleted file mode 100644
index 639b1b42..00000000
--- a/apps/web/src/lib/timeline/placement/apply.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextTrack,
- TimelineElement,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { buildEmptyTrack } from "./track-factory";
-import type { PlacementResult } from "./types";
-import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update";
-
-export function applyPlacement({
- tracks,
- placementResult,
- elements,
- newTrackInsertIndexOverride,
-}: {
- tracks: SceneTracks;
- placementResult: PlacementResult;
- elements: TimelineElement[];
- newTrackInsertIndexOverride?: number;
-}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (placementResult.kind === "existingTrack") {
- const targetTrack = orderedTracks[placementResult.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const updatedTracks = updateTrackInSceneTracks({
- tracks,
- trackId: targetTrack.id,
- update: (track) => ({
- ...track,
- elements: [...track.elements, ...elements],
- }),
- });
-
- return { updatedTracks, targetTrackId: targetTrack.id };
- }
-
- const newTrackId = generateUUID();
- const insertIndex =
- newTrackInsertIndexOverride ?? placementResult.insertIndex;
- const updatedTracks =
- placementResult.trackType === "audio"
- ? {
- ...tracks,
- audio: insertIntoAudioTracks({
- tracks,
- insertIndex,
- track: buildPlacedAudioTrack({
- id: newTrackId,
- elements,
- }),
- }),
- }
- : {
- ...tracks,
- overlay: insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track: buildPlacedOverlayTrack({
- id: newTrackId,
- type: placementResult.trackType,
- elements,
- }),
- }),
- };
- return { updatedTracks, targetTrackId: newTrackId };
-}
-
-function insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: OverlayTrack;
-}): OverlayTrack[] {
- const normalizedInsertIndex = Math.max(
- 0,
- Math.min(insertIndex, tracks.overlay.length),
- );
- const nextTracks = [...tracks.overlay];
- nextTracks.splice(normalizedInsertIndex, 0, track);
- return nextTracks;
-}
-
-function insertIntoAudioTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: AudioTrack;
-}): AudioTrack[] {
- const audioInsertIndex = Math.max(
- 0,
- Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
- );
- const nextTracks = [...tracks.audio];
- nextTracks.splice(audioInsertIndex, 0, track);
- return nextTracks;
-}
-
-function buildPlacedAudioTrack({
- id,
- elements,
-}: {
- id: string;
- elements: TimelineElement[];
-}): AudioTrack {
- return {
- ...buildEmptyTrack({ id, type: "audio" }),
- elements: elements as AudioTrack["elements"],
- };
-}
-
-function buildPlacedOverlayTrack({
- id,
- type,
- elements,
-}: {
- id: string;
- type: Exclude<OverlayTrack["type"], "audio">;
- elements: TimelineElement[];
-}): OverlayTrack {
- switch (type) {
- case "video":
- return {
- ...buildEmptyTrack({ id, type: "video" }),
- elements: elements as VideoTrack["elements"],
- };
- case "text":
- return {
- ...buildEmptyTrack({ id, type: "text" }),
- elements: elements as TextTrack["elements"],
- };
- case "graphic":
- return {
- ...buildEmptyTrack({ id, type: "graphic" }),
- elements: elements as GraphicTrack["elements"],
- };
- case "effect":
- return {
- ...buildEmptyTrack({ id, type: "effect" }),
- elements: elements as EffectTrack["elements"],
- };
- }
-}
diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts
deleted file mode 100644
index de1baff3..00000000
--- a/apps/web/src/lib/timeline/placement/compatibility.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { ElementType, TrackType } from "@/lib/timeline";
-
-const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
- audio: "audio",
- text: "text",
- sticker: "graphic",
- graphic: "graphic",
- effect: "effect",
- video: "video",
- image: "video",
-};
-
-export function getTrackTypeForElementType({
- elementType,
-}: {
- elementType: ElementType;
-}): TrackType {
- return ELEMENT_TRACK_MAP[elementType];
-}
-
-export function canElementGoOnTrack({
- elementType,
- trackType,
-}: {
- elementType: ElementType;
- trackType: TrackType;
-}): boolean {
- return getTrackTypeForElementType({ elementType }) === trackType;
-}
-
-export function validateElementTrackCompatibility({
- element,
- track,
-}: {
- element: { type: ElementType };
- track: { type: TrackType };
-}): { isValid: boolean; errorMessage?: string } {
- const isValid = canElementGoOnTrack({
- elementType: element.type,
- trackType: track.type,
- });
-
- if (!isValid) {
- return {
- isValid: false,
- errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
- };
- }
-
- return { isValid: true };
-}
diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts
deleted file mode 100644
index 530cb69f..00000000
--- a/apps/web/src/lib/timeline/placement/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { applyPlacement } from "./apply";
-export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
-export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
-export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
-export { resolveTrackPlacement } from "./resolve";
-export { buildEmptyTrack } from "./track-factory";
-export type {
- PlacementResult,
- PlacementStrategy,
- PlacementSubject,
- PlacementTimeSpan,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts
deleted file mode 100644
index 04fa5a4a..00000000
--- a/apps/web/src/lib/timeline/placement/insert-index.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { SceneTracks, TrackType } from "@/lib/timeline";
-
-export function getDefaultInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1 + tracks.audio.length;
- }
-
- if (trackType === "effect") {
- return 0;
- }
-
- return tracks.overlay.length;
-}
-
-export function getHighestInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1;
- }
-
- return 0;
-}
-
-export function resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex,
- direction,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
- preferredIndex: number;
- direction: "above" | "below";
-}): { insertIndex: number; insertPosition: "above" | "below" | null } {
- const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
- if (trackCount === 0) {
- return {
- insertIndex: 0,
- insertPosition: trackType === "audio" ? "below" : null,
- };
- }
-
- const safePreferredIndex = Math.min(
- Math.max(preferredIndex, 0),
- trackCount - 1,
- );
- const mainTrackIndex = tracks.overlay.length;
-
- if (trackType === "audio") {
- if (safePreferredIndex <= mainTrackIndex) {
- return {
- insertIndex: mainTrackIndex + 1,
- insertPosition: "below",
- };
- }
-
- return {
- insertIndex:
- direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
- insertPosition: direction,
- };
- }
-
- const insertIndex =
- direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
- if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
- return {
- insertIndex: mainTrackIndex,
- insertPosition: "above",
- };
- }
-
- return {
- insertIndex,
- insertPosition: direction,
- };
-}
diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts
deleted file mode 100644
index 7e206517..00000000
--- a/apps/web/src/lib/timeline/placement/main-track.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
-
-export const MAIN_TRACK_NAME = "Main Track";
-
-export function getEarliestMainTrackElement({
- mainTrack,
- excludeElementId,
-}: {
- mainTrack: VideoTrack;
- excludeElementId?: string;
-}): TimelineElement | null {
- const elements = mainTrack.elements.filter((element) => {
- return !excludeElementId || element.id !== excludeElementId;
- });
- if (elements.length === 0) {
- return null;
- }
-
- return elements.reduce((earliestElement, element) => {
- return element.startTime < earliestElement.startTime
- ? element
- : earliestElement;
- });
-}
-
-export function enforceMainTrackStart({
- tracks,
- targetTrackId,
- requestedStartTime,
- excludeElementId,
-}: {
- tracks: SceneTracks;
- targetTrackId: string;
- requestedStartTime: number;
- excludeElementId?: string;
-}): number {
- if (tracks.main.id !== targetTrackId) {
- return requestedStartTime;
- }
-
- const earliestElement = getEarliestMainTrackElement({
- mainTrack: tracks.main,
- excludeElementId,
- });
- if (!earliestElement) {
- return 0;
- }
-
- if (requestedStartTime <= earliestElement.startTime) {
- return 0;
- }
-
- return requestedStartTime;
-}
diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts
deleted file mode 100644
index 5e37c052..00000000
--- a/apps/web/src/lib/timeline/placement/overlap.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { TimelineElement } from "@/lib/timeline";
-import type { PlacementTimeSpan } from "./types";
-
-interface TrackWithElements {
- elements: TimelineElement[];
-}
-
-function wouldElementOverlap({
- elements,
- startTime,
- endTime,
- excludeElementId,
-}: {
- elements: TimelineElement[];
- startTime: number;
- endTime: number;
- excludeElementId?: string;
-}): boolean {
- return elements.some((element) => {
- if (excludeElementId && element.id === excludeElementId) {
- return false;
- }
-
- const elementEnd = element.startTime + element.duration;
- return startTime < elementEnd && endTime > element.startTime;
- });
-}
-
-export function canPlaceTimeSpansOnTrack({
- track,
- timeSpans,
-}: {
- track: TrackWithElements;
- timeSpans: PlacementTimeSpan[];
-}): boolean {
- return timeSpans.every(({ startTime, duration, excludeElementId }) => {
- return !wouldElementOverlap({
- elements: track.elements,
- startTime,
- endTime: startTime + duration,
- excludeElementId,
- });
- });
-}
diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts
deleted file mode 100644
index 67943912..00000000
--- a/apps/web/src/lib/timeline/placement/resolve.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline";
-import {
- getDefaultInsertIndexForTrack,
- getHighestInsertIndexForTrack,
- resolvePreferredNewTrackPlacement,
-} from "./insert-index";
-import { getTrackTypeForElementType } from "./compatibility";
-import { enforceMainTrackStart } from "./main-track";
-import { canPlaceTimeSpansOnTrack } from "./overlap";
-import type {
- PlacementResult,
- PlacementStrategy,
- PlacementSubject,
- PlacementTimeSpan,
-} from "./types";
-
-type ResolveTrackPlacementParams = PlacementSubject & {
- tracks: SceneTracks;
- timeSpans: PlacementTimeSpan[];
- strategy: PlacementStrategy;
-};
-
-function buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
-}: {
- track: TimelineTrack;
- trackIndex: number;
- tracks: SceneTracks;
- timeSpans: PlacementTimeSpan[];
-}): PlacementResult {
- const firstSpan = timeSpans[0];
- const requestedStartTime = firstSpan?.startTime ?? 0;
- const adjustedStartTime = enforceMainTrackStart({
- tracks,
- targetTrackId: track.id,
- requestedStartTime,
- excludeElementId: firstSpan?.excludeElementId,
- });
- return {
- kind: "existingTrack",
- trackId: track.id,
- trackIndex,
- trackType: track.type,
- ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
- };
-}
-
-function buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition,
-}: {
- trackType: TrackType;
- insertIndex: number;
- insertPosition: "above" | "below" | null;
-}): PlacementResult {
- return {
- kind: "newTrack",
- trackType,
- insertIndex,
- insertPosition,
- };
-}
-
-function findFirstAvailableTrackIndex({
- tracks,
- trackType,
- timeSpans,
-}: {
- tracks: TimelineTrack[];
- trackType: TrackType;
- timeSpans: PlacementTimeSpan[];
-}): number {
- return tracks.findIndex((track) => {
- return (
- track.type === trackType &&
- canPlaceTimeSpansOnTrack({
- track,
- timeSpans,
- })
- );
- });
-}
-
-function resolveAlwaysNewTrack({
- tracks,
- trackType,
- position,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
- position: "highest" | "default";
-}): PlacementResult {
- const insertIndex =
- position === "highest"
- ? getHighestInsertIndexForTrack({
- tracks,
- trackType,
- })
- : getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- });
-
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition: null,
- });
-}
-
-function getInsertDirection({
- hoverDirection,
- verticalDragDirection,
-}: {
- hoverDirection: "above" | "below";
- verticalDragDirection?: "up" | "down" | null;
-}): "above" | "below" {
- if (verticalDragDirection === "up") {
- return "above";
- }
-
- if (verticalDragDirection === "down") {
- return "below";
- }
-
- return hoverDirection;
-}
-
-export function resolveTrackPlacement({
- tracks,
- ...placement
-}: ResolveTrackPlacementParams): PlacementResult | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const trackType =
- "trackType" in placement
- ? placement.trackType
- : getTrackTypeForElementType({
- elementType: placement.elementType,
- });
- const { timeSpans, strategy } = placement;
-
- if (strategy.type === "explicit") {
- const trackIndex = orderedTracks.findIndex(
- (track) => track.id === strategy.trackId,
- );
- if (trackIndex < 0) {
- return null;
- }
-
- const track = orderedTracks[trackIndex];
- if (track.type !== trackType) {
- return null;
- }
-
- return buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
- });
- }
-
- if (strategy.type === "firstAvailable") {
- const existingTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (existingTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[existingTrackIndex],
- trackIndex: existingTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- return resolveAlwaysNewTrack({
- tracks,
- trackType,
- position: "highest",
- });
- }
-
- if (strategy.type === "preferIndex") {
- const preferredTrack = orderedTracks[strategy.trackIndex];
- const isPreferredTrackCompatible =
- !!preferredTrack && preferredTrack.type === trackType;
- const canUseExistingTrack =
- !strategy.createNewTrackOnly &&
- isPreferredTrackCompatible &&
- canPlaceTimeSpansOnTrack({
- track: preferredTrack,
- timeSpans,
- });
- if (canUseExistingTrack) {
- return buildExistingTrackResult({
- track: preferredTrack,
- trackIndex: strategy.trackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex: strategy.trackIndex,
- direction: getInsertDirection({
- hoverDirection: strategy.hoverDirection,
- verticalDragDirection: !isPreferredTrackCompatible
- ? strategy.verticalDragDirection
- : null,
- }),
- });
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition,
- });
- }
-
- if (strategy.type === "aboveSource") {
- const aboveTrackIndex = strategy.sourceTrackIndex - 1;
- const aboveTrack = orderedTracks[aboveTrackIndex];
- if (
- aboveTrack &&
- aboveTrack.type === trackType &&
- canPlaceTimeSpansOnTrack({
- track: aboveTrack,
- timeSpans,
- })
- ) {
- return buildExistingTrackResult({
- track: aboveTrack,
- trackIndex: aboveTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (firstAvailableTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[firstAvailableTrackIndex],
- trackIndex: firstAvailableTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const insertIndex = getHighestInsertIndexForTrack({
- tracks,
- trackType,
- });
-
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition: null,
- });
- }
-
- return resolveAlwaysNewTrack({
- tracks,
- trackType,
- position: strategy.position,
- });
-}
diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts
deleted file mode 100644
index b73c5155..00000000
--- a/apps/web/src/lib/timeline/placement/track-factory.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- TextTrack,
- TrackType,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "video";
- name?: string;
-}): VideoTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "text";
- name?: string;
-}): TextTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "audio";
- name?: string;
-}): AudioTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "graphic";
- name?: string;
-}): GraphicTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "effect";
- name?: string;
-}): EffectTrack;
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack {
- const trackName = name ?? DEFAULT_TRACK_NAMES[type];
-
- switch (type) {
- case "video":
- return {
- id,
- name: trackName,
- type: "video",
- elements: [],
- hidden: false,
- muted: false,
- };
- case "text":
- return {
- id,
- name: trackName,
- type: "text",
- elements: [],
- hidden: false,
- };
- case "graphic":
- return {
- id,
- name: trackName,
- type: "graphic",
- elements: [],
- hidden: false,
- };
- case "audio":
- return {
- id,
- name: trackName,
- type: "audio",
- elements: [],
- muted: false,
- };
- case "effect":
- return {
- id,
- name: trackName,
- type: "effect",
- elements: [],
- hidden: false,
- };
- default:
- throw new Error(`Unsupported track type: ${type}`);
- }
-}
diff --git a/apps/web/src/lib/timeline/placement/types.ts b/apps/web/src/lib/timeline/placement/types.ts
deleted file mode 100644
index 559e85cc..00000000
--- a/apps/web/src/lib/timeline/placement/types.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { ElementType, TrackType } from "@/lib/timeline";
-
-export interface PlacementTimeSpan {
- startTime: number;
- duration: number;
- excludeElementId?: string;
-}
-
-export type PlacementSubject =
- | { elementType: ElementType }
- | { trackType: TrackType };
-
-export type PlacementStrategy =
- | { type: "explicit"; trackId: string }
- | { type: "firstAvailable" }
- | {
- type: "preferIndex";
- trackIndex: number;
- hoverDirection: "above" | "below";
- verticalDragDirection?: "up" | "down" | null;
- createNewTrackOnly?: boolean;
- }
- | { type: "aboveSource"; sourceTrackIndex: number }
- | { type: "alwaysNew"; position: "highest" | "default" };
-
-export type PlacementResult =
- | {
- kind: "existingTrack";
- trackId: string;
- trackIndex: number;
- trackType: TrackType;
- adjustedStartTime?: number;
- }
- | {
- kind: "newTrack";
- insertIndex: number;
- insertPosition: "above" | "below" | null;
- trackType: TrackType;
- };
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
deleted file mode 100644
index ea2982aa..00000000
--- a/apps/web/src/lib/timeline/ruler-utils.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { frameRateToFloat } from "@/lib/fps/utils";
-
-/**
- * frame intervals for labels - starts at 2 so there's always at least
- * one tick between labels even at max zoom.
- * pattern: 2, 3, 5, 10, 15 (matches CapCut)
- */
-const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
-
-/**
- * frame intervals for ticks - can go down to 1 for max granularity.
- */
-const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
-
-/**
- * second intervals for when we're zoomed out past frame-level detail.
- */
-const SECOND_MULTIPLIERS = [
- 1, 2, 3, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
-] as const;
-
-/**
- * minimum pixel spacing between labels to keep them readable
- */
-const MIN_LABEL_SPACING_PX = 120;
-
-/**
- * minimum pixel spacing between ticks. much denser than labels.
- */
-const MIN_TICK_SPACING_PX = 18;
-
-export interface RulerConfig {
- /** time interval in seconds between each label */
- labelIntervalSeconds: number;
- /** time interval in seconds between each tick */
- tickIntervalSeconds: number;
-}
-
-/**
- * determines the optimal label and tick intervals based on zoom level and FPS.
- *
- * labels and ticks scale independently:
- * - labels need wide spacing (~50px) to stay readable
- * - ticks can be denser (~8px) to show finer subdivisions
- *
- * example at different zoom levels:
- * - very zoomed in: labels every 2f, ticks every 1f
- * - zoomed in: labels every 10f, ticks every 1f
- * - zoomed out: labels every 15f, ticks every 3f
- * - very zoomed out: labels every 1s, ticks every 5f
- */
-export function getRulerConfig({
- zoomLevel,
- fps,
-}: {
- zoomLevel: number;
- fps: FrameRate;
-}): RulerConfig {
- const fpsFloat = frameRateToFloat(fps);
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const pixelsPerFrame = pixelsPerSecond / fpsFloat;
-
- const labelIntervalSeconds = findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- minSpacingPx: MIN_LABEL_SPACING_PX,
- frameIntervals: LABEL_FRAME_INTERVALS,
- });
-
- const rawTickIntervalSeconds = findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- minSpacingPx: MIN_TICK_SPACING_PX,
- frameIntervals: TICK_FRAME_INTERVALS,
- });
-
- // ensure tick interval divides evenly into label interval so labels always land on ticks
- const tickIntervalSeconds = ensureTickDividesLabel({
- tickIntervalSeconds: rawTickIntervalSeconds,
- labelIntervalSeconds,
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- });
-
- return { labelIntervalSeconds, tickIntervalSeconds };
-}
-
-/**
- * adjusts tick interval to ensure it divides evenly into the label interval.
- * this guarantees labels always land on tick positions.
- */
-function ensureTickDividesLabel({
- tickIntervalSeconds,
- labelIntervalSeconds,
- pixelsPerFrame,
- pixelsPerSecond,
- fps,
-}: {
- tickIntervalSeconds: number;
- labelIntervalSeconds: number;
- pixelsPerFrame: number;
- pixelsPerSecond: number;
- fps: number;
-}): number {
- const labelFrames = Math.round(labelIntervalSeconds * fps);
- const tickFrames = Math.round(tickIntervalSeconds * fps);
-
- // if tick already divides label evenly, we're good
- if (labelFrames % tickFrames === 0) {
- return tickIntervalSeconds;
- }
-
- // find the smallest tick interval that divides the label interval and has adequate spacing
- for (const candidateFrames of TICK_FRAME_INTERVALS) {
- if (labelFrames % candidateFrames === 0) {
- const candidateSpacing = pixelsPerFrame * candidateFrames;
- // accept if spacing meets minimum threshold
- if (candidateSpacing >= MIN_TICK_SPACING_PX) {
- return candidateFrames / fps;
- }
- }
- }
-
- // try second-level tick intervals that divide the label interval cleanly
- for (const candidateSeconds of SECOND_MULTIPLIERS) {
- const ratio = labelIntervalSeconds / candidateSeconds;
- const isDivisor = Math.abs(ratio - Math.round(ratio)) < 0.0001;
- if (isDivisor) {
- const candidateSpacing = pixelsPerSecond * candidateSeconds;
- if (candidateSpacing >= MIN_TICK_SPACING_PX) {
- return candidateSeconds;
- }
- }
- }
-
- // fallback: use the label interval itself (no intermediate ticks)
- return labelIntervalSeconds;
-}
-
-function findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps,
- minSpacingPx,
- frameIntervals,
-}: {
- pixelsPerFrame: number;
- pixelsPerSecond: number;
- fps: number;
- minSpacingPx: number;
- frameIntervals: readonly number[];
-}): number {
- // try frame-level intervals first
- for (const frameInterval of frameIntervals) {
- const pixelSpacing = pixelsPerFrame * frameInterval;
- if (pixelSpacing >= minSpacingPx) {
- return frameInterval / fps;
- }
- }
-
- // then try second-level intervals
- for (const secondMultiplier of SECOND_MULTIPLIERS) {
- const pixelSpacing = pixelsPerSecond * secondMultiplier;
- if (pixelSpacing >= minSpacingPx) {
- return secondMultiplier;
- }
- }
-
- return 60;
-}
-
-/**
- * checks if a time should have a label based on the label interval.
- */
-export function shouldShowLabel({
- time,
- labelIntervalSeconds,
-}: {
- time: number;
- labelIntervalSeconds: number;
-}): boolean {
- const epsilon = 0.0001;
- const remainder = time % labelIntervalSeconds;
- return remainder < epsilon || remainder > labelIntervalSeconds - epsilon;
-}
-
-/**
- * formats a ruler tick label.
- *
- * - on second boundaries: "MM:SS" (e.g., "00:00", "01:30")
- * - between seconds: "Xf" (e.g., "5f", "15f")
- */
-export function formatRulerLabel({
- timeInSeconds,
- fps,
-}: {
- timeInSeconds: number;
- fps: FrameRate;
-}): string {
- if (isSecondBoundary({ timeInSeconds })) {
- return formatTimestamp({ timeInSeconds });
- }
-
- const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
- return `${frameWithinSecond}f`;
-}
-
-/**
- * checks if a time falls exactly on a second boundary.
- */
-function isSecondBoundary({
- timeInSeconds,
-}: {
- timeInSeconds: number;
-}): boolean {
- const epsilon = 0.0001;
- const remainder = timeInSeconds % 1;
- return remainder < epsilon || remainder > 1 - epsilon;
-}
-
-/**
- * gets the frame number within the current second.
- */
-function getFrameWithinSecond({
- timeInSeconds,
- fps,
-}: {
- timeInSeconds: number;
- fps: number;
-}): number {
- const fractionalPart = timeInSeconds % 1;
- return Math.round(fractionalPart * fps);
-}
-
-/**
- * formats a timestamp as MM:SS or H:MM:SS when >= 1 hour.
- */
-function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
- const totalSeconds = Math.round(timeInSeconds);
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = totalSeconds % 60;
-
- const mm = minutes.toString().padStart(2, "0");
- const ss = seconds.toString().padStart(2, "0");
-
- if (hours > 0) {
- return `${hours}:${mm}:${ss}`;
- }
-
- return `${mm}:${ss}`;
-}
diff --git a/apps/web/src/lib/timeline/scale.ts b/apps/web/src/lib/timeline/scale.ts
deleted file mode 100644
index 7770f59f..00000000
--- a/apps/web/src/lib/timeline/scale.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
-export const TIMELINE_ZOOM_MIN = 0.1;
-export const TIMELINE_ZOOM_MAX = 100;
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
deleted file mode 100644
index b2be95a9..00000000
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import type { Bookmark, SceneTracks } from "@/lib/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { getElementKeyframes } from "@/lib/animation";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-export interface SnapPoint {
- time: number;
- type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
- elementId?: string;
- trackId?: string;
-}
-
-export interface SnapResult {
- snappedTime: number;
- snapPoint: SnapPoint | null;
- snapDistance: number;
-}
-
-const DEFAULT_SNAP_THRESHOLD_PX = 10;
-
-export function findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- excludeElementIds,
- bookmarks = [],
- excludeBookmarkTime,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
- enableBookmarkSnapping = true,
- enableKeyframeSnapping = true,
-}: {
- tracks: SceneTracks;
- playheadTime: number;
- excludeElementId?: string;
- excludeElementIds?: Set<string>;
- bookmarks?: Array<Bookmark>;
- excludeBookmarkTime?: number;
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
- enableBookmarkSnapping?: boolean;
- enableKeyframeSnapping?: boolean;
-}): SnapPoint[] {
- const snapPoints: SnapPoint[] = [];
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const blockedElementIds = new Set(excludeElementIds);
- if (excludeElementId) {
- blockedElementIds.add(excludeElementId);
- }
-
- for (const track of orderedTracks) {
- for (const element of track.elements) {
- if (blockedElementIds.has(element.id)) continue;
-
- if (enableElementSnapping) {
- snapPoints.push(
- {
- time: element.startTime,
- type: "element-start",
- elementId: element.id,
- trackId: track.id,
- },
- {
- time: element.startTime + element.duration,
- type: "element-end",
- elementId: element.id,
- trackId: track.id,
- },
- );
- }
-
- if (enableKeyframeSnapping) {
- for (const keyframe of getElementKeyframes({
- animations: element.animations,
- })) {
- snapPoints.push({
- time: element.startTime + keyframe.time,
- type: "keyframe",
- elementId: element.id,
- trackId: track.id,
- });
- }
- }
- }
- }
-
- if (enablePlayheadSnapping) {
- snapPoints.push({ time: playheadTime, type: "playhead" });
- }
-
- if (enableBookmarkSnapping) {
- for (const bookmark of bookmarks) {
- if (
- excludeBookmarkTime != null &&
- bookmark.time === excludeBookmarkTime
- ) {
- continue;
- }
- snapPoints.push({ time: bookmark.time, type: "bookmark" });
- }
- }
-
- return snapPoints;
-}
-
-export function snapToNearestPoint({
- targetTime,
- snapPoints,
- zoomLevel,
- snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
-}: {
- targetTime: number;
- snapPoints: Array<SnapPoint>;
- zoomLevel: number;
- snapThreshold?: number;
-}): SnapResult {
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const thresholdInTicks = (snapThreshold / pixelsPerSecond) * TICKS_PER_SECOND;
-
- let closestSnapPoint: SnapPoint | null = null;
- let closestDistance = Infinity;
-
- for (const snapPoint of snapPoints) {
- const distance = Math.abs(targetTime - snapPoint.time);
- if (distance < thresholdInTicks && distance < closestDistance) {
- closestDistance = distance;
- closestSnapPoint = snapPoint;
- }
- }
-
- return {
- snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
- snapPoint: closestSnapPoint,
- snapDistance: closestDistance,
- };
-}
-
-export function snapElementEdge({
- targetTime,
- elementDuration,
- tracks,
- playheadTime,
- zoomLevel,
- excludeElementId,
- excludeElementIds,
- snapToStart = true,
- bookmarks = [],
-}: {
- targetTime: number;
- elementDuration: number;
- tracks: SceneTracks;
- playheadTime: number;
- zoomLevel: number;
- excludeElementId?: string;
- excludeElementIds?: Set<string>;
- snapToStart?: boolean;
- bookmarks?: Array<Bookmark>;
-}): SnapResult {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- excludeElementIds,
- bookmarks,
- });
-
- const effectiveTargetTime = snapToStart
- ? targetTime
- : targetTime + elementDuration;
-
- const snapResult = snapToNearestPoint({
- targetTime: effectiveTargetTime,
- snapPoints,
- zoomLevel,
- });
-
- if (!snapToStart && snapResult.snapPoint) {
- snapResult.snappedTime = snapResult.snappedTime - elementDuration;
- }
-
- return snapResult;
-}
diff --git a/apps/web/src/lib/timeline/track-capabilities.ts b/apps/web/src/lib/timeline/track-capabilities.ts
deleted file mode 100644
index 08ee841c..00000000
--- a/apps/web/src/lib/timeline/track-capabilities.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type {
- TimelineTrack,
- VideoTrack,
- AudioTrack,
- GraphicTrack,
- TextTrack,
- EffectTrack,
-} from "@/lib/timeline";
-
-export function canTrackHaveAudio(
- track: TimelineTrack,
-): track is VideoTrack | AudioTrack {
- return track.type === "audio" || track.type === "video";
-}
-
-export function canTrackBeHidden(
- track: TimelineTrack,
-): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
- return track.type !== "audio";
-}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts
deleted file mode 100644
index f1c2028e..00000000
--- a/apps/web/src/lib/timeline/track-element-update.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline";
-
-export function findTrackInSceneTracks({
- tracks,
- trackId,
-}: {
- tracks: SceneTracks;
- trackId: string;
-}): TimelineTrack | null {
- if (tracks.main.id === trackId) {
- return tracks.main;
- }
-
- return (
- tracks.overlay.find((track) => track.id === trackId) ??
- tracks.audio.find((track) => track.id === trackId) ??
- null
- );
-}
-
-export function updateTrackInSceneTracks({
- tracks,
- trackId,
- update,
-}: {
- tracks: SceneTracks;
- trackId: string;
- update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
-}): SceneTracks {
- if (tracks.main.id === trackId) {
- return {
- ...tracks,
- main: update(tracks.main),
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
- if (overlayTrackIndex >= 0) {
- return {
- ...tracks,
- overlay: tracks.overlay.map((track, index) =>
- index === overlayTrackIndex ? update(track) : track,
- ),
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
- if (audioTrackIndex >= 0) {
- return {
- ...tracks,
- audio: tracks.audio.map((track, index) =>
- index === audioTrackIndex ? update(track) : track,
- ),
- };
- }
-
- return tracks;
-}
-
-function updateElementInTrack<TTrack extends TimelineTrack>({
- track,
- elementId,
- update,
- elementPredicate,
-}: {
- track: TTrack;
- elementId: string;
- update: (element: TimelineElement) => TimelineElement;
- elementPredicate?: (element: TimelineElement) => boolean;
-}): TTrack {
- const nextElements = track.elements.map((element) => {
- if (element.id !== elementId) {
- return element;
- }
- if (elementPredicate && !elementPredicate(element)) {
- return element;
- }
- return update(element);
- });
-
- return {
- ...track,
- elements: nextElements,
- } as TTrack;
-}
-
-export function updateElementInSceneTracks({
- tracks,
- trackId,
- elementId,
- update,
- elementPredicate,
-}: {
- tracks: SceneTracks;
- trackId: string;
- elementId: string;
- update: (element: TimelineElement) => TimelineElement;
- elementPredicate?: (element: TimelineElement) => boolean;
-}): SceneTracks {
- if (tracks.main.id === trackId) {
- return {
- ...tracks,
- main: updateElementInTrack({
- track: tracks.main,
- elementId,
- update,
- elementPredicate,
- }),
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
- if (overlayTrackIndex >= 0) {
- return {
- ...tracks,
- overlay: tracks.overlay.map((track, index) =>
- index === overlayTrackIndex
- ? updateElementInTrack({
- track,
- elementId,
- update,
- elementPredicate,
- })
- : track,
- ),
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
- if (audioTrackIndex >= 0) {
- return {
- ...tracks,
- audio: tracks.audio.map((track, index) =>
- index === audioTrackIndex
- ? updateElementInTrack({
- track,
- elementId,
- update,
- elementPredicate,
- })
- : track,
- ),
- };
- }
-
- return tracks;
-}
diff --git a/apps/web/src/lib/timeline/tracks.ts b/apps/web/src/lib/timeline/tracks.ts
deleted file mode 100644
index 181b9fcf..00000000
--- a/apps/web/src/lib/timeline/tracks.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
- video: "Video track",
- text: "Text track",
- audio: "Audio track",
- graphic: "Graphic track",
- effect: "Effect track",
-} as const;
diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts
deleted file mode 100644
index ca9ac999..00000000
--- a/apps/web/src/lib/timeline/types.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-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: SceneTracks;
- 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)[];
- 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 type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-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;
- isSourceAudioEnabled?: boolean;
- hidden?: boolean;
- retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface ImageElement extends BaseTimelineElement {
- type: "image";
- mediaId: string;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
-export interface TextElement extends BaseTimelineElement {
- type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes<T extends ElementType[]>(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record<string, number>;
- 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: SceneTracks;
- 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/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts
deleted file mode 100644
index 2be73441..00000000
--- a/apps/web/src/lib/timeline/update-pipeline.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { clampAnimationsToDuration } from "@/lib/animation";
-import {
- clampRetimeRate,
- getSourceSpanAtClipTime,
- getTimelineDurationForSourceSpan,
-} from "@/lib/retime";
-import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline";
-import { isRetimableElement } from "@/lib/timeline";
-
-type ElementUpdateField = keyof TimelineElement | string;
-
-export interface ElementUpdateContext {
- tracks: SceneTracks;
- trackId: string;
-}
-
-interface ElementUpdateRuleResult {
- element: TimelineElement;
- changedFields?: ElementUpdateField[];
-}
-
-interface ElementUpdateRuleParams {
- element: TimelineElement;
- originalElement: TimelineElement;
- patch: Partial<TimelineElement>;
- context: ElementUpdateContext;
-}
-
-interface ElementUpdateRule {
- triggers: ElementUpdateField[];
- apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult;
-}
-
-const deriveRules: ElementUpdateRule[] = [
- {
- triggers: ["retime"],
- apply: ({ element, originalElement, patch }) => {
- if (!("retime" in patch) || !isRetimableElement(element)) {
- return { element };
- }
-
- const nextRetime = patch.retime
- ? {
- ...patch.retime,
- rate: clampRetimeRate({ rate: patch.retime.rate }),
- }
- : undefined;
-
- const sourceDuration = getSourceDuration({
- trimStart: originalElement.trimStart,
- trimEnd: originalElement.trimEnd,
- duration: originalElement.duration,
- sourceDuration: isRetimableElement(originalElement)
- ? originalElement.sourceDuration
- : undefined,
- retime: isRetimableElement(originalElement)
- ? originalElement.retime
- : undefined,
- });
- const visibleSourceSpan = Math.max(
- 0,
- sourceDuration - element.trimStart - element.trimEnd,
- );
- const nextDuration = getTimelineDurationForSourceSpan({
- sourceSpan: visibleSourceSpan,
- retime: nextRetime,
- });
-
- return {
- element: {
- ...element,
- retime: nextRetime,
- duration: nextDuration,
- },
- changedFields: ["retime", "duration"],
- };
- },
- },
-];
-
-const enforceRules: ElementUpdateRule[] = [
- {
- triggers: ["duration"],
- apply: ({ element }) => ({
- element: {
- ...element,
- animations: clampAnimationsToDuration({
- animations: element.animations,
- duration: element.duration,
- }),
- },
- }),
- },
- {
- triggers: ["startTime"],
- apply: ({ element, context }) => {
- const requestedStartTime = Math.max(0, element.startTime);
- if (context.trackId !== context.tracks.main.id) {
- return {
- element: {
- ...element,
- startTime: requestedStartTime,
- },
- };
- }
-
- const earliestElement = context.tracks.main.elements
- .filter((candidate) => candidate.id !== element.id)
- .reduce<TimelineElement | null>((earliest, candidate) => {
- if (!earliest || candidate.startTime < earliest.startTime) {
- return candidate;
- }
- return earliest;
- }, null);
-
- return {
- element: {
- ...element,
- startTime:
- !earliestElement || requestedStartTime <= earliestElement.startTime
- ? 0
- : requestedStartTime,
- },
- };
- },
- },
-];
-
-export function applyElementUpdate({
- element,
- patch,
- context,
-}: {
- element: TimelineElement;
- patch: Partial<TimelineElement>;
- context: ElementUpdateContext;
-}): TimelineElement {
- let nextElement = { ...element, ...patch } as TimelineElement;
- const changedFields = new Set(
- Object.keys(patch) as ElementUpdateField[],
- );
-
- for (const rule of deriveRules) {
- if (!shouldApplyRule({ rule, changedFields })) {
- continue;
- }
-
- const result = rule.apply({
- element: nextElement,
- originalElement: element,
- patch,
- context,
- });
- nextElement = result.element;
- for (const field of result.changedFields ?? []) {
- changedFields.add(field);
- }
- }
-
- for (const rule of enforceRules) {
- if (!shouldApplyRule({ rule, changedFields })) {
- continue;
- }
-
- nextElement = rule.apply({
- element: nextElement,
- originalElement: element,
- patch,
- context,
- }).element;
- }
-
- return nextElement;
-}
-
-function shouldApplyRule({
- rule,
- changedFields,
-}: {
- rule: ElementUpdateRule;
- changedFields: Set<ElementUpdateField>;
-}): boolean {
- return rule.triggers.some((trigger) => changedFields.has(trigger));
-}
-
-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
- );
-}
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
deleted file mode 100644
index 680f8d0d..00000000
--- a/apps/web/src/lib/timeline/zoom-utils.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import {
- BASE_TIMELINE_PIXELS_PER_SECOND,
- TIMELINE_ZOOM_MAX,
-} from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-const PADDING_MAX_RATIO = 0.75;
-const PADDING_MIN_RATIO = 0.15;
-const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
-
-export function getTimelineZoomMin({
- duration,
- containerWidth,
-}: {
- duration: number;
- containerWidth: number | null | undefined;
-}): number {
- const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1);
- const safeContainerWidth = containerWidth ?? 1000;
- const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
- const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
- const zoomToFit =
- availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
-
- return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
-}
-
-export function getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom,
-}: {
- containerWidth: number;
- zoomLevel: number;
- minZoom: number;
-}): number {
- const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
- const paddingTransitionPercent = Math.min(
- zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
- 1,
- );
- const paddingRatio =
- PADDING_MAX_RATIO -
- (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
-
- return containerWidth * paddingRatio;
-}
-
-export function getZoomPercent({
- zoomLevel,
- minZoom,
-}: {
- zoomLevel: number;
- minZoom: number;
-}): number {
- return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom);
-}
-
-/**
- * convert linear slider position (0-1) to exponential zoom level.
- * at low slider values, zoom changes are small. at high values, changes are large.
- */
-export function sliderToZoom({
- sliderPosition,
- minZoom,
- maxZoom = TIMELINE_ZOOM_MAX,
-}: {
- sliderPosition: number;
- minZoom: number;
- maxZoom?: number;
-}): number {
- const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
- return minZoom * (maxZoom / minZoom) ** clampedPosition;
-}
-
-/**
- * convert exponential zoom level to linear slider position (0-1).
- */
-export function zoomToSlider({
- zoomLevel,
- minZoom,
- maxZoom = TIMELINE_ZOOM_MAX,
-}: {
- zoomLevel: number;
- minZoom: number;
- maxZoom?: number;
-}): number {
- const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
- return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
-}
diff --git a/apps/web/src/lib/transcription/audio.ts b/apps/web/src/lib/transcription/audio.ts
deleted file mode 100644
index e2dc96d9..00000000
--- a/apps/web/src/lib/transcription/audio.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
-
-export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
-export const DEFAULT_STRIDE_SECONDS = 5;
diff --git a/apps/web/src/lib/transcription/caption-defaults.ts b/apps/web/src/lib/transcription/caption-defaults.ts
deleted file mode 100644
index fc277158..00000000
--- a/apps/web/src/lib/transcription/caption-defaults.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const DEFAULT_WORDS_PER_CAPTION = 3;
-export const MIN_CAPTION_DURATION_SECONDS = 0.8;
diff --git a/apps/web/src/lib/transcription/caption.ts b/apps/web/src/lib/transcription/caption.ts
deleted file mode 100644
index 5e23bad8..00000000
--- a/apps/web/src/lib/transcription/caption.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { TranscriptionSegment, CaptionChunk } from "@/lib/transcription/types";
-import {
- DEFAULT_WORDS_PER_CAPTION,
- MIN_CAPTION_DURATION_SECONDS,
-} from "@/lib/transcription/caption-defaults";
-
-export function buildCaptionChunks({
- segments,
- wordsPerChunk = DEFAULT_WORDS_PER_CAPTION,
- minDuration = MIN_CAPTION_DURATION_SECONDS,
-}: {
- segments: TranscriptionSegment[];
- wordsPerChunk?: number;
- minDuration?: number;
-}): CaptionChunk[] {
- const captions: CaptionChunk[] = [];
- let globalEndTime = 0;
-
- for (const segment of segments) {
- const words = segment.text.trim().split(/\s+/);
- if (words.length === 0 || (words.length === 1 && words[0] === "")) continue;
-
- const segmentDuration = segment.end - segment.start;
- const wordsPerSecond = words.length / segmentDuration;
-
- const chunks: string[] = [];
- for (let i = 0; i < words.length; i += wordsPerChunk) {
- chunks.push(words.slice(i, i + wordsPerChunk).join(" "));
- }
-
- let chunkStartTime = segment.start;
- for (const chunk of chunks) {
- const chunkWords = chunk.split(/\s+/).length;
- const chunkDuration = Math.max(minDuration, chunkWords / wordsPerSecond);
- const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
-
- captions.push({
- text: chunk,
- startTime: adjustedStartTime,
- duration: chunkDuration,
- });
-
- globalEndTime = adjustedStartTime + chunkDuration;
- chunkStartTime += chunkDuration;
- }
- }
-
- return captions;
-}
diff --git a/apps/web/src/lib/transcription/diagnostics.ts b/apps/web/src/lib/transcription/diagnostics.ts
deleted file mode 100644
index a1921699..00000000
--- a/apps/web/src/lib/transcription/diagnostics.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { DiagnosticsManager } from "@/core/managers/diagnostics-manager";
-import { timelineHasAudio } from "@/lib/media/audio";
-
-export const TRANSCRIPTION_DIAGNOSTICS_SCOPE = "transcription";
-
-export function registerTranscriptionDiagnostics({
- diagnostics,
-}: {
- diagnostics: DiagnosticsManager;
-}): void {
- diagnostics.register({
- id: "transcription.no_audio",
- scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE,
- severity: "caution",
- message: "No audio detected. Add a clip with audio to the timeline first.",
- check: (editor) => {
- const scene = editor.scenes.getActiveSceneOrNull();
- if (!scene) return false;
- return !timelineHasAudio({
- tracks: scene.tracks,
- mediaAssets: editor.media.getAssets(),
- });
- },
- });
-}
diff --git a/apps/web/src/lib/transcription/languages.ts b/apps/web/src/lib/transcription/languages.ts
deleted file mode 100644
index e5678145..00000000
--- a/apps/web/src/lib/transcription/languages.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export const LANGUAGES = [
- { code: "en", name: "English" },
- { code: "es", name: "Spanish" },
- { code: "it", name: "Italian" },
- { code: "fr", name: "French" },
- { code: "de", name: "German" },
- { code: "pt", name: "Portuguese" },
- { code: "ru", name: "Russian" },
- { code: "ja", name: "Japanese" },
- { code: "zh", name: "Chinese" },
-] as const;
-
-export type Language = (typeof LANGUAGES)[number];
-export type LanguageCode = Language["code"];
diff --git a/apps/web/src/lib/transcription/models.ts b/apps/web/src/lib/transcription/models.ts
deleted file mode 100644
index ee9047e7..00000000
--- a/apps/web/src/lib/transcription/models.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type {
- TranscriptionModel,
- TranscriptionModelId,
-} from "./types";
-
-export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
- {
- id: "whisper-tiny",
- name: "Tiny",
- huggingFaceId: "onnx-community/whisper-tiny",
- description: "Fastest, lower accuracy",
- },
- {
- id: "whisper-small",
- name: "Small",
- huggingFaceId: "onnx-community/whisper-small",
- description: "Good balance of speed and accuracy",
- },
- {
- id: "whisper-medium",
- name: "Medium",
- huggingFaceId: "onnx-community/whisper-medium",
- description: "Higher accuracy, slower",
- },
- {
- id: "whisper-large-v3-turbo",
- name: "Large v3 Turbo",
- huggingFaceId: "onnx-community/whisper-large-v3-turbo",
- description: "Best accuracy, requires WebGPU for good performance",
- },
-];
-
-export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
- "whisper-small";
diff --git a/apps/web/src/lib/transcription/supported-languages.ts b/apps/web/src/lib/transcription/supported-languages.ts
deleted file mode 100644
index 0adc1332..00000000
--- a/apps/web/src/lib/transcription/supported-languages.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { LANGUAGES as TRANSCRIPTION_LANGUAGES } from "./languages";
diff --git a/apps/web/src/lib/transcription/types.ts b/apps/web/src/lib/transcription/types.ts
deleted file mode 100644
index 7ab0da7f..00000000
--- a/apps/web/src/lib/transcription/types.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { LanguageCode } from "./languages";
-
-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;
-}
diff --git a/apps/web/src/lib/wasm/index.ts b/apps/web/src/lib/wasm/index.ts
deleted file mode 100644
index 60031a0e..00000000
--- a/apps/web/src/lib/wasm/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./ticks";
diff --git a/apps/web/src/lib/wasm/ticks.ts b/apps/web/src/lib/wasm/ticks.ts
deleted file mode 100644
index ababe6b3..00000000
--- a/apps/web/src/lib/wasm/ticks.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
-
-export const TICKS_PER_SECOND = _TICKS_PER_SECOND();